Noise Gate
Before and After Section for React
A before-and-after section. Alerts, pings and complaints drift into the seam between two panels and slide under the calm one, and each card that goes through lights the seam and ticks a counter, or lands as a tidy line in a log.
<NoiseGate />The demo follows as you drag.
import { NoiseGate, type NoiseGateItem } from "@/components/ui/noise-gate";
function Icon({ d }: { d: string }) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
<path d={d} />
</svg>
);
}
function Avatar({ initials, className }: { initials: string; className: string }) {
return (
<span className={`grid size-5 place-items-center rounded-full text-[9px] font-semibold text-white ${className}`}>
{initials}
</span>
);
}
const clock = "M8 4.5V8l2.5 1.5M14 8A6 6 0 1 1 2 8a6 6 0 0 1 12 0Z";
const queue = "M2.5 4h11M2.5 8h11M2.5 12h6";
const trendDown = "M2 4.5l4.5 4.5 2.5-2.5L14 11.5M14 8v3.5h-3.5";
const reply = "M6 4 2.5 7.5 6 11M2.5 7.5H10a3.5 3.5 0 0 1 3.5 3.5v1";
const tickets: NoiseGateItem[] = [
{
tag: "SLA",
tone: "critical",
icon: <Icon d={clock} />,
title: "Order #48213 hasn't moved in 9 days",
detail: "First reply overdue by 3h 12m",
},
{
icon: <Avatar initials="MK" className="bg-violet-500" />,
title: "“Is anyone actually reading these?”",
detail: "Maya K. · 4th follow-up",
},
{ tag: "Queue", tone: "warning", icon: <Icon d={queue} />, title: "214 chats waiting", detail: "Longest wait 38 min" },
{
tag: "Refund",
tone: "critical",
repeat: 3,
title: "Refund still not processed",
detail: "Customer wrote 3 times in 10 min",
},
{ icon: <Icon d={reply} />, title: "“Sorry for the delay” sent 1,980×", detail: "Most used macro this week" },
{ tag: "Escalated", tone: "warning", title: "Chargeback threatened on #47790", detail: "Assigned to nobody" },
{
icon: <Avatar initials="JD" className="bg-sky-500" />,
title: "“Fine, I'll just cancel then.”",
detail: "Jordan D. · live chat",
},
{
tag: "CSAT",
tone: "critical",
icon: <Icon d={trendDown} />,
title: "Satisfaction down to 61%",
detail: "14 points lower than last month",
},
{ mono: true, title: "urgent, URGENT, urgent!!", detail: "Three tags, one meaning" },
{ tag: "Handoff", tone: "warning", title: "Night shift notes missing", detail: "92 tickets carried over" },
];
export function NoiseGateDemo() {
return (
<NoiseGate
className="max-w-5xl"
items={tickets}
noiseTitle={
<>
Answering tickets with <span className="text-zinc-500 dark:text-zinc-400">“canned replies”</span>
</>
}
calmTitle="Answering with Harbor"
count={1284}
countPrefix="Resolved"
countSuffix="tickets today"
/>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Noise Gate: A before-and-after section. Alerts, pings and complaints drift into the seam between two panels and slide under the calm one, and each card that goes through lights the seam and ticks a counter, or lands as a tidy line in a log. This demo, Support Inbox: A help desk's pitch. Overdue tickets, angry follow-ups and a refund that fired three times drift into the seam, and every one that goes through ticks Harbor's resolved count. Point at the noise to stop it and read a card. 1. Install it: npx shadcn@latest add @keyframeui/noise-gate-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/noise-gate-demo.json instead.) It adds the demo, components/noise-gate-demo.tsx, and the component, components/ui/noise-gate.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <NoiseGate> on its own. 3. These props set its feel. Change them before you change the file: - display (default "count"): What the calm side shows on its horizon: a pill with a counter, or a log in which each card that goes through becomes its `resolved` line, newest on top. - density (default 9): Most cards on screen at once. Fewer show when the panel is small, so it never turns into a wall. - duration (default 15): Seconds a mid-depth card takes to cross the noise panel. Far cards are slower and near ones quicker. - bob (default 3): How far each card floats across its lane as it drifts, in px. `0` keeps every card on a straight line. - settle (default 0.32): How quickly the stream eases to a stop under the pointer (or with `paused`) and gets going again, as a time constant in seconds. Smaller stops more sharply. - rollDuration (default 0.42): Seconds the counter's changed digits take to roll in when a card reaches the gate. 4. Don't change, in components/ui/noise-gate.tsx: - One rAF loop moves every card by writing `transform` and `opacity` straight to the DOM and redraws the traces; the stream never re-renders, and the loop sleeps when stopped or off screen. - Arrivals are scheduled: each new card starts where it reaches the seam about one beat after the card before it, give or take the jitter, so ticks keep a rhythm instead of bunching. - Hover and `paused` ease the whole stream to a stop through one shared factor, never a snap, and the card under the pointer comes forward on top of the rest. - Digits are keyed from the right and roll in `popLayout`, so a new leading digit doesn't re-roll the rest; the count reads from an `sr-only` span while the moving parts are `aria-hidden`. - Reduced motion shows a seeded, still scatter of cards and traces: no stream and no ticks. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/noise-gate.md
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/noise-gate.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/noise-gateInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/noise-gate.tsx"use client"; import * as React from "react"; import { AnimatePresence, animate, motion, useReducedMotion, } from "motion/react"; import { cn } from "@/lib/utils"; /** * Keep: * - One rAF loop moves every card by writing `transform` and `opacity` straight to the DOM and * redraws the traces; the stream never re-renders, and the loop sleeps when stopped or off * screen. * - Arrivals are scheduled: each new card starts where it reaches the seam about one beat after * the card before it, give or take the jitter, so ticks keep a rhythm instead of bunching. * - Hover and `paused` ease the whole stream to a stop through one shared factor, never a snap, * and the card under the pointer comes forward on top of the rest. * - Digits are keyed from the right and roll in `popLayout`, so a new leading digit doesn't re-roll * the rest; the count reads from an `sr-only` span while the moving parts are `aria-hidden`. * - Reduced motion shows a seeded, still scatter of cards and traces: no stream and no ticks. */ const TIMING = { /** A mid-depth card crossing the noise panel, in seconds (the `duration` prop). */ duration: 15, /** How fast far, mid and near cards drift, as multiples of a mid-depth card's pace. */ depthSpeed: [0.72, 1, 1.28], /** The stream easing to a stop and starting again, a time constant in seconds (the `settle` prop). */ settle: 0.32, /** A new card appearing, in seconds. */ fadeIn: 0.9, /** Between the first cards appearing, in seconds. */ stagger: 0.11, /** A hovered card coming forward, as a time constant in seconds. */ emphasis: 0.12, /** The least a new card drifts before it reaches the seam, in seconds. */ minRun: 2.2, /** Between arrivals: this share of a full crossing, split between the cards. */ beat: 0.9, /** The shortest beat, in seconds. */ minBeat: 0.35, /** ± share of a beat each arrival moves by, so ticks don't land like a metronome. */ jitter: 0.4, /** One bob of a card across its lane: the shortest period in seconds, and up to this many more. */ bobPeriod: 5, bobVary: 3, /** Each trace's three waves moving, in rad/s. */ traceSpeed: [ [1.1, -1.9, 3.3], [-0.8, 2.4, -3.9], [1.6, -2.8, 4.6], ], /** The seam lighting up where a card went through: seconds, keyframe times and curve. */ flash: { duration: 0.9, times: [0, 0.14, 1], ease: "easeOut" }, /** The routed dot running along the seam and the horizon, in px/s. */ route: 460, /** The shortest and longest the routed dot takes, in seconds. */ routeDuration: [0.4, 1.1], /** The routed dot speeding up down the seam and easing out along the horizon, per leg. */ routeEase: ["easeIn", "easeOut"], /** From the edge light to the tick when stacked, in seconds. */ tickDelay: 0.42, /** The pill brightening once on a tick: seconds, keyframe times and curve. */ glow: { duration: 0.9, times: [0, 0.15, 1], ease: "easeOut" }, /** The fainter dot leaving on the far side: this many times as long as the routed dot would take. */ exitPace: 1.6, /** The shortest and longest the fainter dot takes, in seconds. */ exitDuration: [0.4, 1.2], /** The fainter dot's curve, as a motion easing. */ exitEase: "easeOut", /** A tick with no fainter dot lingers this long before it's cleared, in seconds. */ linger: 0.6, /** The counter's changed digits rolling in, in seconds (the `rollDuration` prop). */ rollDuration: 0.42, /** Counter digits, as a spring over `rollDuration`. */ roll: { type: "spring", bounce: 0 }, /** Log rows sliding, as a spring. */ row: { type: "spring", visualDuration: 0.45, bounce: 0 }, } as const; /* ───────────────────────────────────────────────────────── * NOISE GATE STORYBOARD * * enter cards fade in one after another across the noise * panel, at three depths: far ones small, faint and * slow, near ones crisp and quicker * drift every card heads for the seam on its own beat, * bobbing a few px across its lane. Near cards pass * over far ones * gate a card's leading edge touches the seam: the calm * panel's edge lights up along it, and the card slides * on under the calm panel and is gone. A new card * fades in somewhere behind * route a dot runs along the seam to the horizon and turns * into the counter (or the log) * tick the counter's changed digits roll up and the pill * brightens once; in the log, the card's resolved line * slides in on top. A fainter dot leaves on the far side * traces behind the cards three jittery traces converge on * the horizon at the seam, where the calm side's single * dotted line takes over * hover pointing at the noise eases everything to a stop so * a card can be read; the card under the pointer comes * forward at full strength * stacked under 40rem the panels stack: cards fall into the * calm panel's top edge, which lights up, and the tick * follows without the routed dot * reduced a still scatter of cards and traces; nothing ticks * ───────────────────────────────────────────────────────── */ const GATE = { // far, mid, near depths: [ { scale: 0.86, opacity: 0.38 }, { scale: 0.94, opacity: 0.72 }, { scale: 1, opacity: 1 }, ], depthOrder: [2, 0, 1, 2, 1, 0, 2, 1, 0], horizon: 0.6, // share of the panel's height the horizon sits at, side by side overhang: 0.45, // share of a card that may start past the far edge (under the title, stacked) edge: 16, // px cards keep from the panel's outer edges crowd: 0.95, // cards on screen per card-sized area of the field logRows: 6, } as const; // Three traces, each a sum of three waves: wavenumber (rad/px). Their speeds are `TIMING.traceSpeed`. const TRACES = [ { amp: 1, k: [0.034, 0.092, 0.21], phase: 0.3, tone: "text-rose-500/30 dark:text-rose-400/25" }, { amp: 0.74, k: [0.05, 0.13, 0.27], phase: 2.1, tone: "text-amber-500/35 dark:text-amber-400/25" }, { amp: 0.56, k: [0.028, 0.16, 0.33], phase: 4.4, tone: "text-zinc-400/55 dark:text-zinc-500/55" }, ] as const; const TONE = { critical: { tag: "bg-rose-500/10 text-rose-700 dark:bg-rose-400/15 dark:text-rose-300", icon: "text-rose-500 dark:text-rose-400", }, warning: { tag: "bg-amber-500/15 text-amber-800 dark:bg-amber-400/15 dark:text-amber-200", icon: "text-amber-500 dark:text-amber-400", }, neutral: { tag: "bg-zinc-950/[0.06] text-zinc-600 dark:bg-white/10 dark:text-zinc-300", icon: "text-zinc-400 dark:text-zinc-500", }, } as const; const SURFACE = "rounded-xl border border-zinc-950/[0.08] bg-white/85 shadow-[0_1px_2px_rgb(9_9_11/0.05),0_12px_28px_-16px_rgb(9_9_11/0.3)] dark:border-white/10 dark:bg-zinc-900/85 dark:shadow-[0_1px_2px_rgb(0_0_0/0.4),0_12px_28px_-16px_rgb(0_0_0/0.8)]"; const DOTTED: React.CSSProperties = { backgroundImage: "radial-gradient(circle, currentColor 1px, transparent 1.3px)", backgroundSize: "7px 3px", backgroundRepeat: "repeat-x", }; const TAU = Math.PI * 2; const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); const lerp = (a: number, b: number, t: number) => a + (b - a) * t; const smoothstep = (a: number, b: number, v: number) => { const t = clamp((v - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); }; const easeOut = (t: number) => 1 - (1 - t) ** 3; /** Small seeded generator, so the still scatter is the same on every visit. */ function seeded(seed: number) { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } export type NoiseGateTone = "critical" | "warning" | "neutral"; export interface NoiseGateItem { /** The card's main line. */ title: React.ReactNode; /** A smaller, muted second line. */ detail?: React.ReactNode; /** A short label in a pill before the title, e.g. "P1" or "Overdue". */ tag?: React.ReactNode; /** Colors the tag and the icon. */ tone?: NoiseGateTone; /** A leading icon or avatar, about 20px. */ icon?: React.ReactNode; /** Set the title in monospace, for log lines, file names and codes. */ mono?: boolean; /** Stack this many copies, like an alert that keeps firing. */ repeat?: number; /** How much the counter goes up when this card reaches the gate. */ weight?: number; /** With `display="log"`: the line this card becomes once it's through. */ resolved?: React.ReactNode; /** With `display="log"`: a right-aligned value beside `resolved`, in tabular figures. */ resolvedDetail?: React.ReactNode; } export interface NoiseGateProps { /** The cards that drift into the gate, cycled in order. */ items: NoiseGateItem[]; /** Heading of the noise side. */ noiseTitle: React.ReactNode; /** Heading of the calm side. */ calmTitle: React.ReactNode; /** What the calm side shows: a counter pill, or a log of resolved lines. */ display?: "count" | "log"; /** The counter's starting value. Each card adds its `weight` (1) when it reaches the gate. */ count?: number; /** Text before the number in the pill. */ countPrefix?: React.ReactNode; /** Text after the number in the pill. */ countSuffix?: React.ReactNode; /** Pinned to the bottom of the calm side: a call to action or a footnote. */ calmFooter?: React.ReactNode; /** Most cards on screen at once. Fewer show when the panel is small. */ density?: number; /** Seconds a mid-depth card takes to cross the noise panel. */ duration?: number; /** How far each card floats across its lane as it drifts, in px. */ bob?: number; /** How quickly the stream eases to a stop under the pointer and gets going again: a time constant in seconds. */ settle?: number; /** Seconds the counter's changed digits take to roll in. */ rollDuration?: number; /** Ease the stream to a stop. It also stops while the pointer is over the noise. */ paused?: boolean; /** Element for both headings. */ titleAs?: "h2" | "h3" | "h4" | "p"; /** Height while the panels sit side by side. Stacked, each panel sizes itself. */ height?: string; /** Classes for the root. Set `[--noise-gate-radius:…]` to change the corner radius. */ className?: string; /** Classes for the noise panel. */ noiseClassName?: string; /** Classes for the calm panel: its background and text color. */ calmClassName?: string; } type Slot = { item: number; depth: number; /** px along the drift, from the far edge toward the seam (the box's start). */ a: number; /** px across the drift (the box's start). */ c: number; /** Stream seconds the card was born at; it fades in from there. */ born: number; phase: number; period: number; gated: boolean; /** 0..1, how far the card has come forward under the pointer. */ emph: number; }; type Geometry = { stacked: boolean; w: number; h: number; /** Along coordinate of the seam. */ gate: number; /** Along coordinate where cards may start. */ start: number; /** Cross range for a card's box. */ lo: number; hi: number; horizon: number; /** Per item: along and cross size. */ sizes: { a: number; c: number }[]; }; type GateEvent = { item: number; from: number; to: number; stacked: boolean }; type CalmApi = { gate: (event: GateEvent) => void }; type Pulse = { id: number; item: number; weight: number; stacked: boolean; from: number; to: number; route: { x: number[]; y: number[]; times: number[]; duration: number } | null; exit: { x0: number; x1: number; y: number } | null; }; export function NoiseGate({ items, noiseTitle, calmTitle, display = "count", count = 0, countPrefix, countSuffix, calmFooter, density = 9, duration = TIMING.duration, bob = 3, settle = TIMING.settle, rollDuration = TIMING.rollDuration, paused = false, titleAs = "h3", height = "28rem", className, noiseClassName, calmClassName, }: NoiseGateProps) { const reduced = useReducedMotion() ?? false; const noiseRef = React.useRef<HTMLDivElement>(null); const calmRef = React.useRef<HTMLDivElement>(null); const titleRef = React.useRef<HTMLHeadingElement>(null); const layerRef = React.useRef<HTMLDivElement>(null); const cardRefs = React.useRef<(HTMLDivElement | null)[]>([]); const traceRefs = React.useRef<(SVGPathElement | null)[]>([]); const calmApi = React.useRef<CalmApi>(null); const hovering = React.useRef(false); const hovered = React.useRef(-1); const pausedRef = React.useRef(paused); const feel = React.useRef({ bob, settle }); const wake = React.useRef<() => void>(() => {}); React.useEffect(() => { pausedRef.current = paused; wake.current(); }, [paused]); // Read by the frame loop, so changing them doesn't restart the stream. React.useEffect(() => { feel.current = { bob, settle }; wake.current(); }, [bob, settle]); const itemCount = items.length; const Title = titleAs as React.ElementType; // The stream: one frame loop moves every card and redraws the traces. React.useEffect(() => { const panel = noiseRef.current; const calm = calmRef.current; const title = titleRef.current; const layer = layerRef.current; const cards = cardRefs.current; const traces = traceRefs.current; if (!panel || !calm || !title || !layer || itemCount === 0) return; const rand = seeded(7); const sizeOf = (el: HTMLDivElement | null | undefined, stacked: boolean) => el ? stacked ? { a: el.offsetHeight, c: el.offsetWidth } : { a: el.offsetWidth, c: el.offsetHeight } : { a: 0, c: 0 }; const measure = (): Geometry => { const w = panel.clientWidth; const h = panel.clientHeight; const stacked = calm.offsetTop > 1; const titleBottom = title.offsetTop + title.offsetHeight; const sizes = Array.from({ length: itemCount }, (_, i) => sizeOf(cards[i], stacked)); // Stacked, cards start under the title and come out from beneath it. const mask = stacked ? `linear-gradient(to bottom, transparent ${titleBottom - 6}px, #000 ${titleBottom + 30}px)` : ""; layer.style.setProperty("mask-image", mask); layer.style.setProperty("-webkit-mask-image", mask); return stacked ? { stacked, w, h, gate: h, start: titleBottom + 12, lo: GATE.edge, hi: w - GATE.edge, horizon: 0, sizes } : { stacked, w, h, gate: w, start: GATE.edge, lo: titleBottom + 16, hi: h - GATE.edge, horizon: h * GATE.horizon, sizes }; }; let geo = measure(); // How many cards the field holds without turning into a wall. const capacity = () => { const along = Math.max(1, geo.gate - geo.start); const across = Math.max(1, geo.hi - geo.lo); const card = geo.sizes.reduce((sum, s) => sum + s.a * s.c, 0) / itemCount || 1; return clamp(Math.round(((along * across) / card) * GATE.crowd), Math.min(3, itemCount), Math.min(density, itemCount)); }; const slots: Slot[] = []; const lanes: number[] = []; let cursor = 0; let spawned = 0; let clock = 0; // Arrivals are scheduled, not left to chance: each new card starts where it // will reach the seam about one beat after the card before it. let lastArrival = 0; let beat = 1; const nextItem = () => { for (let i = 0; i < itemCount; i++) { const idx = (cursor + i) % itemCount; if (!slots.some((s) => s.item === idx)) { cursor = idx + 1; return idx; } } return cursor++ % itemCount; }; // Of a few random lanes, take the one furthest from the last few cards. const pickLane = (size: number) => { const lo = geo.lo; const hi = Math.max(lo, geo.hi - size); let best = lo; let bestScore = -1; for (let i = 0; i < 6; i++) { const v = lerp(lo, hi, rand()); const score = lanes.length ? Math.min(...lanes.map((l) => Math.abs(l - v))) : 1; if (score > bestScore) { bestScore = score; best = v; } } lanes.push(best); if (lanes.length > 3) lanes.shift(); return best; }; const show = (slot: Slot) => { const el = cards[slot.item]; if (el) el.style.visibility = "visible"; }; const hide = (item: number) => { const el = cards[item]; if (!el) return; el.style.visibility = "hidden"; el.style.opacity = "0"; }; const spawn = (slot: Slot, born = clock, along?: number) => { slot.item = nextItem(); slot.depth = GATE.depthOrder[spawned++ % GATE.depthOrder.length]; geo.sizes[slot.item] = sizeOf(cards[slot.item], geo.stacked); const size = geo.sizes[slot.item]; const d = GATE.depths[slot.depth]; const speed = (geo.gate / duration) * TIMING.depthSpeed[slot.depth]; const lead = (size.a * (1 + d.scale)) / 2; // box start to the visible leading edge if (along == null) { const earliest = Math.max(clock, born) + TIMING.minRun; const target = Math.max(earliest, lastArrival + beat * (1 + TIMING.jitter * (2 * rand() - 1))); const lo = geo.start - size.a * GATE.overhang; const hi = Math.max(lo, geo.gate - lead - speed * (earliest - clock)); along = clamp(geo.gate - lead - speed * (target - clock), lo, hi); lastArrival = Math.max(lastArrival, clock + (geo.gate - lead - along) / speed); } slot.a = along; slot.c = pickLane(size.c); slot.born = born; slot.phase = rand() * TAU; slot.period = TIMING.bobPeriod + rand() * TIMING.bobVary; slot.gated = false; slot.emph = 0; show(slot); }; // Fill the field at once. Live, on the arrival schedule; still, spread // evenly from the far edge to just short of the seam. const seed = (mode: "enter" | "resume" | "still") => { for (const slot of slots) hide(slot.item); slots.length = 0; lanes.length = 0; cursor = 0; spawned = 0; const n = capacity(); const along = geo.sizes.reduce((sum, s) => sum + s.a, 0) / itemCount; const run = geo.gate - geo.start - along * (1 - GATE.overhang); beat = Math.max(TIMING.minBeat, (TIMING.beat * Math.max(1, run) * duration) / geo.gate / n); lastArrival = clock; const order = Array.from({ length: n }, (_, i) => i).sort(() => rand() - 0.5); for (let i = 0; i < n; i++) { const slot = { item: -1 } as Slot; slots.push(slot); if (mode === "still") { const size = sizeOf(cards[i % itemCount], geo.stacked); spawn(slot, clock - TIMING.fadeIn, lerp(geo.start, geo.gate - size.a * 1.15, (order[i] + 0.5) / n)); } else { spawn(slot, mode === "enter" ? clock + i * TIMING.stagger : clock - TIMING.fadeIn); } } }; const traceAt = (k: number, x: number, t: number) => { const tr = TRACES[k]; const w = TIMING.traceSpeed[k]; return ( tr.amp * (0.55 * Math.sin(tr.k[0] * x + w[0] * t + tr.phase) + 0.3 * Math.sin(tr.k[1] * x + w[1] * t + 2 * tr.phase) + 0.15 * Math.sin(tr.k[2] * x + w[2] * t + 3 * tr.phase)) ); }; // Side by side the traces converge on the horizon at the seam; stacked // they run across the middle of the field and swell in its center. const drawTraces = (t: number) => { const { w, h } = geo; const y0 = geo.stacked ? (geo.start + h) / 2 : geo.horizon; const amp = Math.min(geo.stacked ? 48 : 60, (geo.stacked ? h - geo.start : h - geo.lo) * 0.3); for (let k = 0; k < TRACES.length; k++) { const path = traces[k]; if (!path) continue; let d = ""; for (let x = 0; ; x = Math.min(w, x + 6)) { const env = geo.stacked ? Math.sin((Math.PI * x) / w) ** 0.6 : 1 - smoothstep(w * 0.38, w, x); const y = y0 + amp * env * traceAt(k, x, t); d += `${x ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`; if (x >= w) break; } path.setAttribute("d", d); } }; const place = (slot: Slot, opacityOnly = false) => { const el = cards[slot.item]; if (!el) return; const d = GATE.depths[slot.depth]; const fade = easeOut(clamp((clock - slot.born) / TIMING.fadeIn, 0, 1)); const scale = lerp(d.scale, Math.max(d.scale, 1), slot.emph) * lerp(0.96, 1, fade); const bob = feel.current.bob * Math.sin((clock / slot.period) * TAU + slot.phase); el.style.opacity = String(lerp(d.opacity, 1, slot.emph) * fade); el.style.zIndex = String(slot.emph > 0.5 ? 100 : slot.depth * 10 + 1); if (opacityOnly) return; el.style.transform = geo.stacked ? `translate3d(${(slot.c + bob).toFixed(2)}px, ${slot.a.toFixed(2)}px, 0) scale(${scale.toFixed(4)})` : `translate3d(${slot.a.toFixed(2)}px, ${(slot.c + bob).toFixed(2)}px, 0) scale(${scale.toFixed(4)})`; }; // Reduced motion: a still scatter, no stream, no ticks. if (reduced) { const still = () => { geo = measure(); clock = 0; seed("still"); for (const slot of slots) place(slot); drawTraces(0); }; still(); const ro = new ResizeObserver(still); ro.observe(panel); return () => { ro.disconnect(); for (const slot of slots) hide(slot.item); }; } seed("enter"); for (const slot of slots) place(slot); drawTraces(0); let factor = pausedRef.current ? 0 : 1; let raf = 0; let last = 0; let running = false; let visible = false; const step = (now: number) => { const dt = Math.min(0.05, (now - last) / 1000); last = now; const target = pausedRef.current || hovering.current ? 0 : 1; factor += (target - factor) * (1 - Math.exp(-dt / feel.current.settle)); if (target === 1 && factor > 0.999) factor = 1; if (target === 0 && factor < 0.01) factor = 0; clock += dt * factor; for (const slot of slots) { const d = GATE.depths[slot.depth]; slot.a += (geo.gate / duration) * TIMING.depthSpeed[slot.depth] * dt * factor; slot.emph += ((hovered.current === slot.item ? 1 : 0) - slot.emph) * (1 - Math.exp(-dt / TIMING.emphasis)); const size = geo.sizes[slot.item]; const scale = lerp(d.scale, Math.max(d.scale, 1), slot.emph); const lead = slot.a + (size.a * (1 + scale)) / 2; const trail = slot.a + (size.a * (1 - scale)) / 2; if (!slot.gated && lead >= geo.gate) { slot.gated = true; const bob = feel.current.bob * Math.sin((clock / slot.period) * TAU + slot.phase); const mid = slot.c + bob + size.c / 2; const half = (size.c * scale) / 2; calmApi.current?.gate({ item: slot.item, from: mid - half, to: mid + half, stacked: geo.stacked }); } if (trail >= geo.gate + 12) { const old = slot.item; spawn(slot); hide(old); } place(slot); } drawTraces(clock); // Fully stopped and nothing coming forward or back: sleep until woken. const settling = slots.some((s) => s.emph > 0.002 && s.emph < 0.998); if (target === 0 && factor === 0 && !settling) { running = false; return; } raf = requestAnimationFrame(step); }; const start = () => { if (running || !visible) return; running = true; last = performance.now(); raf = requestAnimationFrame(step); }; const stop = () => { running = false; cancelAnimationFrame(raf); }; wake.current = start; const io = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; if (visible) start(); else stop(); }); io.observe(panel); let lastStacked = geo.stacked; let lastGate = geo.gate; const ro = new ResizeObserver(() => { geo = measure(); if (geo.stacked !== lastStacked) { seed("resume"); } else { // Keep the stream going: rescale along the drift, clamp across it. const ratio = geo.gate / (lastGate || geo.gate); for (const slot of slots) { slot.a *= ratio; slot.c = clamp(slot.c, geo.lo, Math.max(geo.lo, geo.hi - geo.sizes[slot.item].c)); } } lastStacked = geo.stacked; lastGate = geo.gate; for (const slot of slots) place(slot); drawTraces(clock); }); ro.observe(panel); ro.observe(calm); return () => { stop(); io.disconnect(); ro.disconnect(); wake.current = () => {}; for (const slot of slots) hide(slot.item); }; }, [itemCount, reduced, density, duration]); const titleClass = "relative z-20 mx-auto max-w-[17ch] px-6 pt-9 text-center text-[clamp(1.625rem,7.4cqi,2.25rem)] leading-[1.04] font-semibold tracking-[-0.035em] text-balance @[40rem]/noise-gate:pt-12 @[40rem]/noise-gate:text-[clamp(1.75rem,3.9cqi,2.75rem)]"; return ( <div className={cn("@container/noise-gate w-full [--noise-gate-radius:1.5rem]", className)} style={{ "--noise-gate-height": height } as React.CSSProperties} > <div className="relative isolate grid overflow-hidden rounded-[var(--noise-gate-radius)] @[40rem]/noise-gate:h-[var(--noise-gate-height)] @[40rem]/noise-gate:grid-cols-2"> <div ref={noiseRef} className={cn( "relative h-[25rem] overflow-hidden bg-white text-zinc-950 @[40rem]/noise-gate:h-auto dark:bg-zinc-950 dark:text-zinc-50", noiseClassName, )} onPointerEnter={(e) => { if (e.pointerType !== "mouse") return; hovering.current = true; }} onPointerLeave={() => { hovering.current = false; hovered.current = -1; wake.current(); }} > <div aria-hidden className="absolute inset-0 text-zinc-950/[0.09] [mask-image:linear-gradient(to_bottom,transparent,#000_45%)] dark:text-white/[0.08]" style={{ backgroundImage: "radial-gradient(circle, currentColor 1px, transparent 1.3px)", backgroundSize: "16px 16px", backgroundPosition: "8px 8px", }} /> <svg aria-hidden className="absolute inset-0 size-full overflow-visible"> {TRACES.map((trace, k) => ( <path key={k} ref={(el) => { traceRefs.current[k] = el; }} className={trace.tone} fill="none" stroke="currentColor" strokeWidth={1} strokeLinejoin="round" strokeLinecap="round" /> ))} </svg> <Title ref={titleRef} className={titleClass}> {noiseTitle} </Title> <div ref={layerRef} aria-hidden className="pointer-events-none absolute inset-0"> {items.map((item, i) => ( <div key={i} ref={(el) => { cardRefs.current[i] = el; }} className="pointer-events-auto absolute top-0 left-0 w-[min(15rem,80%)] will-change-transform" style={{ opacity: 0, visibility: "hidden" }} onPointerEnter={(e) => { if (e.pointerType !== "mouse") return; hovered.current = i; wake.current(); }} onPointerLeave={() => { if (hovered.current === i) hovered.current = -1; wake.current(); }} > <NoiseCard item={item} /> </div> ))} </div> <div aria-hidden className="pointer-events-none absolute inset-0 z-30 rounded-t-[var(--noise-gate-radius)] border border-b-0 border-zinc-950/10 @[40rem]/noise-gate:rounded-tr-none @[40rem]/noise-gate:rounded-l-[var(--noise-gate-radius)] @[40rem]/noise-gate:border-r-0 @[40rem]/noise-gate:border-b dark:border-white/10" /> </div> <CalmPanel ref={calmRef} api={calmApi} items={items} display={display} count={count} rollDuration={rollDuration} countPrefix={countPrefix} countSuffix={countSuffix} footer={calmFooter} className={calmClassName} > <Title className={titleClass}>{calmTitle}</Title> </CalmPanel> </div> </div> ); } function NoiseCard({ item }: { item: NoiseGateItem }) { const tone = TONE[item.tone ?? "neutral"]; const copies = clamp(Math.round(item.repeat ?? 1), 1, 4) - 1; return ( <div className="relative"> {Array.from({ length: copies }, (_, i) => ( <div key={i} className={cn(SURFACE, "absolute inset-0 origin-top")} style={{ transform: `translateY(${-(copies - i) * 6}px) scale(${1 - (copies - i) * 0.05})`, opacity: 0.45 + (i / copies) * 0.35, }} /> ))} <div className={cn(SURFACE, "relative flex items-start gap-2.5 px-3 py-2.5")}> {item.icon ? ( <span className={cn("grid size-5 shrink-0 place-items-center [&_svg]:size-4", tone.icon)}> {item.icon} </span> ) : null} <div className="min-w-0 flex-1"> <div className="flex items-start gap-1.5"> {item.tag ? ( <span className={cn("shrink-0 rounded-[5px] px-1.5 text-[11px] leading-5 font-medium", tone.tag)}> {item.tag} </span> ) : null} <span className={cn( "line-clamp-2 min-w-0 flex-1 text-[13px] leading-5 font-medium text-zinc-900 dark:text-zinc-100", item.mono && "font-mono text-[12px] tracking-tight", )} > {item.title} </span> {copies > 0 ? ( <span className="shrink-0 text-[11px] leading-5 text-zinc-500 tabular-nums dark:text-zinc-400"> ×{copies + 1} </span> ) : null} </div> {item.detail ? ( <div className="mt-0.5 truncate text-[12px] leading-[18px] text-zinc-500 dark:text-zinc-400"> {item.detail} </div> ) : null} </div> </div> </div> ); } type Row = { id: number; item: number }; /** The last few items that have a resolved line, newest first, so the log never starts empty. */ const initialRows = (items: NoiseGateItem[]): Row[] => items .map((item, i) => ({ id: -1 - i, item: i, ok: item.resolved != null })) .filter((row) => row.ok) .slice(-3) .reverse() .map(({ id, item }) => ({ id, item })); function CalmPanel({ ref, api, items, display, count: start, rollDuration, countPrefix, countSuffix, footer, className, children, }: { ref: React.Ref<HTMLDivElement>; api: React.Ref<CalmApi>; items: NoiseGateItem[]; display: "count" | "log"; count: number; rollDuration: number; countPrefix?: React.ReactNode; countSuffix?: React.ReactNode; footer?: React.ReactNode; className?: string; children: React.ReactNode; }) { const panelRef = React.useRef<HTMLDivElement>(null); const anchorRef = React.useRef<HTMLElement>(null); const glowRef = React.useRef<HTMLSpanElement>(null); const [count, setCount] = React.useState(start); const [rows, setRows] = React.useState(() => initialRows(items)); const [pulses, setPulses] = React.useState<Pulse[]>([]); const nextId = React.useRef(0); React.useImperativeHandle(ref, () => panelRef.current as HTMLDivElement); React.useImperativeHandle( api, () => ({ gate({ item, from, to, stacked }) { const panel = panelRef.current; const anchor = anchorRef.current; if (!panel || !anchor) return; const p = panel.getBoundingClientRect(); const a = anchor.getBoundingClientRect(); let route: Pulse["route"] = null; let exit: Pulse["exit"] = null; if (!stacked) { // Down (or up) the seam to the horizon, then along it to the pill or the log. const y = display === "log" ? a.top + 20 - p.top : a.top + a.height / 2 - p.top; const x = a.left - p.left; const yc = clamp((from + to) / 2, 8, p.height - 8); const leg1 = Math.abs(y - yc); const leg2 = Math.max(0, x - 4); const total = Math.max(1, leg1 + leg2); route = { x: [4, 4, x], y: [yc, y, y], times: [0, leg1 / total, 1], duration: clamp(total / TIMING.route, TIMING.routeDuration[0], TIMING.routeDuration[1]), }; exit = { x0: a.right - p.left, x1: p.width - 4, y }; } setPulses((list) => [ ...list, { id: nextId.current++, item, weight: items[item]?.weight ?? 1, stacked, from, to, route, exit }, ]); }, }), [display, items], ); const arrive = React.useCallback((pulse: Pulse) => { setCount((c) => c + pulse.weight); setRows((list) => [{ id: pulse.id, item: pulse.item }, ...list].slice(0, GATE.logRows)); const glow = glowRef.current; if (glow) animate( glow, { opacity: [0, 1, 0] }, { duration: TIMING.glow.duration, times: [...TIMING.glow.times], ease: TIMING.glow.ease }, ); }, []); const done = React.useCallback((id: number) => { setPulses((list) => list.filter((p) => p.id !== id)); }, []); return ( <div ref={panelRef} className={cn( "relative z-10 flex flex-col bg-[#3245ff] text-white shadow-[0_-24px_40px_-32px_rgb(9_9_11/0.3)] @[40rem]/noise-gate:shadow-[-24px_0_40px_-32px_rgb(9_9_11/0.3)]", className, )} > {children} <div className={cn( "relative mt-7 flex flex-col pb-9 @[40rem]/noise-gate:absolute @[40rem]/noise-gate:inset-x-0 @[40rem]/noise-gate:top-[60%] @[40rem]/noise-gate:bottom-0 @[40rem]/noise-gate:mt-0 @[40rem]/noise-gate:pb-0", footer && "pb-8", )} > {display === "count" ? ( <div className="flex items-center @[40rem]/noise-gate:-mt-[22px]"> <span aria-hidden className="h-[3px] min-w-6 flex-1 opacity-50" style={DOTTED} /> <div ref={anchorRef as React.Ref<HTMLDivElement>} className="relative mx-2 inline-flex h-11 max-w-[calc(100%-4.5rem)] shrink-0 items-center gap-2 rounded-full border border-current/25 bg-current/[0.08] px-4 text-[clamp(0.8125rem,3.9cqi,0.9375rem)] font-medium whitespace-nowrap @[40rem]/noise-gate:text-[clamp(0.8125rem,1.75cqi,0.9375rem)]" > <span ref={glowRef} aria-hidden className="absolute inset-0 rounded-full bg-current/15 opacity-0" /> <CheckIcon className="relative size-4 shrink-0" /> <span aria-hidden className="relative inline-flex min-w-0 items-baseline gap-[0.3em]"> {countPrefix != null ? <span>{countPrefix}</span> : null} <Digits value={count} duration={rollDuration} /> {countSuffix != null ? <span className="truncate">{countSuffix}</span> : null} </span> <span className="sr-only"> {countPrefix} {count.toLocaleString("en-US")} {countSuffix} </span> </div> <span aria-hidden className="h-[3px] min-w-6 flex-1 opacity-50" style={DOTTED} /> </div> ) : ( <div className="flex items-start @[40rem]/noise-gate:-mt-5 @[40rem]/noise-gate:min-h-0 @[40rem]/noise-gate:flex-1"> <span aria-hidden className="mt-[18.5px] h-[3px] min-w-6 flex-1 opacity-50" style={DOTTED} /> <ul ref={anchorRef as React.Ref<HTMLUListElement>} className="relative mx-2 h-[12.5rem] w-[min(19rem,calc(100%-4.5rem))] shrink-0 overflow-hidden text-left [mask-image:linear-gradient(to_bottom,#000_45%,transparent)] @[40rem]/noise-gate:h-full" > <AnimatePresence initial={false}> {rows.map((row) => { const item = items[row.item]; if (!item) return null; return ( // A new line opens at the top and pushes the rest down; the oldest fades out under the mask. <motion.li key={row.id} initial={{ height: 0, opacity: 0 }} animate={{ height: 40, opacity: 1 }} exit={{ opacity: 0 }} transition={TIMING.row} className="flex items-center gap-2.5 overflow-hidden border-b border-current/15 text-[14px]" > <CheckIcon className="size-4 shrink-0 opacity-85" /> <span className="min-w-0 flex-1 truncate font-medium">{item.resolved ?? item.title}</span> {item.resolvedDetail != null ? ( <span className="shrink-0 tabular-nums opacity-85">{item.resolvedDetail}</span> ) : null} </motion.li> ); })} </AnimatePresence> </ul> <span aria-hidden className="mt-[18.5px] h-[3px] min-w-6 flex-1 opacity-50" style={DOTTED} /> </div> )} {footer ? ( <div className="relative mt-7 px-6 text-center @[40rem]/noise-gate:mt-auto @[40rem]/noise-gate:pt-6 @[40rem]/noise-gate:pb-8"> {footer} </div> ) : null} </div> {pulses.map((pulse) => ( <PulseView key={pulse.id} pulse={pulse} onArrive={arrive} onDone={done} /> ))} </div> ); } /** The seam lighting up where a card went through, and the dot routed to the counter. */ function PulseView({ pulse, onArrive, onDone, }: { pulse: Pulse; onArrive: (pulse: Pulse) => void; onDone: (id: number) => void; }) { const flashRef = React.useRef<HTMLSpanElement>(null); const dotRef = React.useRef<HTMLSpanElement>(null); const exitRef = React.useRef<HTMLSpanElement>(null); React.useEffect(() => { const flash = flashRef.current; if (!flash) return; let live = true; const running: { stop: () => void }[] = []; running.push( animate( flash, { opacity: [0, 1, 0] }, { duration: TIMING.flash.duration, times: [...TIMING.flash.times], ease: TIMING.flash.ease }, ), ); const settle = (seconds: number) => running.push(animate(0, 1, { duration: seconds, onComplete: () => live && onDone(pulse.id) })); const arrived = () => { if (!live) return; onArrive(pulse); const exit = exitRef.current; if (pulse.exit && exit && pulse.exit.x1 > pulse.exit.x0) { const { x0, x1, y } = pulse.exit; running.push( animate( exit, { x: [x0, x1], y: [y, y], opacity: [0.6, 0] }, { duration: clamp( ((x1 - x0) / TIMING.route) * TIMING.exitPace, TIMING.exitDuration[0], TIMING.exitDuration[1], ), ease: TIMING.exitEase, onComplete: () => live && onDone(pulse.id), }, ), ); } else { settle(TIMING.linger); } }; const dot = dotRef.current; if (pulse.route && dot) { const { x, y, times, duration } = pulse.route; running.push( animate(dot, { x, y, opacity: [0, 1, 1] }, { duration, times, ease: [...TIMING.routeEase], onComplete: arrived }), ); } else { running.push(animate(0, 1, { duration: TIMING.tickDelay, onComplete: arrived })); } return () => { live = false; for (const r of running) r.stop(); }; }, [pulse, onArrive, onDone]); const dotClass = "pointer-events-none absolute top-0 left-0 -mt-[3px] -ml-[3px] size-1.5 rounded-full bg-current opacity-0 ring-[3px] ring-current/20"; return ( <> <span ref={flashRef} aria-hidden className={cn( "pointer-events-none absolute opacity-0", pulse.stacked ? "top-0 h-8 [mask-image:linear-gradient(to_right,transparent,#000_30%,#000_70%,transparent)]" : "left-0 w-8 [mask-image:linear-gradient(to_bottom,transparent,#000_30%,#000_70%,transparent)]", )} style={pulse.stacked ? { left: pulse.from, width: pulse.to - pulse.from } : { top: pulse.from, height: pulse.to - pulse.from }} > <span className={cn( "absolute bg-current", pulse.stacked ? "inset-x-0 top-0 h-0.5" : "inset-y-0 left-0 w-0.5", )} /> <span className={cn( "absolute inset-0 from-current/20 to-transparent", pulse.stacked ? "bg-linear-to-b" : "bg-linear-to-r", )} /> </span> {pulse.route ? <span ref={dotRef} aria-hidden className={dotClass} /> : null} {pulse.exit ? <span ref={exitRef} aria-hidden className={dotClass} /> : null} </> ); } function Digits({ value, duration }: { value: number; duration: number }) { const chars = value.toLocaleString("en-US").split(""); return ( <span className="inline-flex font-semibold tabular-nums"> {chars.map((char, i) => ( // Keyed from the right, so a new leading digit doesn't re-roll the rest. <Digit key={chars.length - i} char={char} duration={duration} /> ))} </span> ); } function Digit({ char, duration }: { char: string; duration: number }) { return ( <span className="relative inline-flex overflow-hidden"> <AnimatePresence initial={false} mode="popLayout"> <motion.span key={char} initial={{ y: "100%", opacity: 0 }} animate={{ y: "0%", opacity: 1 }} exit={{ y: "-100%", opacity: 0 }} transition={{ ...TIMING.roll, visualDuration: duration }} className="inline-block" > {char} </motion.span> </AnimatePresence> </span> ); } function CheckIcon({ className }: { className?: string }) { return ( <svg aria-hidden viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={className} > <path d="M3.5 8.5 6.5 11.5 12.5 4.5" /> </svg> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Noise Gate. Each one installs whole, with the component included.
Month-End Close
The log display. Missing receipts, a duplicate AWS charge and a spreadsheet four people are editing go through the gate and come out as reconciled lines with their amounts, newest on top.
- FX revalued9 invoices
- Payroll accrued$64,210.00
- Bank feed categorized312 lines
October closed on the 2nd.
import { NoiseGate, type NoiseGateItem } from "@/components/ui/noise-gate";
function Icon({ d }: { d: string }) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
<path d={d} />
</svg>
);
}
const receipt = "M4 2h8v12l-2-1.25L8 14l-2-1.25L4 14V2ZM6 5.5h4M6 8.5h4";
const copy = "M5.5 5.5V3a1 1 0 0 1 1-1H13a1 1 0 0 1 1 1v6.5a1 1 0 0 1-1 1h-2.5M3 5.5h6.5a1 1 0 0 1 1 1V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V6.5a1 1 0 0 1 1-1Z";
const file = "M9 2H4.5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V5.5L9 2ZM9 2v3.5h3.5";
const bank = "M2 6h12L8 2.5 2 6ZM3.5 6v5.5M6.5 6v5.5M9.5 6v5.5M12.5 6v5.5M2 13.5h12";
const close: NoiseGateItem[] = [
{
tag: "Missing",
tone: "critical",
icon: <Icon d={receipt} />,
title: "No receipt for Uber",
detail: "$23.40 · Oct 3",
resolved: "Uber receipt matched",
resolvedDetail: "$23.40",
},
{
tag: "Duplicate?",
tone: "warning",
icon: <Icon d={copy} />,
title: "AWS charged twice",
detail: "$1,204.88 · Oct 1",
resolved: "AWS duplicate refunded",
resolvedDetail: "$1,204.88",
},
{
tag: "Overdue",
tone: "critical",
repeat: 3,
title: "Invoice #2291 unpaid",
detail: "Northwind · 14 days late",
resolved: "Invoice #2291 collected",
resolvedDetail: "$8,400.00",
},
{
mono: true,
icon: <Icon d={file} />,
title: "Q3_recon_v7_FINAL(2).xlsx",
detail: "Edited by 4 people today",
resolved: "Q3 reconciled",
resolvedDetail: "1,284 lines",
},
{
icon: <span className="grid size-5 place-items-center rounded-full bg-teal-600 text-[9px] font-semibold text-white">AL</span>,
title: "“Why is cash off by $0.03?”",
detail: "Ana L. · #finance",
resolved: "Rounding difference booked",
resolvedDetail: "$0.03",
},
{
tag: "Unmatched",
tone: "warning",
icon: <Icon d={bank} />,
title: "312 bank lines uncategorized",
detail: "Operating account ••4410",
resolved: "Bank feed categorized",
resolvedDetail: "312 lines",
},
{
tag: "Accrual",
tone: "critical",
title: "Payroll accrual not booked",
detail: "Due before close",
resolved: "Payroll accrued",
resolvedDetail: "$64,210.00",
},
{
title: "Which FX rate did we use?",
detail: "EUR invoices · 9 open",
resolved: "FX revalued",
resolvedDetail: "9 invoices",
},
];
export function NoiseGateLedgerDemo() {
return (
<NoiseGate
className="max-w-5xl"
display="log"
items={close}
noiseTitle={
<>
Closing the books with <span className="text-zinc-500 dark:text-zinc-400">“one more spreadsheet”</span>
</>
}
calmTitle="Closing with Ledgerline"
calmClassName="bg-[#0b6b4f] text-white"
calmFooter={<p className="text-sm text-white/85">October closed on the 2nd.</p>}
/>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Noise Gate: A before-and-after section. Alerts, pings and complaints drift into the seam between two panels and slide under the calm one, and each card that goes through lights the seam and ticks a counter, or lands as a tidy line in a log. This demo, Month-End Close: The log display. Missing receipts, a duplicate AWS charge and a spreadsheet four people are editing go through the gate and come out as reconciled lines with their amounts, newest on top. 1. Install it: npx shadcn@latest add @keyframeui/noise-gate-ledger-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/noise-gate-ledger-demo.json instead.) It adds the demo, components/noise-gate-ledger-demo.tsx, and the component, components/ui/noise-gate.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <NoiseGate> on its own. 3. These props set its feel. Change them before you change the file: - display (default "count"): What the calm side shows on its horizon: a pill with a counter, or a log in which each card that goes through becomes its `resolved` line, newest on top. - density (default 9): Most cards on screen at once. Fewer show when the panel is small, so it never turns into a wall. - duration (default 15): Seconds a mid-depth card takes to cross the noise panel. Far cards are slower and near ones quicker. - bob (default 3): How far each card floats across its lane as it drifts, in px. `0` keeps every card on a straight line. - settle (default 0.32): How quickly the stream eases to a stop under the pointer (or with `paused`) and gets going again, as a time constant in seconds. Smaller stops more sharply. - rollDuration (default 0.42): Seconds the counter's changed digits take to roll in when a card reaches the gate. 4. Don't change, in components/ui/noise-gate.tsx: - One rAF loop moves every card by writing `transform` and `opacity` straight to the DOM and redraws the traces; the stream never re-renders, and the loop sleeps when stopped or off screen. - Arrivals are scheduled: each new card starts where it reaches the seam about one beat after the card before it, give or take the jitter, so ticks keep a rhythm instead of bunching. - Hover and `paused` ease the whole stream to a stop through one shared factor, never a snap, and the card under the pointer comes forward on top of the rest. - Digits are keyed from the right and roll in `popLayout`, so a new leading digit doesn't re-roll the rest; the count reads from an `sr-only` span while the moving parts are `aria-hidden`. - Reduced motion shows a seeded, still scatter of cards and traces: no stream and no ticks. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/noise-gate.md
Focus Section
A whole landing section: heading, subhead, and a calmer, slower gate with a call to action pinned under the counter. The calm panel is ink in light mode and turns white in dark mode.
Quietly holds pings, threads and invites until they matter, then hands you one calm digest.
import { NoiseGate, type NoiseGateItem } from "@/components/ui/noise-gate";
function Icon({ d }: { d: string }) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
<path d={d} />
</svg>
);
}
function Avatar({ initials, className }: { initials: string; className: string }) {
return (
<span className={`grid size-5 place-items-center rounded-full text-[9px] font-semibold text-white ${className}`}>
{initials}
</span>
);
}
const hash = "M6 2.5 4.5 13.5M11.5 2.5 10 13.5M2.5 6h11M2 10h11";
const calendar = "M2.5 4.5a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-8ZM2.5 7h11M5.5 2v2.5M10.5 2v2.5";
const bell = "M4 11V7a4 4 0 0 1 8 0v4l1 1.5H3L4 11ZM6.5 14h3";
const mail = "M2.5 4.5h11v7a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-7ZM2.5 4.5 8 9l5.5-4.5";
const pings: NoiseGateItem[] = [
{ icon: <Icon d={hash} />, title: "@here standup moved to 9:15 (again)", detail: "#general · 214 people" },
{
tag: "DM",
tone: "warning",
icon: <Avatar initials="PS" className="bg-fuchsia-500" />,
title: "quick q, got 5 min?",
detail: "Priya · just now",
},
{
tag: "Conflict",
tone: "critical",
icon: <Icon d={calendar} />,
title: "3 meetings at 2:00 PM",
detail: "Thursday · you accepted all three",
},
{
icon: <Avatar initials="SR" className="bg-orange-500" />,
title: "“Can you hop on a call real quick?”",
detail: "Sam R. · huddle",
},
{ icon: <Icon d={bell} />, title: "48 unread in #design-feedback", detail: "Since you looked away" },
{
tag: "Mention",
tone: "warning",
repeat: 3,
title: "You were mentioned in #launch",
detail: "Three threads, same question",
},
{ icon: <Icon d={mail} />, title: "Re: Re: Fwd: Re: Q4 offsite", detail: "17 replies" },
{ tag: "Reminder", tone: "critical", title: "Reply to Dana's thread", detail: "Snoozed 4 times" },
{ icon: <Icon d={calendar} />, title: "This week is 86% meetings", detail: "Up from 71%" },
];
export function NoiseGateFocusDemo() {
return (
<section className="w-full max-w-5xl">
<div className="mx-auto max-w-xl text-center">
<h2 className="text-[clamp(2rem,5vw,3rem)] leading-[1.02] font-semibold tracking-[-0.04em] text-balance text-zinc-950 dark:text-zinc-50">
Your attention, back.
</h2>
<p className="mx-auto mt-4 max-w-md text-base leading-relaxed text-pretty text-zinc-600 dark:text-zinc-400">
Quietly holds pings, threads and invites until they matter, then hands you one calm digest.
</p>
</div>
<NoiseGate
className="mt-10"
items={pings}
density={6}
duration={18}
noiseTitle={
<>
Getting work done with <span className="text-zinc-500 dark:text-zinc-400">“do not disturb”</span>
</>
}
calmTitle="Focusing with Quietly"
count={312}
countPrefix="Held back"
countSuffix="pings today"
calmClassName="bg-zinc-950 text-white dark:bg-zinc-50 dark:text-zinc-950"
calmFooter={
<a
href="#early-access"
className="inline-flex h-10 items-center gap-1.5 rounded-full bg-white px-5 text-sm font-medium text-zinc-950 transition-[transform,background-color] duration-150 ease-out hover:bg-zinc-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white active:scale-[0.97] dark:bg-zinc-950 dark:text-white dark:hover:bg-zinc-800 dark:focus-visible:outline-zinc-950"
>
Get early access
<svg aria-hidden viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className="size-4">
<path d="M3.5 8h9M8.5 4l4 4-4 4" />
</svg>
</a>
}
/>
</section>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Noise Gate: A before-and-after section. Alerts, pings and complaints drift into the seam between two panels and slide under the calm one, and each card that goes through lights the seam and ticks a counter, or lands as a tidy line in a log. This demo, Focus Section: A whole landing section: heading, subhead, and a calmer, slower gate with a call to action pinned under the counter. The calm panel is ink in light mode and turns white in dark mode. 1. Install it: npx shadcn@latest add @keyframeui/noise-gate-focus-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/noise-gate-focus-demo.json instead.) It adds the demo, components/noise-gate-focus-demo.tsx, and the component, components/ui/noise-gate.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <NoiseGate> on its own. 3. These props set its feel. Change them before you change the file: - display (default "count"): What the calm side shows on its horizon: a pill with a counter, or a log in which each card that goes through becomes its `resolved` line, newest on top. - density (default 9): Most cards on screen at once. Fewer show when the panel is small, so it never turns into a wall. - duration (default 15): Seconds a mid-depth card takes to cross the noise panel. Far cards are slower and near ones quicker. - bob (default 3): How far each card floats across its lane as it drifts, in px. `0` keeps every card on a straight line. - settle (default 0.32): How quickly the stream eases to a stop under the pointer (or with `paused`) and gets going again, as a time constant in seconds. Smaller stops more sharply. - rollDuration (default 0.42): Seconds the counter's changed digits take to roll in when a card reaches the gate. 4. Don't change, in components/ui/noise-gate.tsx: - One rAF loop moves every card by writing `transform` and `opacity` straight to the DOM and redraws the traces; the stream never re-renders, and the loop sleeps when stopped or off screen. - Arrivals are scheduled: each new card starts where it reaches the seam about one beat after the card before it, give or take the jitter, so ticks keep a rhythm instead of bunching. - Hover and `paused` ease the whole stream to a stop through one shared factor, never a snap, and the card under the pointer comes forward on top of the rest. - Digits are keyed from the right and roll in `popLayout`, so a new leading digit doesn't re-roll the rest; the count reads from an `sr-only` span while the moving parts are `aria-hidden`. - Reduced motion shows a seeded, still scatter of cards and traces: no stream and no ticks. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/noise-gate.md
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items Required | NoiseGateItem[] | None | The cards that drift into the gate, cycled in order. Each card is rendered once and reused, so 8 to 12 give enough variety. Their fields are listed below as item.*. |
noiseTitle Required | React.ReactNode | None | Heading of the noise side. Cards stay below it, so it's never covered. Quoting the old way works well: Answering tickets with “canned replies”. |
calmTitle Required | React.ReactNode | None | Heading of the calm side, on the same line as the noise heading. |
display | "count" | "log" | "count" | What the calm side shows on its horizon: a pill with a counter, or a log in which each card that goes through becomes its resolved line, newest on top. |
count | number | 0 | The counter's starting value. Each card adds its weight when it reaches the gate. |
countPrefix | React.ReactNode | None | Text before the number in the pill, e.g. "Resolved". |
countSuffix | React.ReactNode | None | Text after the number, e.g. "tickets today". Screen readers get the whole line, without the rolling digits. |
calmFooter | React.ReactNode | None | Pinned to the bottom of the calm side: a call to action or a footnote. |
density | number | 9 | Most cards on screen at once. Fewer show when the panel is small, so it never turns into a wall. |
duration | number | 15 | Seconds a mid-depth card takes to cross the noise panel. Far cards are slower and near ones quicker. |
bob | number | 3 | How far each card floats across its lane as it drifts, in px. 0 keeps every card on a straight line. |
settle | number | 0.32 | How quickly the stream eases to a stop under the pointer (or with paused) and gets going again, as a time constant in seconds. Smaller stops more sharply. |
rollDuration | number | 0.42 | Seconds the counter's changed digits take to roll in when a card reaches the gate. |
paused | boolean | false | Eases the stream to a stop. It also stops while a mouse is over the noise, so a card can be read, and the card under the pointer comes forward. |
titleAs | "h2" | "h3" | "h4" | "p" | "h3" | Element for both headings. |
height | string | "28rem" | Height while the panels sit side by side. Below a 40rem container they stack, and each panel sizes itself. |
className | string | None | Classes for the root, e.g. a max width. Set [--noise-gate-radius:0px] for square corners. |
noiseClassName | string | "bg-white dark:bg-zinc-950" | Classes for the noise panel. |
calmClassName | string | "bg-[#3245ff] text-white" | Classes for the calm panel: its background and text color. The pill, the dotted horizon, the seam light and the dots are all drawn in its text color. |
item.title Required | React.ReactNode | None | NoiseGateItem: the card's main line, up to two lines. |
item.detail | React.ReactNode | None | NoiseGateItem: a smaller, muted second line. |
item.tag | React.ReactNode | None | NoiseGateItem: a short label in a pill before the title, e.g. "SLA" or "Overdue". |
item.tone | "critical" | "warning" | "neutral" | "neutral" | NoiseGateItem: colors the tag and the icon. |
item.icon | React.ReactNode | None | NoiseGateItem: a leading icon or avatar, about 20px. SVG icons take the tone's color. |
item.mono | boolean | false | NoiseGateItem: sets the title in monospace, for log lines, file names and codes. |
item.repeat | number | 1 | NoiseGateItem: stacks up to 4 copies behind the card, like an alert that keeps firing, with a ×n count. |
item.weight | number | 1 | NoiseGateItem: how much the counter goes up when this card reaches the gate. |
item.resolved | React.ReactNode | None | NoiseGateItem: with display="log", the line this card becomes once it's through. Falls back to title. |
item.resolvedDetail | React.ReactNode | None | NoiseGateItem: with display="log", a right-aligned value beside resolved, in tabular figures. |
itemsRequiredType
NoiseGateItem[]The cards that drift into the gate, cycled in order. Each card is rendered once and reused, so 8 to 12 give enough variety. Their fields are listed below as
item.*.noiseTitleRequiredType
React.ReactNodeHeading of the noise side. Cards stay below it, so it's never covered. Quoting the old way works well: Answering tickets with “canned replies”.
calmTitleRequiredType
React.ReactNodeHeading of the calm side, on the same line as the noise heading.
displayType
"count" | "log"Default"count"What the calm side shows on its horizon: a pill with a counter, or a log in which each card that goes through becomes its
resolvedline, newest on top.countType
numberDefault0The counter's starting value. Each card adds its
weightwhen it reaches the gate.countPrefixType
React.ReactNodeText before the number in the pill, e.g. "Resolved".
countSuffixType
React.ReactNodeText after the number, e.g. "tickets today". Screen readers get the whole line, without the rolling digits.
calmFooterType
React.ReactNodePinned to the bottom of the calm side: a call to action or a footnote.
densityType
numberDefault9Most cards on screen at once. Fewer show when the panel is small, so it never turns into a wall.
durationType
numberDefault15Seconds a mid-depth card takes to cross the noise panel. Far cards are slower and near ones quicker.
bobType
numberDefault3How far each card floats across its lane as it drifts, in px.
0keeps every card on a straight line.settleType
numberDefault0.32How quickly the stream eases to a stop under the pointer (or with
paused) and gets going again, as a time constant in seconds. Smaller stops more sharply.rollDurationType
numberDefault0.42Seconds the counter's changed digits take to roll in when a card reaches the gate.
pausedType
booleanDefaultfalseEases the stream to a stop. It also stops while a mouse is over the noise, so a card can be read, and the card under the pointer comes forward.
titleAsType
"h2" | "h3" | "h4" | "p"Default"h3"Element for both headings.
heightType
stringDefault"28rem"Height while the panels sit side by side. Below a 40rem container they stack, and each panel sizes itself.
classNameType
stringClasses for the root, e.g. a max width. Set
[--noise-gate-radius:0px]for square corners.noiseClassNameType
stringDefault"bg-white dark:bg-zinc-950"Classes for the noise panel.
calmClassNameType
stringDefault"bg-[#3245ff] text-white"Classes for the calm panel: its background and text color. The pill, the dotted horizon, the seam light and the dots are all drawn in its text color.
item.titleRequiredType
React.ReactNodeNoiseGateItem: the card's main line, up to two lines.item.detailType
React.ReactNodeNoiseGateItem: a smaller, muted second line.item.tagType
React.ReactNodeNoiseGateItem: a short label in a pill before the title, e.g. "SLA" or "Overdue".item.toneType
"critical" | "warning" | "neutral"Default"neutral"NoiseGateItem: colors the tag and the icon.item.iconType
React.ReactNodeNoiseGateItem: a leading icon or avatar, about 20px. SVG icons take the tone's color.item.monoType
booleanDefaultfalseNoiseGateItem: sets the title in monospace, for log lines, file names and codes.item.repeatType
numberDefault1NoiseGateItem: stacks up to 4 copies behind the card, like an alert that keeps firing, with a ×n count.item.weightType
numberDefault1NoiseGateItem: how much the counter goes up when this card reaches the gate.item.resolvedType
React.ReactNodeNoiseGateItem: withdisplay="log", the line this card becomes once it's through. Falls back totitle.item.resolvedDetailType
React.ReactNodeNoiseGateItem: withdisplay="log", a right-aligned value besideresolved, in tabular figures.