ns-ui
Carbon Ply Fade
Activity feed rendered as a carbon-paper multi-part form: one strike lands on the top ply at full density and echoes down through 4-5 contiguous, fully-visible ply rows, each registering 90ms after the one above at 0.68x the density, matching real pressure-copy force dissipation through a stack.
Use when an activity feed or fanout notice ("this event was also copied to N recipients") where the visual should read as one strike propagating through a stack rather than N separate items, and the ranking/recency itself carries the meaning (top = current, lower = staler copies); pick feed-escapement instead when arrivals are a genuine FIFO queue that should be metered one row at a time rather than shown all at once as a density stack, or toast-gravity-stack when the arrivals are transient dismissible toasts rather than a permanent multi-ply record.
Install
npx shadcn add https://design.helpmarq.com /r/carbon-ply-fade.jsonSource
registry/core/carbon-ply-fade/component.tsx"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// CarbonPlyFade — an activity feed rendered as a carbon-paper multi-part
// form. Ply 1 through ply N are stacked as full-width, fully-visible rows
// inside one bordered form (a real stack, contiguous rows rather than
// overlapping cards — overlap would bury a lower ply's text under the
// opaque ply above it, which is the one thing that must stay legible: the
// falloff has to be readable on every ply at once). Each row is nudged
// ~2px right per ply below it (the spec's "2px right per ply behind it")
// to read as a peeled multi-part slip. A "strike" (one event) lands on ply
// 1 at full density and propagates down through the stack 90ms per row,
// each ply's registered density = the one above it x0.68 — the real
// force-dissipation falloff of a pressure-copy stack, not a chemical or
// optical fade. A ply's content flashes to its peak density INSTANTLY on
// arrival and holds there (carbon transfer is permanent per pull, it does
// not fade back out); only the row itself gives a small physical squash
// (scaleY 0.985, 60ms) and spring-back (140ms) on impact. Between strikes
// every ply is static for the remaining ~700ms of the 1.1s cadence.
// Geometry (row height, offset, font size, ply count) derives from the
// container's smaller dimension so it reads at card scale. Hovering ply 1
// pauses the cadence without touching what's already registered; the
// strike never uses --ns-accent, density is luminance-only. prefers-
// reduced-motion freezes on a fully-propagated strike, 90ms x3 after it
// lands, every ply settled and none mid-transit.
// ---------------------------------------------------------------------------
const STRIKE_INTERVAL_MS = 1100; // top-of-cadence, one strike per interval
const PROPAGATION_DELAY_MS = 90; // per-ply registration delay below the one above
const FALLOFF_RATIO = 0.68; // ply(N) peak density = ply(N-1) peak density * this
const MIN_OPACITY = 0.12; // floor so a deep ply reads "faint", never "absent"
const ROW_HEIGHT = 40; // px, one ply's readable row height at card scale, before scale
const MIN_PLY_COUNT = 4;
const MAX_PLY_COUNT = 5;
const OFFSET_X = 2; // px per ply, horizontal peel, before scale
const COMPRESS_MS = 60;
const SPRING_BACK_MS = 140;
const DEFAULT_EVENTS = [
"Invoice #4471 → Billing, Ops, Archive",
"Order #8825 → Warehouse, Shipping",
"Ticket #221 → Support, Escalations",
"Memo 6B → Legal, Compliance, Records",
"Claim #503 → Underwriting, Adjuster",
];
function opacityForPly(i: number): number {
return Math.max(MIN_OPACITY, Math.pow(FALLOFF_RATIO, i));
}
type PlyNode = {
cardEl: HTMLDivElement | null;
textEl: HTMLParagraphElement | null;
};
export interface CarbonPlyFadeProps {
className?: string;
/** accessible name for the live region carried by ply 1. Default "Recent activity". */
"aria-label"?: string;
/** rotating pool of event labels the strikes pull from; defaults to a built-in set */
events?: string[];
}
export function CarbonPlyFade({
className = "",
"aria-label": ariaLabel = "Recent activity",
events = DEFAULT_EVENTS,
}: CarbonPlyFadeProps) {
const containerRef = useRef<HTMLDivElement>(null);
const plyRefs = useRef<PlyNode[]>([]);
const timeoutsRef = useRef<number[]>([]);
const strikeTimeoutRef = useRef<number | null>(null);
const eventIdxRef = useRef(-1);
const pausedRef = useRef(false);
const reducedRef = useRef(false);
const runStrikeRef = useRef<() => void>(() => {});
const [plyCount, setPlyCount] = useState(MIN_PLY_COUNT);
const [scale, setScale] = useState(1);
const plyIndices = useMemo(() => Array.from({ length: plyCount }, (_, i) => i), [plyCount]);
const clearAllTimeouts = useCallback(() => {
for (const id of timeoutsRef.current) window.clearTimeout(id);
timeoutsRef.current = [];
if (strikeTimeoutRef.current !== null) {
window.clearTimeout(strikeTimeoutRef.current);
strikeTimeoutRef.current = null;
}
}, []);
const registerPly = useCallback((i: number, text: string) => {
const ply = plyRefs.current[i];
if (!ply?.textEl || !ply.cardEl) return;
ply.textEl.textContent = text;
ply.textEl.style.opacity = String(opacityForPly(i));
const row = ply.cardEl;
row.style.transition = `transform ${COMPRESS_MS}ms ease-out`;
row.style.transform = "scaleY(0.985)";
const bounce = window.setTimeout(() => {
row.style.transition = `transform ${SPRING_BACK_MS}ms cubic-bezier(0.34, 1.56, 0.64, 1)`;
row.style.transform = "scaleY(1)";
}, COMPRESS_MS);
timeoutsRef.current.push(bounce);
}, []);
const runStrike = useCallback(() => {
if (pausedRef.current) {
strikeTimeoutRef.current = window.setTimeout(() => runStrikeRef.current(), STRIKE_INTERVAL_MS);
return;
}
eventIdxRef.current = (eventIdxRef.current + 1) % events.length;
const text = events[eventIdxRef.current];
for (let i = 0; i < plyRefs.current.length; i++) {
const id = window.setTimeout(() => registerPly(i, text), i * PROPAGATION_DELAY_MS);
timeoutsRef.current.push(id);
}
strikeTimeoutRef.current = window.setTimeout(() => runStrikeRef.current(), STRIKE_INTERVAL_MS);
}, [events, registerPly]);
runStrikeRef.current = runStrike;
// reduced motion: paint one fully-propagated, fully-settled strike and
// never schedule another — the deliberately chosen non-t0 freeze frame.
const paintReducedFrame = useCallback(() => {
const text = events[0] ?? "";
plyRefs.current.forEach((ply, i) => {
if (!ply?.textEl || !ply.cardEl) return;
ply.textEl.textContent = text;
ply.textEl.style.opacity = String(opacityForPly(i));
ply.cardEl.style.transition = "none";
ply.cardEl.style.transform = "scaleY(1)";
});
}, [events]);
// geometry: ply count and scale derive from the container's smaller
// dimension so this reads at card scale rather than at a fixed pixel size.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const measure = () => {
const rect = el.getBoundingClientRect();
const smaller = Math.min(rect.width, rect.height || rect.width);
const nextScale = Math.max(0.8, Math.min(1.15, smaller / 300));
const usableHeight = rect.height || ROW_HEIGHT * MIN_PLY_COUNT;
const fitCount = Math.floor(usableHeight / (ROW_HEIGHT * nextScale));
const nextCount = Math.max(MIN_PLY_COUNT, Math.min(MAX_PLY_COUNT, fitCount || MIN_PLY_COUNT));
setScale(nextScale);
setPlyCount(nextCount);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
reducedRef.current = mq.matches;
const onChange = (e: MediaQueryListEvent) => {
reducedRef.current = e.matches;
clearAllTimeouts();
if (e.matches) {
paintReducedFrame();
} else {
eventIdxRef.current = -1;
runStrikeRef.current();
}
};
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [clearAllTimeouts, paintReducedFrame]);
useEffect(() => {
clearAllTimeouts();
if (reducedRef.current) {
paintReducedFrame();
return;
}
eventIdxRef.current = -1;
runStrikeRef.current();
return clearAllTimeouts;
// plyCount/scale change the geometry every registered ply reads, and a
// resize mid-cascade should restart cleanly rather than leave stale
// in-flight timeouts targeting the old scale.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [plyCount, scale, clearAllTimeouts, paintReducedFrame]);
const onPointerEnter = useCallback(() => {
pausedRef.current = true;
}, []);
const onPointerLeave = useCallback(() => {
pausedRef.current = false;
}, []);
return (
<div
ref={containerRef}
className={`flex h-full w-full items-start justify-center p-4 ${className}`}
style={{ minHeight: ROW_HEIGHT * MIN_PLY_COUNT }}
>
<div className="w-full max-w-sm overflow-hidden rounded-md border border-border bg-background">
{plyIndices.map((i) => (
<div
key={i}
ref={(el) => {
plyRefs.current[i] = { cardEl: el, textEl: plyRefs.current[i]?.textEl ?? null };
}}
onPointerEnter={i === 0 ? onPointerEnter : undefined}
onPointerLeave={i === 0 ? onPointerLeave : undefined}
className={`flex items-center px-3.5 ${i < plyCount - 1 ? "border-b border-border/60" : ""}`}
style={{
height: ROW_HEIGHT * scale,
transformOrigin: "50% 50%",
paddingLeft: 14 + OFFSET_X * i * scale,
}}
aria-hidden={i === 0 ? undefined : true}
{...(i === 0 ? { role: "status", "aria-live": "polite", "aria-label": ariaLabel } : {})}
>
<p
ref={(el) => {
plyRefs.current[i] = { cardEl: plyRefs.current[i]?.cardEl ?? null, textEl: el };
}}
className="truncate font-mono text-[12px] text-foreground"
style={{ opacity: opacityForPly(i) }}
>
{" "}
</p>
</div>
))}
</div>
</div>
);
}
Build spec
An activity feed reimagined as a carbon-paper multi-part form: one bordered panel containing 4-5 contiguous, fully-visible ply rows (border-b between them, no overlap) rather than overlapping offset cards — overlap would bury a lower ply's text under the opaque ply above it, which defeats the one thing that has to stay legible: the falloff must be readable on every ply at once. Each row is nudged ~2px right per ply below it via paddingLeft (the spec's '2px right per ply behind it'; the spec's ~6px DOWN offset is expressed as the row's own 40px height/pitch rather than a literal 6px overlap-producing translate, a deliberate deviation so four ply rows of real text can coexist on screen — noted here rather than silently kept). Row height and the horizontal nudge are scaled by a factor derived from the container's smaller dimension via ResizeObserver, clamped 0.8-1.15, so the stack reads at card scale. Ply count is available height divided by the 40px row height, clamped 4-5; on a standard card-scale container this settles at exactly 4. Every 1.1s a 'strike' event fires: the component advances to the next label in a built-in (or caller-supplied) rotating event list and, for each ply i from 0 (top) to plyCount-1, schedules a registration at i*90ms after the strike starts — the real mechanical delay of force traveling down a ply stack. When a ply's registration fires its paragraph's textContent is set to the new event label and its opacity is set instantly (no transition — carbon transfer does not fade in) to Math.max(0.12, 0.68**i), so ply 1 is always --foreground at 100%, ply 2 ~68%, ply 3 ~46%, ply 4 ~31% (light theme: --foreground 31% over --background is ~2.2:1, faint but present and clearly above --border's ~1.1:1, so the falloff reads as 'faint' rather than 'gone'). The 12% floor guards a hypothetical 5th ply (0.68**4 ~= 21%) rather than ever engaging at the default 4-ply depth. The ply row itself gets a small physical squash on impact — transform scaleY(0.985) over a 60ms ease-out transition, then a 140ms cubic-bezier(0.34,1.56,0.64,1) spring back to scaleY(1) — read as the row compressing under the strike. Between strikes every ply holds its last registered text and opacity unchanged for the remaining ~700ms of the cadence; nothing fades back out. Hovering ply 1 (onPointerEnter/onPointerLeave) sets a paused ref that skips advancing to the next event while still letting the current strike's cascade finish and letting the interval re-check itself every 1.1s, so a viewer can read the current stack without it being overwritten mid-look, and un-hovering resumes rotation cleanly. Ply 1 alone carries role=status aria-live=polite aria-label so assistive tech announces only the freshest copy; plies 2 and up are aria-hidden, decorative echoes of the same event. The strike flash is luminance-only (opacity of --foreground text, a border-border row divider, a bg-background fill) — --ns-accent never appears. prefers-reduced-motion clears every scheduled timeout and paints one fully-propagated, fully-settled strike directly (all plies at their falloff opacity, no scaleY transition, no further scheduling) rather than freezing mid-cascade. No canvas, no dependencies — pure DOM, refs-only imperative style writes (no per-frame React state) for the strike/registration/squash path, React state only for plyCount and scale from the resize observer.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| className? | string | — | — |
| aria-label? | string | "Recent activity" | accessible name for the live region carried by ply 1. Default "Recent activity". |
| events? | string[] | DEFAULT_EVENTS | rotating pool of event labels the strikes pull from; defaults to a built-in set |