All patterns

Shimmer Sweep

A soft highlight travels across placeholder blocks, each one a beat behind the last.

loadingsubtleelegantautomatic · looping · starter · ~1.5s
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.

218 lines · react + motion only
import type { CSSProperties } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Shimmer Sweep
 *
 * A soft highlight travels across placeholder blocks, each one a beat
 * behind the last, for waits with no progress to report.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Works with zero props; tune via `variant`, `rows`, `width`, colors.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ShimmerSweepProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** How many placeholder rows to render. */
  rows?: number;
  /** Block width — px number or any CSS length. */
  width?: number | string;
  /** Placeholder fill. A translucent neutral, so it reads on light and dark. */
  placeholderColor?: string;
  /** The travelling highlight. */
  highlightColor?: string;
  /** Accessible status label announced to screen readers. */
  label?: string;
};

type VariantConfig = {
  sweepSeconds: number;
  repeatDelay: number;
  stagger: number;
  highlightOpacity: number;
  /** Half-width of the soft band, in % of the block. Narrower reads crisper. */
  band: number;
};

// Quality rule: this loops for as long as the request takes, so it has to
// stay boring at the tenth repetition. Variants change the pace and the
// brightness of the band, never its amplitude of movement.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Slow, wide, barely there. For placeholders behind other live content.
  subtle: {
    sweepSeconds: 1.9,
    repeatDelay: 0.55,
    stagger: 0.07,
    highlightOpacity: 0.16,
    band: 28,
  },
  // The all-purpose setting: readable across a list without pulling focus.
  default: {
    sweepSeconds: 1.5,
    repeatDelay: 0.35,
    stagger: 0.09,
    highlightOpacity: 0.26,
    band: 22,
  },
  // Quicker and crisper, with almost no pause between passes — for a
  // full-panel placeholder that is the only thing on screen.
  playful: {
    sweepSeconds: 1.15,
    repeatDelay: 0.15,
    stagger: 0.11,
    highlightOpacity: 0.36,
    band: 16,
  },
};

// Varied line lengths per row: identical bars read as a printed form
// rather than as content that hasn't arrived yet.
const ROW_WIDTHS: ReadonlyArray<readonly [string, string]> = [
  ["72%", "46%"],
  ["58%", "38%"],
  ["66%", "52%"],
];

const PLACEHOLDER_COLOR = "rgba(127, 127, 140, 0.18)";

export default function ShimmerSweep({
  variant = "default",
  rows = 3,
  width = 288,
  placeholderColor = PLACEHOLDER_COLOR,
  highlightColor = "#FFFFFF",
  label = "Loading",
}: ShimmerSweepProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // Reduced motion: the placeholders stay, the light stops. The blocks
  // and the status label already say "not here yet" on their own.
  const sweeping = !reduceMotion;

  return (
    <div
      role="status"
      aria-label={label}
      style={{
        width,
        display: "flex",
        flexDirection: "column",
        gap: 16,
      }}
    >
      {Array.from({ length: rows }, (_, rowIndex) => {
        const [titleWidth, metaWidth] =
          ROW_WIDTHS[rowIndex % ROW_WIDTHS.length];
        // Blocks are delayed in reading order, so the highlight reads as
        // one light source crossing the panel instead of every box
        // blinking at once.
        const firstBlock = rowIndex * 3;

        return (
          <div
            key={rowIndex}
            style={{ display: "flex", alignItems: "center", gap: 12 }}
          >
            <Block
              cfg={cfg}
              color={placeholderColor}
              highlight={highlightColor}
              sweeping={sweeping}
              delayIndex={firstBlock}
              style={{
                width: 44,
                height: 44,
                borderRadius: 12,
                flexShrink: 0,
              }}
            />
            <div
              style={{
                flex: 1,
                display: "flex",
                flexDirection: "column",
                gap: 9,
              }}
            >
              <Block
                cfg={cfg}
                color={placeholderColor}
                highlight={highlightColor}
                sweeping={sweeping}
                delayIndex={firstBlock + 1}
                style={{ width: titleWidth, height: 11 }}
              />
              <Block
                cfg={cfg}
                color={placeholderColor}
                highlight={highlightColor}
                sweeping={sweeping}
                delayIndex={firstBlock + 2}
                style={{ width: metaWidth, height: 9 }}
              />
            </div>
          </div>
        );
      })}
    </div>
  );
}

/** One placeholder block and the light passing over it. */
function Block({
  cfg,
  color,
  highlight,
  sweeping,
  delayIndex,
  style,
}: {
  cfg: VariantConfig;
  color: string;
  highlight: string;
  sweeping: boolean;
  delayIndex: number;
  style: CSSProperties;
}) {
  return (
    <div
      style={{
        position: "relative",
        overflow: "hidden",
        background: color,
        borderRadius: 6,
        ...style,
      }}
    >
      {sweeping ? (
        <motion.div
          aria-hidden
          initial={{ x: "-100%" }}
          animate={{ x: "100%" }}
          transition={{
            duration: cfg.sweepSeconds,
            repeat: Infinity,
            repeatDelay: cfg.repeatDelay,
            delay: delayIndex * cfg.stagger,
            // Eased rather than linear: the band drifts in and out at the
            // edges instead of hitting them at full speed.
            ease: "easeInOut",
          }}
          style={{
            position: "absolute",
            inset: 0,
            opacity: cfg.highlightOpacity,
            // Tilted a few degrees so it reads as light falling across
            // the surface, not as a scanner bar.
            background: `linear-gradient(100deg, transparent ${
              50 - cfg.band
            }%, ${highlight} 50%, transparent ${50 + cfg.band}%)`,
          }}
        />
      ) : null}
    </div>
  );
}

About this pattern

The waiting texture for feeds and message lists with no progress to report. One soft band of light crosses each placeholder in turn; because the blocks are offset in reading order, it reads as a single light source moving over the panel rather than every box blinking together. It is deliberately quiet — this loops for as long as the request takes, so it has to stay unremarkable at the tenth repetition as well as the first.

Feed placeholderInbox loadingSearch results pendingPanel skeleton

Where it shows up

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

  • OverviewLast 30 days
    Revenue$48,210+12.4%
    Orders1,284+3.1%
    Refunds$1,940−0.8%
    Revenue by day
    Dashboard

    The open-source treatment that made the travelling highlight the default skeleton look.

Related patterns