Wire
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.
motionfree
"use client";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/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]);
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 });
jitterProps.current = { interval, jitter };
// 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 (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) => {
if (hoverPause) 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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/wireManual — install dependencies, then copy the source
npm install motionProps
| 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 all props of Omit<React.HTMLAttributes<HTMLDivElement>, "children"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.