Status Glyph Cadence

Status

A 20-64px inline status glyph for agent work where the motion pattern itself is the signal — five states, five distinct cadences, no color and no swapped icon.

Install
npx shadcn add https://design.helpmarq.com/r/status-glyph-cadence.json
Source
registry/core/status-glyph-cadence/component.tsx
"use client";

// ---------------------------------------------------------------------------
// BeaconCadence — an inline status glyph for agent/AI work where the MOTION
// PATTERN is the signal, not color and not a swapped icon. One field of six
// SVG dots (a hexagonal ring) plus a center accent; each state gives that
// same field a distinct cadence: working spins it steadily, searching sweeps
// a bright cluster back and forth across a bounded arc, awaiting-input holds
// everything still but breathes a center dot, blocked snap-jitters the ring
// through a broken gap, done stops moving entirely on a complete ring with a
// settle-in checkmark. Every dot's resting (non-animated) opacity is chosen
// so the shape alone reads as that state — that resting frame is also
// exactly what prefers-reduced-motion falls back to, so reduced motion never
// collapses two states into the same frozen spinner. Pure CSS keyframes on
// SVG transforms, all ink from --foreground, zero dependencies.
// ---------------------------------------------------------------------------

export type BeaconCadenceState =
  | "working"
  | "searching"
  | "awaiting-input"
  | "blocked"
  | "done";

const STATE_LABEL: Record<BeaconCadenceState, string> = {
  working: "Working",
  searching: "Searching",
  "awaiting-input": "Awaiting input",
  blocked: "Blocked",
  done: "Done",
};

// six ring positions on a 24x24 viewBox, clock-style starting at 12 o'clock,
// clockwise — a hexagon reads cleanly at 20px where 8+ dots start to smear.
const RING: ReadonlyArray<readonly [number, number]> = [
  [12, 4],
  [18.93, 8],
  [18.93, 16],
  [12, 20],
  [5.07, 16],
  [5.07, 8],
];

// Per-state resting opacity for the six ring dots. This is the shape a
// screen-off, motion-off viewer gets, so each row has to be legible as its
// state entirely on its own — not a coincidence of where a rotation stopped.
const RING_OPACITY: Record<BeaconCadenceState, readonly number[]> = {
  working: [1, 0.83, 0.66, 0.49, 0.32, 0.15], // decaying comet trail
  searching: [1, 0.7, 0.15, 0.15, 0.15, 0.15], // small bright beam cluster
  "awaiting-input": [0.22, 0.22, 0.22, 0.22, 0.22, 0.22], // even + dim, center carries it
  blocked: [0, 0.55, 0.55, 0.55, 0.55, 0.55], // one dot missing — the ring can't close
  done: [1, 1, 1, 1, 1, 1], // complete
};

const DOT_R = 1.7;

const CSS = `
.ns-bc-ring{transform-box:fill-box;transform-origin:center;}
.ns-bc-center{transform-box:fill-box;transform-origin:center;}
.ns-bc-check{transform-box:fill-box;transform-origin:center;}
.ns-bc-spin{animation:ns-bc-spin 1.2s linear infinite;}
.ns-bc-sweep{animation:ns-bc-sweep 1.7s cubic-bezier(.45,0,.55,1) infinite;}
.ns-bc-tremor{animation:ns-bc-tremor 1.5s steps(1) infinite;}
.ns-bc-pulse{animation:ns-bc-pulse 1.9s ease-in-out infinite;}
.ns-bc-settle{animation:ns-bc-settle .42s cubic-bezier(.34,1.56,.64,1) both;}
@keyframes ns-bc-spin{to{transform:rotate(360deg)}}
@keyframes ns-bc-sweep{
  0%,100%{transform:rotate(-35deg)}
  50%{transform:rotate(35deg)}
}
@keyframes ns-bc-tremor{
  0%,58%,100%{transform:rotate(0deg)}
  15%{transform:rotate(9deg)}
  30%{transform:rotate(-5deg)}
  44%{transform:rotate(3deg)}
}
@keyframes ns-bc-pulse{
  0%,100%{transform:scale(.8);opacity:.5}
  50%{transform:scale(1.22);opacity:1}
}
@keyframes ns-bc-settle{
  from{transform:scale(.5);opacity:0}
  to{transform:scale(1);opacity:1}
}
@media (prefers-reduced-motion: reduce){
  .ns-bc-spin,.ns-bc-sweep,.ns-bc-tremor,.ns-bc-pulse,.ns-bc-settle{animation:none;}
}
`;

const RING_ANIMATION_CLASS: Record<BeaconCadenceState, string> = {
  working: "ns-bc-spin",
  searching: "ns-bc-sweep",
  "awaiting-input": "",
  blocked: "ns-bc-tremor",
  done: "",
};

