All patterns

Thinking Shimmer Label

A band of light travels through a status word while the letters stay perfectly still.

aisubtleelegantautomatic · 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.

176 lines · react + motion only
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import {
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
} from "motion/react";

/**
 * Vibary · Thinking Shimmer Label
 *
 * A status word that stays perfectly still while a band of light travels
 * through its letters. The glyphs never move, never resize and never
 * re-flow.
 *
 * How: a narrow window with a soft-edged mask slides across the word,
 * and a second, full-strength copy of the word inside it is translated
 * by exactly the opposite amount — so the copy stays pinned over the
 * resting text while the window it is seen through moves. Both values
 * are transforms, which is what keeps the whole effect on the
 * compositor.
 *
 * Self-contained: depends only on `react` and `motion`. The lit copy
 * inherits `currentColor`, so the highlight is the page's own ink on a
 * light theme and on a dark one.
 * Works with zero props; tune via `variant`, `label`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ThinkingShimmerLabelProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** The word held while the model works. */
  label?: string;
  /** Font size in px. The band scales with the word, not with this. */
  fontSize?: number;
  /** Announced to assistive technology. */
  statusLabel?: string;
};

type VariantConfig = {
  /** Opacity the word rests at between passes. */
  rest: number;
  /** Width of the travelling band, as a share of the word. */
  band: number;
  /** Seconds for one pass across the word. */
  passSeconds: number;
  /** Seconds of stillness between passes. */
  restSeconds: number;
};

// Quality rule: the word is text, so it does not scale, translate or
// bounce — not by a pixel. Variants change how wide the band is and how
// often it passes, never how the letters sit. There is no spring here on
// purpose: a highlight that eases out mid-word looks like a stutter, so
// the travel is linear and the pause happens between passes.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A slow, wide wash that barely registers. For a label that may sit
  // on screen for a minute.
  subtle: { rest: 0.52, band: 0.75, passSeconds: 2.1, restSeconds: 0.5 },
  // A readable pass with a short breath between. The all-purpose setting.
  default: { rest: 0.42, band: 0.55, passSeconds: 1.5, restSeconds: 0.35 },
  // A tighter, quicker band with no pause, for a hero waiting state.
  playful: { rest: 0.34, band: 0.4, passSeconds: 1.1, restSeconds: 0 },
};

/** Soft on both edges so the band has no visible boundary — the light
 *  arrives and leaves rather than switching on. */
const BAND_MASK =
  "linear-gradient(90deg, transparent 0%, #000 42%, #000 58%, transparent 100%)";

export default function ThinkingShimmerLabel({
  variant = "default",
  label = "Thinking",
  fontSize = 13.5,
  statusLabel = "Working on your request",
}: ThinkingShimmerLabelProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const wordRef = useRef<HTMLSpanElement | null>(null);
  const [width, setWidth] = useState(0);

  // The travel distance is the word's own width, so the band crosses
  // exactly the letters and nothing else.
  useLayoutEffect(() => {
    setWidth(wordRef.current?.offsetWidth ?? 0);
  }, [label, fontSize]);

  const band = Math.max(20, width * cfg.band);
  const travel = useMotionValue(-band);
  // The lit copy is pushed back by whatever the window moved forward,
  // which is what holds it exactly over the resting word.
  const counter = useTransform(travel, (value) => -value);

  useEffect(() => {
    if (reduceMotion || width === 0) return;
    travel.set(-band);
    const controls = animate(travel, [-band, width], {
      duration: cfg.passSeconds,
      repeat: Infinity,
      repeatDelay: cfg.restSeconds,
      ease: "linear",
    });
    return () => controls.stop();
  }, [reduceMotion, width, band, cfg.passSeconds, cfg.restSeconds, travel]);

  const wordStyle = {
    fontSize,
    fontWeight: 600,
    letterSpacing: 0.2,
    lineHeight: 1.25,
    whiteSpace: "pre" as const,
  };

  // Reduced motion: the word alone, at full strength. The status is the
  // information; the travelling light is presentation.
  if (reduceMotion) {
    return (
      <span
        role="status"
        aria-label={statusLabel}
        style={{ ...wordStyle, opacity: 0.75 }}
      >
        {label}
      </span>
    );
  }

  return (
    <span
      ref={wordRef}
      role="status"
      aria-label={statusLabel}
      style={{ position: "relative", display: "inline-block", ...wordStyle }}
    >
      {/* The word itself, in the flow, at rest. It sets the box; nothing
          is ever laid out twice. */}
      <span style={{ opacity: cfg.rest }}>{label}</span>

      {/* The window: a soft-edged slot that slides across the word. */}
      <motion.span
        aria-hidden
        style={{
          position: "absolute",
          left: 0,
          top: 0,
          bottom: 0,
          width: band,
          x: travel,
          overflow: "hidden",
          maskImage: BAND_MASK,
          maskSize: "100% 100%",
          maskRepeat: "no-repeat",
          pointerEvents: "none",
        }}
      >
        {/* The lit copy, counter-translated so it never leaves the exact
            position of the resting word. */}
        <motion.span
          style={{
            position: "absolute",
            left: 0,
            top: 0,
            x: counter,
            color: "inherit",
            ...wordStyle,
          }}
        >
          {label}
        </motion.span>
      </motion.span>
    </span>
  );
}

About this pattern

The quietest way to say a model is still working. A second, full-strength copy of the word sits pinned exactly over the resting one and is revealed only through a soft band that slides across it, so the glyphs never move, resize or re-flow — the only animated value in the component is the position of that mask. The lit copy inherits the page's own ink, so it reads on a light theme and on a dark one without a colour to configure. Linear travel with a breath between passes: an eased highlight stalling mid-word looks like a stutter.

Model is still workingReply being preparedBackground job in flightStreaming has not started yet

Where it shows up

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

  • Summarise the supplier contract and flag anything unusual.
    Working through the document…
    Ask a follow-up
    AI assistant

    A status caption carries a travelling highlight while a reply is prepared.

Related patterns