Skip to main content

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.

Usage

registry/core/carbon-ply-fade/demo.tsx
"use client";

import { CarbonPlyFade } from "./component";

export default function CarbonPlyFadeDemo() {
  return (
    <main className="flex min-h-screen items-center justify-center bg-background p-6">
      <div className="w-full max-w-sm">
        <p className="mb-4 font-mono text-xs tracking-widest text-ns-muted">
          ns-ui / carbon-ply-fade
        </p>
        <div className="rounded-md border border-border bg-surface p-4">
          <CarbonPlyFade aria-label="Recent event copy" />
        </div>
        <p className="mt-3 font-mono text-[11px] text-ns-muted">
          each strike echoes down the stack, fainter ply by ply — hover the top card to hold it
        </p>
      </div>
    </main>
  );
}

Component

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>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/carbon-ply-fade.tsx, so point the import there.

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.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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

PropTypeDefaultDescription
className?string——
aria-label?string"Recent activity"accessible name for the live region carried by ply 1. Default "Recent activity".
events?string[]DEFAULT_EVENTSrotating pool of event labels the strikes pull from; defaults to a built-in set