# Wire — Animated Data Visualization for React

> A self-looping activity feed for marketing heroes: entries land with one confident spring and a hairline flash that cools in 400ms, siblings shuffle down, and the oldest retires under an edge fade — all on seeded, jittered news-wire cadence. Decorative counterpart to dispatch; hover pauses the feed.

- Page: https://crucibleui.com/components/data/wire
- Category: [data](https://crucibleui.com/components/data)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

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

Registry JSON: https://crucibleui.com/r/wire.json

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

## Props (`WireProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items?` | `WireItem[]` | `a built-in set of generic product events` | Events the feed cycles through. The loop order is a seeded shuffle of this array (deterministic per `seed`), so the cadence never reads scripted. |
| `interval?` | `number` | `2.4` | Mean seconds between inserts while looping. |
| `jitter?` | `number` | `0.35` | Jitter applied to each gap as a fraction of `interval` (0 = metronomic, 0.5 = gaps range ±50%). Seeded — the same rhythm every load. |
| `direction?` | `"top" \| "bottom"` | `"top"` | Where new entries land: "top" (news wire) or "bottom" (chat). |
| `maxVisible?` | `number` | `4` | How many entries stay in the feed before the oldest retires. |
| `fadeDepth?` | `number` | `48` | Depth in px of the fade mask under which the oldest entry retires. |
| `spring?` | `{ stiffness?: number; damping?: number }` | `{ stiffness: 500, damping: 42 }` | Spring for entry and the siblings' shuffle — one confident settle, no bounce chain. |
| `loop?` | `boolean` | `true` | Self-loop through `items`. Disable to drive the feed only via `ref.push()`. |
| `hoverPause?` | `boolean` | `true` | Pause the loop while the pointer is over the feed. |
| `colors?` | `string[]` | `["#fbbf24"]` | Accent cycle for icon chips (per-item `accent` wins). Accents stay confined to the chip — cards remain neutral. |
| `seed?` | `number` | `7` | Seed for the shuffle order and interval jitter. |
| `paused?` | `boolean` | `false` | Freeze the loop entirely. |
| `ref?` | `React.Ref<WireHandle>` | — | Imperative handle exposing `push(item)` for live-push mode. |

Also accepts: `Omit<React.HTMLAttributes<HTMLDivElement>, "children">`.

## Usage example

```tsx
import { Wire } from "@/components/crucible/wire";

export default function WireDemo() {
  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.035),transparent_70%)]"
      />
      <div className="relative z-10 flex w-full max-w-sm flex-col items-center gap-6 px-6 py-10">
        <Wire />
        <p className="text-[11px] font-medium uppercase tracking-[0.2em] text-neutral-500">
          Hover to pause the feed
        </p>
      </div>
    </div>
  );
}
```

## Accessibility & performance

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

## Source (`wire.tsx`)

```tsx
"use client";

import { AnimatePresence, motion } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/use-visibility-pause";

export interface WireItem {
  /** Small glyph rendered inside the accent chip (inline SVG works best). */
  icon?: React.ReactNode;
  /** Headline of the event — also used for the item's aria-label. */
  title: string;
  /** Secondary meta line under the title. */
  description?: string;
  /** Short timestamp string, e.g. "just now", "2m ago". */
  time?: string;
  /** Accent for this item's icon chip; falls back to the `colors` cycle. */
  accent?: string;
}

/** Imperative handle for live-push mode — see `WireProps.ref`. */
export interface WireHandle {
  /** Push a live item into the feed. Works alongside or instead of the loop. */
  push: (item: WireItem) => void;
}

export interface WireProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  /**
   * Events the feed cycles through. The loop order is a seeded shuffle of this
   * array (deterministic per `seed`), so the cadence never reads scripted.
   * @default a built-in set of generic product events
   */
  items?: WireItem[];
  /** Mean seconds between inserts while looping. @default 2.4 */
  interval?: number;
  /**
   * Jitter applied to each gap as a fraction of `interval` (0 = metronomic,
   * 0.5 = gaps range ±50%). Seeded — the same rhythm every load. @default 0.35
   */
  jitter?: number;
  /** Where new entries land: "top" (news wire) or "bottom" (chat). @default "top" */
  direction?: "top" | "bottom";
  /** How many entries stay in the feed before the oldest retires. @default 4 */
  maxVisible?: number;
  /** Depth in px of the fade mask under which the oldest entry retires. @default 48 */
  fadeDepth?: number;
  /**
   * Spring for entry and the siblings' shuffle — one confident settle, no
   * bounce chain. @default { stiffness: 500, damping: 42 }
   */
  spring?: { stiffness?: number; damping?: number };
  /** Self-loop through `items`. Disable to drive the feed only via `ref.push()`. @default true */
  loop?: boolean;
  /** Pause the loop while the pointer is over the feed. @default true */
  hoverPause?: boolean;
  /**
   * Accent cycle for icon chips (per-item `accent` wins). Accents stay confined
   * to the chip — cards remain neutral. @default ["#fbbf24"]
   */
  colors?: string[];
  /** Seed for the shuffle order and interval jitter. @default 7 */
  seed?: number;
  /** Freeze the loop entirely. @default false */
  paused?: boolean;
  /** Imperative handle exposing `push(item)` for live-push mode. */
  ref?: React.Ref<WireHandle>;
}

/* ---------------------------------------------------------------- helpers */

/** Deterministic PRNG (mulberry32) — no Math.random anywhere. */
function mulberry32(seed: number): () => number {
  let t = seed >>> 0;
  return () => {
    t = (t + 0x6d2b79f5) | 0;
    let r = Math.imul(t ^ (t >>> 15), 1 | t);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}

/** Seeded Fisher–Yates over item indices — the loop's deterministic order. */
function seededOrder(length: number, seed: number): number[] {
  const rand = mulberry32(seed);
  const order = Array.from({ length }, (_, i) => i);
  for (let i = length - 1; i > 0; i--) {
    const j = Math.floor(rand() * (i + 1));
    [order[i], order[j]] = [order[j], order[i]];
  }
  return order;
}

function Glyph({ d }: { d: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4"
      aria-hidden
    >
      <path d={d} />
    </svg>
  );
}

const DEFAULT_ITEMS: WireItem[] = [
  {
    icon: <Glyph d="M15 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M8.5 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8M19 8v6M16 11h6" />,
    title: "New signup",
    description: "A new account just joined",
    time: "just now",
  },
  {
    icon: <Glyph d="M2 9h20M5.5 4.5h13a3 3 0 0 1 3 3v9a3 3 0 0 1-3 3h-13a3 3 0 0 1-3-3v-9a3 3 0 0 1 3-3ZM6 15.5h4" />,
    title: "Payment received",
    description: "Invoice #1042 was settled",
    time: "1m ago",
  },
  {
    icon: <Glyph d="M12 3v12M7 10l5 5 5-5M4 21h16" />,
    title: "Report exported",
    description: "Monthly summary is ready",
    time: "3m ago",
  },
  {
    icon: <Glyph d="M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0ZM8.5 12.5l2.5 2.5 5-5.5" />,
    title: "Deploy complete",
    description: "Version 2.4.1 is live",
    time: "6m ago",
  },
  {
    icon: <Glyph d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />,
    title: "New comment",
    description: "Someone replied to a thread",
    time: "9m ago",
  },
  {
    icon: <Glyph d="M13 2 3 14h7l-1 8 10-12h-7l1-8Z" />,
    title: "Milestone reached",
    description: "10k events processed",
    time: "12m ago",
  },
];

const DEFAULT_COLORS = ["#fbbf24"];

/** Minimum seconds between polite screen-reader announcements. */
const ANNOUNCE_GAP_MS = 4000;

type Entry = { key: number; item: WireItem };

/**
 * Wire — a self-running "live activity" feed for marketing pages. New entries
 * land with one confident spring while siblings shuffle down via layout
 * animation and the oldest retires under an edge fade; each fresh entry's
 * leading edge flashes a hairline brightness that cools within 400ms — freshly
 * printed. Loops a provided items array on seeded, jittered intervals (never
 * metronomic, deterministic per seed), or accepts live items through an
 * imperative `push` ref. Hover pauses the feed; the interval also suspends
 * offscreen and on hidden tabs. Cards stay neutral with hairline borders — the
 * per-item accent lives only in the icon chip. Reduced motion renders a static
 * list of the most recent items, and live pushes appear without motion.
 *
 * **Boundary with `dispatch`:** wire is the *decorative* counterpart — a
 * declarative, self-looping showcase with seeded fake data, no dismissal and
 * no imperative toast() API, built to run unattended in a hero. For real app
 * notifications (imperative `toast()`, receding stack, swipe-dismiss, promise
 * morphs, `role="status"`), use `dispatch` instead.
 */
export function Wire({
  items = DEFAULT_ITEMS,
  interval = 2.4,
  jitter = 0.35,
  direction = "top",
  maxVisible = 4,
  fadeDepth = 48,
  spring,
  loop = true,
  hoverPause = true,
  colors = DEFAULT_COLORS,
  seed = 7,
  paused = false,
  ref,
  className,
  onPointerEnter,
  onPointerLeave,
  ...props
}: WireProps) {
  const reducedMotion = useReducedMotion();
  const stiffness = spring?.stiffness ?? 500;
  const damping = spring?.damping ?? 42;

  const order = React.useMemo(() => seededOrder(items.length, seed), [items, seed]);

  // Prefill so the feed reads alive from the first paint: the last `maxVisible`
  // loop pushes, newest first. Deterministic (seeded) — SSR-safe.
  const prefill = React.useMemo<Entry[]>(() => {
    const count = Math.min(maxVisible, items.length);
    return order
      .slice(0, count)
      .map((itemIndex, i) => ({ key: i, item: items[itemIndex] }))
      .reverse();
  }, [order, items, maxVisible]);

  const [entries, setEntries] = React.useState<Entry[]>(prefill);
  const [announcement, setAnnouncement] = React.useState("");

  const seqRef = React.useRef(prefill.length);
  const cursorRef = React.useRef(prefill.length);
  const hoveredRef = React.useRef(false);
  const lastAnnounceRef = React.useRef(0);
  const prevElapsedRef = React.useRef(0);
  const countdownRef = React.useRef(interval);

  // Persistent jitter PRNG, distinct stream from the shuffle. Lazy ref init
  // keeps it deterministic and stable across renders.
  const jitterRandRef = React.useRef<(() => number) | null>(null);
  if (jitterRandRef.current === null) jitterRandRef.current = mulberry32(seed * 7919 + 1);

  // If items/seed/maxVisible change, restart from the new deterministic prefill.
  // (On mount `entries === prefill`, so React bails out — no extra render.)
  React.useEffect(() => {
    setEntries(prefill);
    seqRef.current = prefill.length;
    cursorRef.current = prefill.length;
  }, [prefill]);

  // A new seed also restarts the jitter stream, so the rhythm is deterministic
  // per seed (not just the order).
  const seedRef = React.useRef(seed);
  React.useEffect(() => {
    if (seedRef.current === seed) return;
    seedRef.current = seed;
    jitterRandRef.current = mulberry32(seed * 7919 + 1);
  }, [seed]);

  const pushItem = React.useCallback(
    (item: WireItem) => {
      const key = seqRef.current++;
      setEntries((prev) => [{ key, item }, ...prev].slice(0, maxVisible));
      // Polite announcements, throttled to sanity.
      const now = performance.now();
      if (now - lastAnnounceRef.current >= ANNOUNCE_GAP_MS) {
        lastAnnounceRef.current = now;
        setAnnouncement(`New activity: ${item.title}`);
      }
    },
    [maxVisible]
  );

  React.useImperativeHandle(ref, () => ({ push: pushItem }), [pushItem]);

  const advanceRef = React.useRef(() => {});
  advanceRef.current = () => {
    if (items.length === 0) return;
    const itemIndex = order[cursorRef.current % order.length];
    cursorRef.current++;
    pushItem(items[itemIndex]);
  };

  const jitterProps = React.useRef({ interval, jitter, hoverPause });
  jitterProps.current = { interval, jitter, hoverPause };

  // rAF-driven countdown: suspends offscreen and on hidden tabs via the hook,
  // and skips while hovered. dt-based so hook restarts (elapsed resets) are safe.
  const loopSuspended = paused || !loop || (reducedMotion && loop);
  const containerRef = useVisibilityPause<HTMLDivElement>(
    (elapsed) => {
      const prev = prevElapsedRef.current;
      const dt = elapsed >= prev ? elapsed - prev : elapsed;
      prevElapsedRef.current = elapsed;
      if (jitterProps.current.hoverPause && hoveredRef.current) return;
      countdownRef.current -= dt;
      if (countdownRef.current <= 0) {
        advanceRef.current();
        const { interval: base, jitter: j } = jitterProps.current;
        const rand = jitterRandRef.current!;
        countdownRef.current = Math.max(0.35, base * (1 + j * (rand() * 2 - 1)));
      }
    },
    { paused: loopSuspended }
  );

  const rendered = direction === "top" ? entries : [...entries].reverse();

  // no-repeat + explicit size: content past the box is masked out, so the
  // oldest entry retires under the fade and the entering card slips in from
  // under a shallow leading-edge fade instead of overhanging the feed.
  const mask =
    direction === "top"
      ? `linear-gradient(to bottom, transparent 0, #000 12px, #000 calc(100% - ${fadeDepth}px), transparent 100%)`
      : `linear-gradient(to top, transparent 0, #000 12px, #000 calc(100% - ${fadeDepth}px), transparent 100%)`;

  const enterY = direction === "top" ? -18 : 18;
  const exitY = direction === "top" ? 14 : -14;

  return (
    <div
      ref={containerRef}
      data-crucible="wire"
      className={cn("relative w-full max-w-sm", className)}
      onPointerEnter={(event) => {
        hoveredRef.current = true;
        onPointerEnter?.(event);
      }}
      onPointerLeave={(event) => {
        hoveredRef.current = false;
        onPointerLeave?.(event);
      }}
      {...props}
    >
      <div
        role="feed"
        aria-label="Activity feed"
        aria-busy={false}
        className="flex w-full flex-col gap-2.5"
        style={{
          maskImage: mask,
          WebkitMaskImage: mask,
          maskRepeat: "no-repeat",
          WebkitMaskRepeat: "no-repeat",
          maskSize: "100% 100%",
          WebkitMaskSize: "100% 100%",
        }}
      >
        <AnimatePresence initial={false} mode="popLayout">
          {rendered.map(({ key, item }, i) => {
            const accent = item.accent ?? colors[key % Math.max(1, colors.length)] ?? DEFAULT_COLORS[0];
            return (
              <motion.article
                key={key}
                aria-label={item.title}
                aria-posinset={i + 1}
                aria-setsize={rendered.length}
                layout={!reducedMotion}
                initial={reducedMotion ? false : { opacity: 0, y: enterY, scale: 0.98 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={
                  reducedMotion
                    ? { opacity: 0, transition: { duration: 0 } }
                    : { opacity: 0, y: exitY, transition: { duration: 0.3, ease: "easeOut" } }
                }
                transition={
                  reducedMotion
                    ? { duration: 0 }
                    : {
                        // One confident spring — entry and sibling shuffle alike.
                        type: "spring",
                        stiffness,
                        damping,
                        opacity: { duration: 0.28, ease: "easeOut" },
                      }
                }
                className="relative flex w-full items-center gap-3 rounded-lg border border-white/[0.08] bg-white/[0.03] px-3.5 py-3"
              >
                {/* Freshly printed: the leading edge flashes a hairline that
                    cools within 400ms. Skipped for prefilled entries because
                    AnimatePresence mounts them with initial={false}. */}
                {!reducedMotion && (
                  <motion.span
                    aria-hidden
                    className="pointer-events-none absolute inset-x-3 top-0 h-px"
                    style={{
                      background:
                        "linear-gradient(90deg, transparent, rgba(255,255,255,0.85), transparent)",
                      boxShadow: "0 0 10px rgba(255,255,255,0.35)",
                    }}
                    initial={{ opacity: 1 }}
                    animate={{ opacity: 0 }}
                    transition={{ duration: 0.4, ease: "easeOut" }}
                  />
                )}
                <span
                  aria-hidden
                  className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border"
                  style={{
                    color: accent,
                    borderColor: `color-mix(in srgb, ${accent} 28%, transparent)`,
                    background: `color-mix(in srgb, ${accent} 12%, transparent)`,
                  }}
                >
                  {item.icon ?? <span className="h-1.5 w-1.5 rounded-full bg-current" />}
                </span>
                <span className="min-w-0 flex-1">
                  <span className="block truncate text-sm font-medium text-neutral-100">{item.title}</span>
                  {item.description && (
                    <span className="mt-0.5 block truncate text-xs text-neutral-400">{item.description}</span>
                  )}
                </span>
                {item.time && (
                  <span className="shrink-0 self-start text-[10px] font-medium tabular-nums text-neutral-500">
                    {item.time}
                  </span>
                )}
              </motion.article>
            );
          })}
        </AnimatePresence>
      </div>
      <span className="sr-only" role="status" aria-live="polite">
        {announcement}
      </span>
    </div>
  );
}
```
