Scan Sweep
A scan line travels across the surface being analyzed, then rests before the next pass.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import type { ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Scan Sweep
*
* A scan line travelling across the surface a model is reading, with a
* rest between passes so the loop reads as repeated work rather than a
* spinner parked on top of content.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Works with zero props (a sample document renders inside); wrap your
* own card or image by passing `children`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ScanSweepProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** The surface being analyzed. Falls back to a sample document. */
children?: ReactNode;
/** Line, halo and frame color. */
color?: string;
/** Corner radius of the scanned frame — match the card underneath. */
radius?: number;
/** Accessible status label announced to screen readers. */
label?: string;
};
type VariantConfig = {
/** One full pass, top to bottom. */
cycleSeconds: number;
/** Pause after each pass — the settle that stops it feeling frantic. */
restSeconds: number;
/** Peak alpha (%) of the line itself. */
lineStrength: number;
/** Peak alpha (%) of the halo either side of the line. */
haloStrength: number;
/** Corner brackets frame the scanned region; noise in dense UI. */
brackets: boolean;
};
// Variants trade speed and brightness, never travel distance: the line
// always crosses the whole surface, because a sweep that stops short
// looks like it missed something.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Slow, dim, unframed — safe to run behind text someone is reading.
subtle: {
cycleSeconds: 2.6,
restSeconds: 0.5,
lineStrength: 55,
haloStrength: 8,
brackets: false,
},
// Legible at a glance without taking over the card. The all-purpose setting.
default: {
cycleSeconds: 2.0,
restSeconds: 0.35,
lineStrength: 80,
haloStrength: 14,
brackets: true,
},
// Quicker passes, brighter line — for a hero "analyzing" moment.
playful: {
cycleSeconds: 1.5,
restSeconds: 0.18,
lineStrength: 100,
haloStrength: 20,
brackets: true,
},
};
// The four corner brackets, as border edges rather than glyphs.
const CORNERS = [
{ top: 10, left: 10, borderTopWidth: 1.5, borderLeftWidth: 1.5 },
{ top: 10, right: 10, borderTopWidth: 1.5, borderRightWidth: 1.5 },
{ bottom: 10, left: 10, borderBottomWidth: 1.5, borderLeftWidth: 1.5 },
{ bottom: 10, right: 10, borderBottomWidth: 1.5, borderRightWidth: 1.5 },
] as const;
/** Sample surface so the file renders on its own, with no props. */
function SampleDocument() {
return (
<div
style={{
width: 260,
padding: 16,
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div
style={{
flexShrink: 0,
width: 34,
height: 42,
borderRadius: 5,
background:
"linear-gradient(160deg, rgba(127,127,140,0.30), rgba(127,127,140,0.12))",
}}
/>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 600 }}>
Q3-revenue-report.pdf
</div>
<div style={{ fontSize: 12, opacity: 0.55, marginTop: 2 }}>
18 pages · 2.4 MB
</div>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
{[100, 92, 74].map((width) => (
<div
key={width}
style={{
height: 7,
width: `${width}%`,
borderRadius: 4,
background: "rgba(127,127,140,0.20)",
}}
/>
))}
</div>
</div>
);
}
export default function ScanSweep({
variant = "default",
children,
color = "#7C7CF0",
radius = 16,
label = "Analyzing",
}: ScanSweepProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// One color prop drives line, halo and frame: color-mix keeps the
// whole overlay in the caller's hue without parsing hex by hand.
const tint = (percent: number) =>
`color-mix(in srgb, ${color} ${percent}%, transparent)`;
// The band is as tall as the frame and carries the line at its own
// midpoint, so travelling ±62% of its height sweeps the line just past
// both edges — no measurement, no layout reads, pure transform.
const band = (
<motion.div
aria-hidden
initial={{ y: "-62%" }}
animate={{ y: "62%" }}
transition={{
duration: cfg.cycleSeconds,
// easeInOut so the pass eases out of the top edge and settles into
// the bottom one; linear travel reads as machinery.
ease: "easeInOut",
repeat: Infinity,
repeatDelay: cfg.restSeconds,
}}
style={{
position: "absolute",
inset: 0,
background: `linear-gradient(to bottom,
${tint(0)} 41%,
${tint(cfg.haloStrength)} 47.5%,
${tint(cfg.lineStrength * 0.55)} 49.4%,
${tint(cfg.lineStrength)} 50%,
${tint(cfg.lineStrength * 0.55)} 50.6%,
${tint(cfg.haloStrength)} 52.5%,
${tint(0)} 59%)`,
}}
/>
);
// Reduced motion: the line stays, parked at the midpoint. The frame
// keeps saying "this region is being read" — only the travel is gone.
const parkedLine = (
<span
aria-hidden
style={{
position: "absolute",
left: 0,
right: 0,
top: "50%",
height: 1,
background: tint(cfg.lineStrength),
}}
/>
);
return (
<div
role="status"
aria-label={label}
aria-busy="true"
style={{
position: "relative",
overflow: "hidden",
borderRadius: radius,
border: `1px solid ${tint(22)}`,
background: "rgba(127,127,140,0.06)",
}}
>
{children ?? <SampleDocument />}
<div
aria-hidden
style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
>
{reduceMotion ? parkedLine : band}
{cfg.brackets &&
CORNERS.map((corner, index) => (
<span
key={index}
style={{
position: "absolute",
width: 12,
height: 12,
borderStyle: "solid",
borderWidth: 0,
borderColor: tint(45),
...corner,
}}
/>
))}
</div>
</div>
);
}About this pattern
Attach it to whatever the model is reading — an uploaded document, a photo, a receipt, a chart. The line crosses the region once, pauses, and goes again, so the loop reads as repeated passes of work instead of a spinner parked on top of content. Because the sweep is scoped to one surface it also answers the question a global loader can't: which thing is being processed right now.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Search results
Scanning treatment over the camera frame while the subject is identified.
Related patterns
- Voice Waveform ListenBars rise and fall against the room while the microphone is open, so the mic reads as live.
- Embedding Cluster SettleScattered points drift into labelled groups, the halos and captions landing after them.
- Progressive Image GenerationA generated picture resolves from blur to sharp across a few discrete refinement passes.