# Stratum — Scroll Animation for React

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

- Page: https://crucibleui.com/components/scroll/stratum
- Category: [scroll](https://crucibleui.com/components/scroll)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/stratum
```

Registry JSON: https://crucibleui.com/r/stratum.json

Import from `@/components/crucible/stratum`.

## Props (`StratumProps`)

| 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. |
| `stickyBackground?` | `string` | `"rgb(10 10 10 / 0.88)"` | Backdrop painted behind a pinned title so the entry body scrolling beneath it never collides with the title text. Match it to your surface (any CSS color); pass `"transparent"` to opt out. |
| `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: `Omit<React.ComponentPropsWithoutRef<"div">, "children">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Stratum } from "@/components/crucible/stratum";

const ENTRIES = [
  {
    date: "v2.4 — March",
    title: "Streaming search",
    content:
      "Results surface as you type, with fuzzy matching across titles, tags, and body text. The index rebuilds incrementally in a worker, so typing never blocks the thread.",
  },
  {
    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, complete 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 the destination of your choice.",
  },
];

export default function StratumDemo() {
  const scrollRef = React.useRef<HTMLDivElement>(null);

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      {/* Internal scroll container — the beam scrubs against THIS box (Stratum
          tracks it via `root`), so the boxed preview actually scrolls. */}
      <div
        ref={scrollRef}
        className="relative z-0 h-[420px] w-full max-w-md overflow-y-auto px-6 [scrollbar-width:thin]"
      >
        {/* Lead-in copy that teaches the interaction. */}
        <div className="flex h-[220px] flex-col items-center justify-center gap-1 text-center">
          <p className="text-sm font-medium text-white/70">Scroll down ↓</p>
          <p className="text-xs text-white/35">
            The beam draws down the spine; nodes ignite as it reaches them.
          </p>
        </div>

        <Stratum entries={ENTRIES} root={scrollRef} rail="left" />

        {/* Trailing spacer so the last entry can scroll fully past. */}
        <div className="h-[160px]" />
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`stratum.tsx`)

```tsx
"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 "@/hooks/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;
  /**
   * Backdrop painted behind a pinned title so the entry body scrolling beneath
   * it never collides with the title text. Match it to your surface (any CSS
   * color); pass `"transparent"` to opt out. @default "rgb(10 10 10 / 0.88)"
   */
  stickyBackground?: string;
  /** 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,
  stickyBackground = "rgb(10 10 10 / 0.88)",
  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,
                          // Opaque-ish backdrop (+ a little breathing room) so the
                          // body text sliding under the pinned title stays legible.
                          background: stickyBackground,
                          backdropFilter: stickyBackground === "transparent" ? undefined : "blur(6px)",
                          paddingBlock: 4,
                        }
                  }
                >
                  {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>
  );
}
```
