# Beacon — Animated UI Component for React

> A dynamic-island status pill that fluidly morphs between size presets — a minimal indicator, a compact row, a wide media bar, and a tall card. Width, height, and corner radius are spring-coupled as one liquid shape that overshoots ~1% and beads down like mercury, while the content restacks in lockstep, the outgoing line sliding down and under the incoming. Sequenceable for guided flows, keyboard-expandable, and announced through an aria-live status region.

- Page: https://crucibleui.com/components/utility/beacon
- Category: [utility](https://crucibleui.com/components/utility)
- Tier: pro (one-time purchase, see https://crucibleui.com/pricing)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/beacon
```

Pro install requires adding the token-bearing `@crucible` registry to components.json (issued at https://crucibleui.com/account after purchase).

Import from `@/components/crucible/beacon`.

## Props (`BeaconProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `states?` | `BeaconState[]` | `a connecting → connected → live set` | The states this beacon can morph between. |
| `value?` | `string` | — | Controlled active state id. Pair with `onValueChange`. |
| `defaultValue?` | `string` | `first state` | Initial active state id when uncontrolled. |
| `onValueChange?` | `(id: string) => void` | — | Fires whenever the active state changes, from any source. |
| `sequence?` | `BeaconSequenceStep[]` | — | Ordered steps for a self-running flow, driven by `play()` or `autoPlay`. |
| `autoPlay?` | `boolean` | `false` | Start the sequence automatically on mount. |
| `loop?` | `boolean` | `false` | Loop the sequence instead of stopping at the last step. |
| `onSequenceEnd?` | `() => void` | — | Fires when a non-looping sequence reaches its end. |
| `stepDuration?` | `number` | `2200` | Fallback dwell per step, in ms, when a step omits `duration`. |
| `speed?` | `number` | `0.5` | 0–1 morph responsiveness — higher springs snappier between sizes. |
| `intensity?` | `number` | `0.5` | 0–1 mercury-bead overshoot on settle. `0` lands critically damped; `0.5` gives the signature ~1% overshoot before it beads down. |
| `expandable?` | `boolean` | `true` | Allow Enter/Space/click to expand a state that has a `body`. |
| `paused?` | `boolean` | `false` | Pause the running sequence and freeze the live indicator. |
| `politeness?` | `"polite" \| "assertive"` | `"polite"` | Live-region politeness for status announcements. |
| `colors?` | `{ /** Live-status indicator accent. @default "#5eead4" */ accent?: string; /** Pill shell fill. @default "#0b0b0d" */ shell?: string; /** Primary text color. @default "#f4f4f5" */ text?: string; }` | — | Colors — one cool accent over a neutral near-black shell. |
| `sizes?` | `Partial<Record<BeaconSize, Partial<BeaconDimensions>>>` | — | Per-size geometry overrides. |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"div">, "children" | "onChange">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Beacon, type BeaconHandle, type BeaconState } from "@/components/crucible/beacon";

const STATES: BeaconState[] = [
  {
    id: "connecting",
    size: "compact",
    indicator: "spinner",
    title: "Connecting",
    detail: "Reaching the server",
  },
  {
    id: "connected",
    size: "compact",
    indicator: "pulse",
    title: "Connected",
    detail: "Secure channel open",
    body: (
      <p>
        Everything checks out — latency 24 ms, no dropped packets. Press again
        to collapse.
      </p>
    ),
  },
  {
    id: "playing",
    size: "wide",
    indicator: "pulse",
    title: "Now playing",
    detail: "Ambient — Session 01",
    trailing: (
      <span className="flex items-end gap-[3px]" aria-hidden>
        <span className="h-2.5 w-[3px] rounded-full bg-white/70" />
        <span className="h-4 w-[3px] rounded-full bg-white/70" />
        <span className="h-3 w-[3px] rounded-full bg-white/70" />
      </span>
    ),
  },
];

const SEQUENCE = [
  { id: "connecting", duration: 1500 },
  { id: "connected", duration: 1900 },
  { id: "playing", duration: 2600 },
];

const BUTTONS: { id: string; label: string }[] = [
  { id: "connecting", label: "Connecting" },
  { id: "connected", label: "Connected" },
  { id: "playing", label: "Playing" },
];

export default function BeaconDemo() {
  const [value, setValue] = React.useState("connected");
  const ref = React.useRef<BeaconHandle>(null);

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full flex-col items-center justify-center gap-8 overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_50%_0%,rgba(255,255,255,0.05),transparent_70%)]"
      />

      <div className="relative z-10 flex min-h-[120px] items-center justify-center">
        <Beacon
          ref={ref}
          states={STATES}
          sequence={SEQUENCE}
          value={value}
          onValueChange={setValue}
        />
      </div>

      <div className="relative z-10 flex flex-col items-center gap-4 px-6">
        <div className="flex flex-wrap items-center justify-center gap-2">
          {BUTTONS.map((b) => (
            <button
              key={b.id}
              type="button"
              onClick={() => ref.current?.goTo(b.id)}
              aria-pressed={value === b.id}
              className={
                value === b.id
                  ? "rounded-full border border-white/25 bg-white/[0.12] px-3.5 py-1.5 text-xs font-medium text-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50"
                  : "rounded-full border border-white/10 bg-white/[0.03] px-3.5 py-1.5 text-xs font-medium text-white/60 transition-colors hover:bg-white/[0.08] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50"
              }
            >
              {b.label}
            </button>
          ))}
          <button
            type="button"
            onClick={() => ref.current?.play()}
            className="rounded-full border border-white/10 bg-white/[0.03] px-3.5 py-1.5 text-xs font-medium text-white/60 transition-colors hover:bg-white/[0.08] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50"
          >
            Run sequence
          </button>
        </div>
        <p className="max-w-xs text-center text-sm text-neutral-500">
          Switch states — the pill morphs like one liquid shape. Press it while
          Connected to expand the card; run the sequence to watch it flow on its
          own.
        </p>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.