export interface BeaconCadenceProps {
  /** which cadence to render — the motion pattern IS the state, color never is */
  state: BeaconCadenceState;
  /** glyph size in px, meant to sit inline beside a text label. legible 20-64. */
  size?: number;
  /**
   * accessible text announced via the component's own aria-live region on
   * every state change (e.g. "Solving…"). Defaults to a plain state name.
   * If the consumer already renders a visible adjacent label that updates
   * with state, either pass that same string here (redundant but harmless
   * for sighted users) or aria-hide their own label to avoid a double
   * announcement — the glyph is the one guaranteed to announce transitions.
   */
  label?: string;
  className?: string;
}

export function BeaconCadence({
  state,
  size = 24,
  label,
  className = "",
}: BeaconCadenceProps) {
  const opacities = RING_OPACITY[state];
  const ringClass = RING_ANIMATION_CLASS[state];

  return (
    <span
      role="status"
      aria-live="polite"
      className={`inline-flex shrink-0 items-center justify-center ${className}`}
      style={{ width: size, height: size }}
    >
      <style>{CSS}</style>
      <svg
        key={state}
        viewBox="0 0 24 24"
        width={size}
        height={size}
        aria-hidden="true"
        focusable="false"
      >
        <g className={ringClass ? `ns-bc-ring ${ringClass}` : "ns-bc-ring"}>
          {RING.map(([x, y], i) => (
            <circle
              key={i}
              cx={x}
              cy={y}
              r={DOT_R}
              style={{ fill: "var(--foreground)", opacity: opacities[i] }}
            />
          ))}
        </g>

        {state === "awaiting-input" && (
          <circle
            className="ns-bc-center ns-bc-pulse"
            cx={12}
            cy={12}
            r={2.8}
            style={{ fill: "var(--foreground)" }}
          />
        )}

        {state === "done" && (
          <path
            className="ns-bc-check"
            d="M8.3 12.4l2.5 2.5 4.9-5.4"
            fill="none"
            stroke="var(--foreground)"
            strokeWidth="1.8"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        )}
      </svg>
      <span className="sr-only">{label ?? STATE_LABEL[state]}</span>
    </span>
  );
}
Build spec

An inline status glyph, 20-64px, meant to sit beside a Geist Sans text label like 'Solving…'. The whole point: the motion pattern encodes state, not color and not a swapped icon, so it stays legible to someone who cannot see color or is scanning quickly. It renders one field of six SVG dots arranged in a hexagonal ring (24x24 viewBox, clock positions starting at 12 o'clock) plus a center element used by some states, all filled from var(--foreground) with per-dot opacity as the only other channel. Five states, each a genuinely distinct cadence built from the same dot field: working spins the whole ring steadily at constant linear speed (360deg/1.2s), with the six dots pre-set to a decaying comet-trail opacity gradient so even a single frame reads as motion-in-one-direction; searching leaves the ring stationary as a base transform but oscillates it back and forth across a bounded 70deg arc (cubic-bezier ease, 1.7s) with only two adjacent dots bright and the rest dim, reading as a sweeping beam rather than a rotation; awaiting-input holds the entire ring completely still and uniformly dim and instead breathes a separate center dot (scale 0.8-1.22, opacity 0.5-1, ease-in-out, 1.9s) — the only state with motion at the center rather than the ring; blocked holds the ring at one fixed dot permanently missing (a gap the ring can't close) and snap-jitters the whole ring through a few degrees on a stepped (not eased) timing function, a juddery stall distinct from every smooth cadence; done stops moving entirely — full ring, all six dots at full opacity, plus a checkmark path that plays a one-time spring settle-in (cubic-bezier(.34,1.56,.64,1), 420ms) on mount via a React key on state, then holds static. Every state's non-animated resting opacity pattern (comet trail / bright cluster / uniform dim / broken gap / complete ring) is deliberately chosen to be legible completely on its own, because under prefers-reduced-motion every animation class is simply removed via a CSS media query and that resting pattern is exactly what's left — five distinct static glyphs, never five states collapsed into one frozen spinner. Exposes state to assistive tech itself: the root is role=status aria-live=polite wrapping an aria-hidden SVG and a visually hidden (sr-only) text node that reads a plain label ('Working' / 'Searching' / 'Awaiting input' / 'Blocked' / 'Done') by default, overridable via a label prop so it can announce the same copy the consumer displays visually (e.g. 'Solving…') — a moving glyph alone tells a screen reader nothing, this makes every transition an announced live-region update. Props: state (the five-value union, required), size (px, default 24, legible from 20 to 64 since all geometry is in viewBox units and scales proportionally with no fine detail that vanishes small), label (accessible text override), className. Pure CSS keyframes driving SVG transforms and opacity, zero dependencies, no canvas.

Tags
statusindicatoragentsvgmotionaria-liveloadingicon