Stratum
A scroll-linked vertical timeline: dated entries with sticky pinned titles, a progress beam that draws down the spine, and node dots that ignite with an escapement pop as the beam reaches them.
motionfree
"use client";
import * as React from "react";
import {
motion,
useScroll,
useTransform,
useMotionValueEvent,
type TargetAndTransition,
} from "motion/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
/** A single dated entry on the timeline. */
export interface StratumEntry {
/**
* Small label rendered above the title — a date, version tag, or step marker.
* Optional; omit for an undated milestone.
*/
date?: React.ReactNode;
/** Entry heading. Pins as a sticky title while its content scrolls past. */
title: React.ReactNode;
/** Body content that scrolls beneath the pinned title. */
content: React.ReactNode;
/** Stable identity for the list key. Falls back to the entry index. */
id?: string;
}
export interface StratumProps
extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
/**
* The timeline entries, top to bottom. Beautiful with zero props — a sample
* changelog renders when omitted.
*/
entries?: StratumEntry[];
/** Which edge the spine sits on. `center` alternates content side to side. @default "left" */
rail?: "left" | "center" | "right";
/** Live-beam + ignited-node color (the one cool accent). @default "#60a5fa" */
accentColor?: string;
/** Dim track + cold (un-ignited) node color. @default "#52525b" */
railColor?: string;
/** Beam / track stroke width in px. @default 2 */
beamWidth?: number;
/** Node marker shape. @default "dot" */
marker?: "dot" | "ring" | "diamond" | "square";
/** Node marker size in px. @default 14 */
markerSize?: number;
/** Sticky title offset from the top of the scroll container, in px. @default 16 */
stickyOffset?: number;
/** Per-entry content reveal as it enters view. @default "slide" */
reveal?: "slide" | "fade" | "none";
/**
* Scroll container to track instead of the window. Pass a ref to an
* `overflow-y-auto` ancestor so the beam scrubs against THAT container
* (e.g. inside a boxed panel). @default the viewport
*/
root?: React.RefObject<HTMLElement | null>;
/** Heading level rendered for each entry title (semantic outline). @default 3 */
headingLevel?: 2 | 3 | 4 | 5 | 6;
}
const DEFAULT_ACCENT = "#60a5fa";
const DEFAULT_RAIL = "#52525b";
const DEFAULT_ENTRIES: StratumEntry[] = [
{
date: "v2.4 — March",
title: "Streaming search",
content:
"Results now surface as you type, with fuzzy matching across titles, tags, and body text. The index rebuilds incrementally in a worker, so typing never blocks.",
},
{
date: "v2.5 — April",
title: "Shared views",
content:
"Save a filtered view and share it with the whole workspace. Everyone sees the same board, live, with cursors and selection presence.",
},
{
date: "v2.6 — May",
title: "Offline drafts",
content:
"Edits queue locally and sync the moment you reconnect. Conflicts resolve field by field, so two people rarely clobber each other's work.",
},
{
date: "v2.7 — June",
title: "Granular roles",
content:
"The old admin toggle is gone. Grant access per project, per environment, or per key — and audit every grant from one place.",
},
{
date: "v3.0 — July",
title: "Audit stream",
content:
"Every change is recorded with who, what, and when. Export on demand, or stream the log straight to your SIEM of choice.",
},
];
/** Reveal target/rest states per preset — used as inline transitions, not variants. */
function revealFrames(preset: NonNullable<StratumProps["reveal"]>): {
hidden: TargetAndTransition;
shown: TargetAndTransition;
} {
switch (preset) {
case "fade":
return { hidden: { opacity: 0 }, shown: { opacity: 1 } };
case "none":
return { hidden: { opacity: 1 }, shown: { opacity: 1 } };
case "slide":
default:
return { hidden: { opacity: 0, y: 14 }, shown: { opacity: 1, y: 0 } };
}
}
interface NodeMarkerProps {
lit: boolean;
reduced: boolean;
marker: NonNullable<StratumProps["marker"]>;
size: number;
accent: string;
cold: string;
}
/**
* A single rail node. Cold slate until the beam reaches it, then it seats with a
* crisp escapement pop — the hot core springs in with a 2px overshoot and locks,
* a halo flares once, and it stays lit thereafter.
*/
function NodeMarker({ lit, reduced, marker, size, accent, cold }: NodeMarkerProps) {
const radius =
marker === "diamond" || marker === "square" ? "18%" : "9999px";
const hollow = marker === "ring";
// Reduced motion: land lit and static, no springs, no flare.
const coreTransition = reduced
? { duration: 0 }
: { type: "spring" as const, stiffness: 720, damping: 15, mass: 0.5 };
return (
<span
className="relative inline-flex items-center justify-center"
style={{ width: size, height: size }}
>
{/* One-shot ignite flare. Mounted only once lit, keyed so it plays exactly
once — unrelated re-renders (other nodes igniting) never replay it. */}
{!reduced && lit && (
<motion.span
key="flare"
aria-hidden
className="pointer-events-none absolute inset-0"
style={{ borderRadius: radius, background: accent }}
initial={{ opacity: 0.55, scale: 0.6 }}
animate={{ opacity: 0, scale: 2.4 }}
transition={{ duration: 0.6, ease: [0.2, 0.7, 0.3, 1] }}
/>
)}
{/* Outer marker: outline that warms from slate to accent. */}
<motion.span
aria-hidden
className={cn(
"absolute inset-0 transition-none",
marker === "diamond" && "rotate-45"
)}
style={{
borderRadius: radius,
borderStyle: "solid",
borderWidth: Math.max(1.5, size * 0.14),
background: hollow ? "transparent" : "#0a0a0a",
}}
initial={false}
animate={{
borderColor: lit ? accent : cold,
boxShadow: lit
? `0 0 ${size * 0.9}px ${accent}, 0 0 ${size * 0.35}px ${accent}`
: `0 0 0px ${accent}00`,
}}
transition={reduced ? { duration: 0 } : { duration: 0.45, ease: "easeOut" }}
/>
{/* Hot core: springs in with overshoot when lit. Absent for the ring marker. */}
{!hollow && (
<motion.span
aria-hidden
className={cn("relative", marker === "diamond" && "rotate-45")}
style={{
width: "46%",
height: "46%",
borderRadius: marker === "diamond" || marker === "square" ? "12%" : "9999px",
background: lit
? `radial-gradient(circle at 50% 40%, #ffffff, ${accent})`
: cold,
}}
initial={false}
animate={{ scale: lit ? 1 : 0 }}
transition={coreTransition}
/>
)}
</span>
);
}
/**
* Stratum — a scroll-linked vertical timeline. Dated entries run down a rail
* with sticky titles that pin while their content scrolls past. A progress beam
* draws down the spine bound to scroll position (bright above the read point,
* dim below), its leading edge carrying a faint white meniscus head. Node dots
* ignite as the beam reaches them — cold slate seating into an accent glow with
* an escapement pop — and stay lit.
*
* Semantic and accessible: renders an ordered list of entries with real
* headings, so the timeline is navigable and reads correctly to assistive tech.
* Under `prefers-reduced-motion` the beam is fully drawn, every node is lit, and
* content sits in normal flow with no scrub or reveal.
*
* SSR-safe: no window access at module scope, deterministic markup, node
* positions measured client-side. Perf: a single scroll-scrubbed transform
* drives the beam and node ignition (no per-node scroll listeners), one
* IntersectionObserver drives reveals, and both tear down on unmount.
*/
export function Stratum({
entries = DEFAULT_ENTRIES,
rail = "left",
accentColor = DEFAULT_ACCENT,
railColor = DEFAULT_RAIL,
beamWidth = 2,
marker = "dot",
markerSize = 14,
stickyOffset = 16,
reveal = "slide",
root,
headingLevel = 3,
className,
style,
...props
}: StratumProps) {
const reduced = useReducedMotion();
const innerRef = React.useRef<HTMLDivElement>(null);
const nodeRefs = React.useRef<(HTMLElement | null)[]>([]);
const contentRefs = React.useRef<(HTMLElement | null)[]>([]);
// Fractional (0..1) rail position of each node center, measured client-side.
const fractionsRef = React.useRef<number[]>([]);
const peakLitRef = React.useRef(0);
const peakRevealedRef = React.useRef<Set<number>>(new Set());
const [litCount, setLitCount] = React.useState(0);
const [revealed, setRevealed] = React.useState<Set<number>>(() => new Set());
const Heading = `h${headingLevel}` as "h2";
const laneWidth = Math.max(markerSize + 20, 40);
const laneCenter = laneWidth / 2;
const frames = revealFrames(reveal);
const { scrollYProgress } = useScroll({
container: root,
target: innerRef,
offset: ["start start", "end end"],
});
// Beam fill + meniscus head position, scrubbed straight off scroll progress.
const beamHeight = useTransform(scrollYProgress, [0, 1], ["0%", "100%"]);
const headTop = useTransform(scrollYProgress, [0, 1], ["0%", "100%"]);
const headOpacity = useTransform(scrollYProgress, [0, 0.008, 0.985, 1], [0, 1, 1, 0]);
const recomputeLit = React.useCallback(
(p: number) => {
const fracs = fractionsRef.current;
let count = 0;
for (let i = 0; i < fracs.length; i++) {
if (p + 0.001 >= fracs[i]) count++;
else break;
}
if (count > peakLitRef.current) {
peakLitRef.current = count;
setLitCount(count);
}
},
[]
);
// Measure node positions once mounted, and re-measure on resize. Nodes stay
// lit once reached, so we only ever grow litCount from the scroll subscription.
React.useEffect(() => {
const inner = innerRef.current;
if (!inner) return;
const measure = () => {
const total = inner.offsetHeight || 1;
fractionsRef.current = nodeRefs.current.map((el) =>
el ? (el.offsetTop + el.offsetHeight / 2) / total : 1
);
recomputeLit(scrollYProgress.get());
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(inner);
return () => ro.disconnect();
}, [entries, rail, recomputeLit, scrollYProgress]);
// Single scroll subscription drives node ignition. Reduced motion lights all.
useMotionValueEvent(scrollYProgress, "change", (p) => {
if (reduced) return;
recomputeLit(p);
});
React.useEffect(() => {
if (reduced) {
peakLitRef.current = entries.length;
setLitCount(entries.length);
}
}, [reduced, entries.length]);
// Single IntersectionObserver drives per-entry reveals.
React.useEffect(() => {
if (reduced || reveal === "none") {
setRevealed(new Set(entries.map((_, i) => i)));
return;
}
const els = contentRefs.current.filter(Boolean) as HTMLElement[];
if (els.length === 0) return;
const io = new IntersectionObserver(
(obsEntries) => {
let changed = false;
const next = new Set(peakRevealedRef.current);
for (const e of obsEntries) {
if (e.isIntersecting) {
const idx = Number((e.target as HTMLElement).dataset.stratumIndex);
if (!Number.isNaN(idx) && !next.has(idx)) {
next.add(idx);
changed = true;
}
}
}
if (changed) {
peakRevealedRef.current = next;
setRevealed(next);
}
},
{ root: root?.current ?? null, threshold: 0.15, rootMargin: "0px 0px -10% 0px" }
);
els.forEach((el) => io.observe(el));
return () => io.disconnect();
}, [reduced, reveal, entries, root]);
// Lane geometry: the absolute spine and the node column share a center line.
const laneStyle: React.CSSProperties =
rail === "left"
? { left: laneCenter, transform: "translateX(-50%)" }
: rail === "right"
? { right: laneCenter, transform: "translateX(50%)" }
: { left: "50%", transform: "translateX(-50%)" };
const gridTemplate =
rail === "left"
? `${laneWidth}px minmax(0,1fr)`
: rail === "right"
? `minmax(0,1fr) ${laneWidth}px`
: `minmax(0,1fr) ${laneWidth}px minmax(0,1fr)`;
const headSize = Math.max(beamWidth * 3, 7);
return (
<div
ref={innerRef}
data-crucible="stratum"
className={cn("relative", className)}
style={style}
{...props}
>
{/* Spine: dim track + bright scrubbed beam + meniscus head. Decorative. */}
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 z-0"
style={{ ...laneStyle, width: beamWidth }}
>
{/* Dim track (full height). */}
<div
className="absolute inset-y-0 left-0 w-full rounded-full"
style={{ background: railColor, opacity: 0.5 }}
/>
{/* Live beam: fades in from the top, brightest at the leading edge. */}
<motion.div
className="absolute left-0 top-0 w-full rounded-full"
style={{
height: reduced ? "100%" : beamHeight,
background: `linear-gradient(to bottom, ${accentColor}00, ${accentColor})`,
boxShadow: `0 0 ${beamWidth * 5}px ${accentColor}`,
transformOrigin: "top",
}}
/>
{/* Meniscus head bead at the beam's leading edge. */}
{!reduced && (
<motion.div
className="absolute left-1/2 rounded-full bg-white"
style={{
top: headTop,
width: headSize,
height: headSize,
x: "-50%",
y: "-50%",
opacity: headOpacity,
boxShadow: `0 0 ${headSize * 1.6}px #ffffff, 0 0 ${headSize * 0.7}px ${accentColor}`,
}}
/>
)}
</div>
<ol className="relative z-10 m-0 list-none p-0">
{entries.map((entry, i) => {
const key = entry.id ?? i;
const isLit = i < litCount;
const isRevealed = revealed.has(i);
// Center rail alternates content side; left/right are fixed.
const dotCol = rail === "center" ? 2 : rail === "left" ? 1 : 2;
let contentCol: number;
let contentAlign: "left" | "right";
if (rail === "left") {
contentCol = 2;
contentAlign = "left";
} else if (rail === "right") {
contentCol = 1;
contentAlign = "right";
} else {
const rightSide = i % 2 === 0;
contentCol = rightSide ? 3 : 1;
contentAlign = rightSide ? "left" : "right";
}
return (
<li
key={key}
className="grid"
style={{ gridTemplateColumns: gridTemplate, columnGap: 4 }}
>
{/* Node column — dot aligned to the entry's title line. */}
<div
className="flex justify-center"
style={{ gridColumn: dotCol, paddingTop: stickyOffset }}
>
<span
ref={(el) => {
nodeRefs.current[i] = el;
}}
className="flex h-max items-start"
>
<NodeMarker
lit={isLit}
reduced={reduced}
marker={marker}
size={markerSize}
accent={accentColor}
cold={railColor}
/>
</span>
</div>
{/* Content column. */}
<div
className="min-w-0 pb-16"
style={{ gridColumn: contentCol, textAlign: contentAlign }}
>
<div
className="mb-4"
style={
reduced
? { position: "static" }
: { position: "sticky", top: stickyOffset, zIndex: 5 }
}
>
{entry.date != null && (
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-neutral-500">
{entry.date}
</div>
)}
<Heading className="mt-1 text-lg font-semibold tracking-tight text-white">
{entry.title}
</Heading>
</div>
<motion.div
ref={(el: HTMLDivElement | null) => {
contentRefs.current[i] = el;
}}
data-stratum-index={i}
className="text-sm leading-relaxed text-neutral-400"
initial={false}
animate={isRevealed ? frames.shown : frames.hidden}
transition={
reduced ? { duration: 0 } : { duration: 0.5, ease: [0.25, 0.4, 0.25, 1] }
}
>
{entry.content}
</motion.div>
</div>
</li>
);
})}
</ol>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/stratumManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| entries | StratumEntry[] | The timeline entries, top to bottom. Beautiful with zero props — a sample changelog renders when omitted. | |
| rail | "left" | "center" | "right" | "left" | Which edge the spine sits on. center alternates content side to side. |
| accentColor | string | "#60a5fa" | Live-beam + ignited-node color (the one cool accent). |
| railColor | string | "#52525b" | Dim track + cold (un-ignited) node color. |
| beamWidth | number | 2 | Beam / track stroke width in px. |
| marker | "dot" | "ring" | "diamond" | "square" | "dot" | Node marker shape. |
| markerSize | number | 14 | Node marker size in px. |
| stickyOffset | number | 16 | Sticky title offset from the top of the scroll container, in px. |
| reveal | "slide" | "fade" | "none" | "slide" | Per-entry content reveal as it enters view. |
| root | React.RefObject<HTMLElement | null> | the viewport | Scroll container to track instead of the window. Pass a ref to an overflow-y-auto ancestor so the beam scrubs against THAT container (e.g. inside a boxed panel). |
| headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | Heading level rendered for each entry title (semantic outline). |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"div">, "children"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.