Skip to main content

ns-ui

Status Glyph Cadence

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.

Usage

registry/core/status-glyph-cadence/demo.tsx
"use client";

import { useEffect, useState } from "react";
import { BeaconCadence, type BeaconCadenceState } from "./component";

// the active run cycles through the full cadence lifecycle on its own —
// this is the realistic case: an agent narrating its own progress, no
// pointer or keyboard input in the loop at all.
const RUN: { state: BeaconCadenceState; text: string; ms: number }[] = [
  { state: "working", text: "Solving the integral…", ms: 2600 },
  { state: "searching", text: "Searching prior runs…", ms: 2400 },
  { state: "blocked", text: "Blocked — rate limited", ms: 2200 },
  { state: "awaiting-input", text: "Awaiting your input", ms: 2600 },
  { state: "working", text: "Applying the fix…", ms: 2200 },
  { state: "done", text: "Task complete", ms: 3200 },
];

// fixed reference rows -- all five states, always on screen regardless of
// where the cycling hero above happens to land, so the full cadence set is
// visible in a single still frame (not just whichever state fired last).
const RUNS: { state: BeaconCadenceState; text: string; time: string }[] = [
  { state: "working", text: "Refactoring auth middleware", time: "0:18" },
  { state: "searching", text: "Indexing changelog", time: "0:41" },
  { state: "blocked", text: "Missing API key", time: "1:12" },
  { state: "awaiting-input", text: "Confirm before deploy", time: "2:03" },
  { state: "done", text: "Nightly backup", time: "6:00" },
];

export default function BeaconCadenceDemo() {
  const [step, setStep] = useState(0);
  const active = RUN[step % RUN.length]!;

  useEffect(() => {
    const t = window.setTimeout(() => setStep((s) => s + 1), active.ms);
    return () => window.clearTimeout(t);
  }, [step, active.ms]);

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-12 px-6">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / status-glyph-cadence
      </p>

      <div className="w-full max-w-sm rounded-xl border border-border bg-surface">
        <div className="flex items-center gap-4 border-b border-border px-5 py-5">
          <BeaconCadence state={active.state} size={60} />
          <div className="min-w-0">
            <p className="truncate text-sm text-foreground">{active.text}</p>
            <p className="font-mono text-[10px] uppercase tracking-[0.14em] text-ns-muted">
              current run
            </p>
          </div>
        </div>

        <ul className="flex flex-col divide-y divide-border">
          {RUNS.map((row) => (
            <li key={row.text} className="flex items-center gap-3 px-5 py-3">
              <BeaconCadence state={row.state} size={20} />
              <span className="min-w-0 flex-1 truncate text-xs text-foreground">
                {row.text}
              </span>
              <span className="font-mono text-[10px] text-ns-muted">
                {row.time}
              </span>
            </li>
          ))}
        </ul>
      </div>

      <p className="font-mono text-[10px] text-ns-muted">
        motion is the signal — five distinct cadences, no color, no swapped icon
      </p>
    </div>
  );
}

Component

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;
  /** extra classes merged onto the rendered root element */
  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>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/status-glyph-cadence.tsx, so point the import there.

Use when an agent's work state has to survive being read by someone who cannot see color or is only glancing, so each state is a different motion cadence over the same six-dot ring rather than a swapped icon or a red/green change, and blocked is drawn as a ring that cannot close; pick status-sphere-dots instead when the same indicator must also hold up at 200px as a centerpiece rather than only at 20-64px beside a label, or loader-ink-blob when the states are conversational (listening, speaking) rather than task states.

Install

npx shadcn add https://design.helpmarq.com/r/status-glyph-cadence.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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.

Props

PropTypeDefaultDescription
stateBeaconCadenceState—which cadence to render — the motion pattern IS the state, color never is
size?number24glyph size in px, meant to sit inline beside a text label. legible 20-64.
label?string—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.
className?string—extra classes merged onto the rendered root element