All patterns

Scan Sweep

A scan line travels across the surface being analyzed, then rests before the next pass.

aifuturisticenergeticautomatic · looping · intermediate · ~2.0s
Variant

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.

231 lines · react + motion only
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.

Document analysisImage recognitionReceipt scanningFraud review

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Search
    Inbox
    Docs
    Files
    Issues
    SearchNew
    Contract renewalInbox · matched “renewal terms”
    Supplier contract.docxFiles · matched “renewal”
    Q3 planning notesDocs · matched “renewal window”
    RID-412 renewal bannerIssues · matched “renewal”
    Search results

    Scanning treatment over the camera frame while the subject is identified.

Related patterns