Skip to content
← Components

Agent Loaders

Source

agent-loaders.tsx
"use client";

import { useEffect, useState, type ReactNode } from "react";

/*
 * Four ways to say "the agent is working". Each is CSS-driven (the keyframes
 * live in the design system's tokens: shimmer, wave, hop) except the braille
 * spinner, which steps through glyphs like a terminal. All of them hold
 * still under prefers-reduced-motion, and each carries a text label for
 * screen readers.
 */

/** A light sweeping across muted text: "Thinking", "Searching the web". */
export function ShimmerText({ children, className = "" }: { children: ReactNode; className?: string }) {
  return (
    <span
      className={`animate-shimmer bg-clip-text text-transparent motion-reduce:animate-none motion-reduce:text-muted ${className}`}
      style={{
        backgroundImage:
          "linear-gradient(90deg, var(--muted) 0%, var(--muted) 40%, var(--ink) 50%, var(--muted) 60%, var(--muted) 100%)",
        backgroundSize: "200% 100%",
      }}
    >
      {children}
    </span>
  );
}

/** A 3×3 grid of pixels with a diagonal wave, a nod to the dither cards. */
export function DotGrid({ label = "Working", size = 4, className = "" }: { label?: string; size?: number; className?: string }) {
  return (
    <span role="status" aria-label={label} className={`inline-grid grid-cols-3 gap-[3px] ${className}`}>
      {Array.from({ length: 9 }, (_, i) => {
        const row = Math.floor(i / 3);
        const col = i % 3;
        return (
          <span
            key={i}
            aria-hidden
            className="animate-wave rounded-[1px] bg-ink motion-reduce:animate-none motion-reduce:opacity-60"
            style={{ width: size, height: size, animationDelay: `${(row + col) * 110}ms` }}
          />
        );
      })}
    </span>
  );
}

const BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];

/** The terminal spinner, stepping through braille glyphs at 80ms. */
export function BrailleSpinner({ label = "Working", className = "" }: { label?: string; className?: string }) {
  const [frame, setFrame] = useState(0);

  useEffect(() => {
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const id = setInterval(() => setFrame((f) => (f + 1) % BRAILLE.length), 80);
    return () => clearInterval(id);
  }, []);

  return (
    <span role="status" aria-label={label} className={`inline-block w-[1ch] font-mono text-accent-strong ${className}`}>
      <span aria-hidden>{BRAILLE[frame]}</span>
    </span>
  );
}

/** Three dots hopping in turn: the classic "typing" indicator. */
export function ThinkingDots({ label = "Thinking", className = "" }: { label?: string; className?: string }) {
  return (
    <span role="status" aria-label={label} className={`inline-flex items-center gap-1 ${className}`}>
      {[0, 1, 2].map((i) => (
        <span
          key={i}
          aria-hidden
          className="size-1.5 animate-hop rounded-full bg-ink motion-reduce:animate-none motion-reduce:opacity-60"
          style={{ animationDelay: `${i * 140}ms` }}
        />
      ))}
    </span>
  );
}

/** Elapsed seconds, for "Thinking · 4s". */
export function useElapsed(running = true) {
  const [seconds, setSeconds] = useState(0);
  useEffect(() => {
    if (!running) return;
    const start = Date.now();
    const id = setInterval(() => setSeconds(Math.floor((Date.now() - start) / 1000)), 250);
    return () => clearInterval(id);
  }, [running]);
  return seconds;
}

// The demo sizes itself to its container, not the viewport, so it fits
// half-width gallery cards and the 4:3 stage on phones without clipping.
function Tile({ name, children, index }: { name: string; children: ReactNode; index: number }) {
  return (
    <figure
      className="flex animate-enter flex-col overflow-hidden rounded-xl bg-surface shadow-md"
      style={{ animationDelay: `calc(${index} * var(--stagger))` }}
    >
      <div className="flex h-14 items-center justify-center px-4 @md:h-20">{children}</div>
      <figcaption className="border-t border-line px-3 py-1.5 font-mono text-meta text-muted @md:py-2">{name}</figcaption>
    </figure>
  );
}

export default function Demo() {
  const seconds = useElapsed();

  return (
    <div className="@container w-full max-w-xl">
      <div className="grid grid-cols-2 gap-2 @md:gap-3">
        <Tile name="ShimmerText" index={0}>
          <span className="text-body font-medium">
            <ShimmerText>Thinking</ShimmerText>
            <span className="ml-2 font-mono text-meta tabular-nums text-muted">{seconds}s</span>
          </span>
        </Tile>
        <Tile name="DotGrid" index={1}>
          <span className="flex items-center gap-2.5 text-body text-muted">
            <DotGrid label="Searching" />
            Searching
          </span>
        </Tile>
        <Tile name="BrailleSpinner" index={2}>
          <span className="flex items-center gap-2 font-mono text-[13px] text-ink">
            <BrailleSpinner label="Running tests" />
            npm test
          </span>
        </Tile>
        <Tile name="ThinkingDots" index={3}>
          <span className="flex items-center gap-2 rounded-full bg-panel px-3 py-2">
            <ThinkingDots />
          </span>
        </Tile>
      </div>
    </div>
  );
}