# Shingle — Scroll Animation for React

> A scroll-pinned card stack: full-width cards slide up and seat at a fixed point with a 2px settle-and-lock, then pile into a receding deck as later cards pin over them — depth carried by cool desaturation, a hair of scale and a soft downward shadow, never color. Reverses cleanly and falls back to a plain legible list under reduced motion.

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

## Install

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

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

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

## Props (`ShingleProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children?` | `React.ReactNode` | — | The cards. Each top-level child becomes one full-width card that pins and stacks. Falls back to a neutral demo deck when omitted. |
| `scrollContainer?` | `React.RefObject<HTMLElement \| null>` | — | Scroll container to track instead of the window. Pass a ref to an internal `overflow-y-auto` element (this component's own demo does) so the pin position is measured against the scrollport, not the page. |
| `itemDistance?` | `number` | `44` | Vertical gap in px between cards in normal flow — also the scroll distance that advances the deck by one card. |
| `stackPosition?` | `number \| string` | `"18%"` | Pin anchor: how far below the top of the scrollport a card seats. Number = px, string = any CSS length/percent (e.g. `"18%"`). |
| `itemScale?` | `number` | `0.05` | Scale removed per card of depth as the deck recedes. |
| `itemStackDistance?` | `number` | `12` | Upward peek in px per card of depth, so receding cards show above the front. |
| `maxDepth?` | `number` | `4` | How many cards deep the recession clamps (the deepest visible layer). |
| `blurAmount?` | `number` | `1.5` | Blur radius in px added per card of depth. |
| `rotationAmount?` | `number` | `0` | Rotation in degrees per card of depth (alternating sign for an overlapped-tile feel). |
| `settle?` | `number` | `2` | The signature detent: px of overshoot as a card seats at the pin point (tiny settle-and-lock). `0` disables it. |
| `onStackComplete?` | `() => void` | — | Fired once when the final card seats at the pin. |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"div">, "children">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Shingle } from "@/components/crucible/shingle";

const CARDS = [
  {
    kicker: "01",
    title: "Slide up",
    body: "Each card rises with the scroll and locks at a fixed point near the top.",
  },
  {
    kicker: "02",
    title: "Pin",
    body: "Once seated it sticks — the next card climbs up and covers it.",
  },
  {
    kicker: "03",
    title: "Recede",
    body: "The card beneath scales down, lifts a hair and cools toward slate.",
  },
  {
    kicker: "04",
    title: "Settle",
    body: "Watch the seat: a 2px overshoot, then it locks into the deck.",
  },
  {
    kicker: "05",
    title: "Reverse",
    body: "Scroll back up and the whole deck unstacks cleanly, card by card.",
  },
];

export default function ShingleDemo() {
  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">
      <div ref={scrollRef} className="relative z-10 h-[420px] w-full overflow-y-auto">
        {/* Intro so a card already peeks above the fold at rest. */}
        <div className="flex h-24 items-center justify-center px-6 text-center">
          <p className="text-sm font-medium tracking-wide text-neutral-400">
            Scroll down — cards pin and pile into a deck.
          </p>
        </div>

        <Shingle scrollContainer={scrollRef} className="px-6">
          {CARDS.map((c) => (
            <div key={c.kicker} className="space-y-2">
              <p className="text-xs font-medium tracking-widest text-white/40 tabular-nums uppercase">
                {c.kicker}
              </p>
              <p className="text-2xl font-semibold tracking-tight">{c.title}</p>
              <p className="max-w-sm text-sm text-white/60">{c.body}</p>
            </div>
          ))}
        </Shingle>

        {/* Runway so the last card can reach its pin. */}
        <div className="flex h-64 items-center justify-center px-6 text-center">
          <p className="max-w-xs text-sm text-neutral-500">
            Scroll back up to unstack the deck.
          </p>
        </div>
      </div>
    </div>
  );
}
```

## Accessibility & performance

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

## Source (`shingle.tsx`)

```tsx
"use client";

import {
  animate,
  motion,
  useMotionValue,
  useMotionValueEvent,
  useScroll,
  useTransform,
  type MotionValue,
} from "motion/react";
import * as React from "react";

import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { cn } from "@/lib/utils";

export interface ShingleProps
  extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
  /**
   * The cards. Each top-level child becomes one full-width card that pins and
   * stacks. Falls back to a neutral demo deck when omitted.
   */
  children?: React.ReactNode;
  /**
   * Scroll container to track instead of the window. Pass a ref to an
   * internal `overflow-y-auto` element (this component's own demo does) so the
   * pin position is measured against the scrollport, not the page.
   */
  scrollContainer?: React.RefObject<HTMLElement | null>;
  /**
   * Vertical gap in px between cards in normal flow — also the scroll distance
   * that advances the deck by one card. @default 44
   */
  itemDistance?: number;
  /**
   * Pin anchor: how far below the top of the scrollport a card seats. Number =
   * px, string = any CSS length/percent (e.g. `"18%"`). @default "18%"
   */
  stackPosition?: number | string;
  /** Scale removed per card of depth as the deck recedes. @default 0.05 */
  itemScale?: number;
  /** Upward peek in px per card of depth, so receding cards show above the front. @default 12 */
  itemStackDistance?: number;
  /** How many cards deep the recession clamps (the deepest visible layer). @default 4 */
  maxDepth?: number;
  /** Blur radius in px added per card of depth. @default 1.5 */
  blurAmount?: number;
  /** Rotation in degrees per card of depth (alternating sign for an overlapped-tile feel). @default 0 */
  rotationAmount?: number;
  /**
   * The signature detent: px of overshoot as a card seats at the pin point
   * (tiny settle-and-lock). `0` disables it. @default 2
   */
  settle?: number;
  /** Fired once when the final card seats at the pin. */
  onStackComplete?: () => void;
}

const DEFAULTS = {
  itemDistance: 44,
  stackPosition: "18%" as number | string,
  itemScale: 0.05,
  itemStackDistance: 12,
  maxDepth: 4,
  blurAmount: 1.5,
  rotationAmount: 0,
  settle: 2,
};

const CARD_CLASS =
  "relative overflow-hidden rounded-2xl border border-white/10 " +
  "bg-[linear-gradient(180deg,#15161a_0%,#101114_100%)] p-6 text-white sm:p-8 " +
  "shadow-[0_28px_64px_-32px_rgba(0,0,0,0.9),inset_0_1px_0_rgba(255,255,255,0.05)]";

function clamp(v: number, min: number, max: number) {
  return v < min ? min : v > max ? max : v;
}

function resolveStack(sp: number | string, containerHeight: number): number {
  if (typeof sp === "number") return sp;
  const s = sp.trim();
  if (s.endsWith("%")) return (containerHeight * (parseFloat(s) || 0)) / 100;
  return parseFloat(s) || 0;
}

interface CardCommon {
  index: number;
  total: number;
  scrollY: MotionValue<number>;
  scrollContainer?: React.RefObject<HTMLElement | null>;
  itemDistance: number;
  stackPosition: number | string;
  itemScale: number;
  itemStackDistance: number;
  maxDepth: number;
  blurAmount: number;
  rotationAmount: number;
  settle: number;
  onSeat: (index: number) => void;
  children: React.ReactNode;
}

function ShingleCard({
  index,
  total,
  scrollY,
  scrollContainer,
  itemDistance,
  stackPosition,
  itemScale,
  itemStackDistance,
  maxDepth,
  blurAmount,
  rotationAmount,
  settle,
  onSeat,
  children,
}: CardCommon) {
  const anchorRef = React.useRef<HTMLDivElement>(null);
  const cardRef = React.useRef<HTMLDivElement>(null);
  // Measured layout: scroll position at which this card seats, and the scroll
  // span that advances the deck by one card. Held in a ref (non-reactive);
  // `remeasure` bumps to force the transforms to recompute after a relayout.
  const layout = React.useRef({ pinStart: 0, spacing: 1 });
  const remeasure = useMotionValue(0);
  // Tiny settle-and-lock overshoot, animated imperatively when the card seats.
  const settleY = useMotionValue(0);
  const seated = React.useRef(false);
  const dir = index % 2 === 0 ? 1 : -1;

  React.useEffect(() => {
    const measure = () => {
      const anchor = anchorRef.current;
      const card = cardRef.current;
      if (!anchor || !card) return;
      const cont = scrollContainer?.current ?? null;
      const containerTop = cont ? cont.getBoundingClientRect().top : 0;
      const containerH = cont
        ? cont.clientHeight
        : typeof window !== "undefined"
          ? window.innerHeight
          : 0;
      const stackPx = resolveStack(stackPosition, containerH);
      // Anchor is a zero-height static marker: its viewport top plus the current
      // scroll offset yields a scroll-invariant flow position.
      const anchorTop = anchor.getBoundingClientRect().top;
      layout.current.pinStart = scrollY.get() + (anchorTop - containerTop) - stackPx;
      layout.current.spacing = Math.max(card.offsetHeight + itemDistance, 1);
      remeasure.set(remeasure.get() + 1);
    };

    measure();
    const raf = requestAnimationFrame(measure);
    const ro = new ResizeObserver(measure);
    if (cardRef.current) ro.observe(cardRef.current);
    const cont = scrollContainer?.current;
    if (cont) ro.observe(cont);
    window.addEventListener("resize", measure);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      window.removeEventListener("resize", measure);
    };
  }, [scrollContainer, stackPosition, itemDistance, scrollY, remeasure]);

  // Depth: 0 = the front card (or still entering), rising by one per card that
  // pins on top, clamped to the deepest visible layer — and to the number of
  // cards that actually exist above this one, so the last card (nothing pins
  // over it) stays crisp at the front instead of receding into the runway.
  const depthCap = Math.max(0, Math.min(maxDepth, total - 1 - index));
  const depth = useTransform(() => {
    remeasure.get();
    const { pinStart, spacing } = layout.current;
    return clamp((scrollY.get() - pinStart) / spacing, 0, depthCap);
  });

  // Fire the settle exactly as the card crosses its pin, going down.
  useMotionValueEvent(scrollY, "change", (v) => {
    const pin = layout.current.pinStart;
    if (pin <= 8) return; // cards that start already pinned don't need a detent.
    if (!seated.current && v >= pin) {
      seated.current = true;
      if (settle > 0) {
        settleY.set(settle);
        animate(settleY, 0, { type: "spring", stiffness: 620, damping: 14, mass: 0.5 });
      }
      onSeat(index);
    } else if (seated.current && v < pin - 6) {
      seated.current = false;
    }
  });

  const y = useTransform([depth, settleY], ([d, s]: number[]) => -d * itemStackDistance + s);
  const scale = useTransform(depth, (d) => 1 - d * itemScale);
  const rotate = useTransform(depth, (d) => d * rotationAmount * dir);
  const filter = useTransform(depth, (d) => {
    const t = maxDepth > 0 ? Math.min(d, maxDepth) / maxDepth : 0;
    return `blur(${(d * blurAmount).toFixed(2)}px) saturate(${(1 - t * 0.5).toFixed(3)}) brightness(${(1 - t * 0.12).toFixed(3)})`;
  });
  // Depth veil: a slate-cool wash with a downward inner-shadow — depth read as
  // desaturation + shadow, never a color shift.
  const veilOpacity = useTransform(depth, (d) => (maxDepth > 0 ? Math.min(d / maxDepth, 1) * 0.5 : 0));

  return (
    <>
      <div ref={anchorRef} aria-hidden className="pointer-events-none h-0" />
      <motion.div
        ref={cardRef}
        className={cn("sticky", CARD_CLASS)}
        style={{
          top: stackPosition,
          y,
          scale,
          rotate,
          filter,
          zIndex: index,
          marginBottom: itemDistance,
          transformOrigin: "center top",
          willChange: "transform, filter",
        }}
      >
        {children}
        <motion.span
          aria-hidden
          className="pointer-events-none absolute inset-0 rounded-[inherit]"
          style={{
            opacity: veilOpacity,
            background: "linear-gradient(180deg, rgba(30,41,59,0.35) 0%, rgba(15,23,42,0.55) 100%)",
            boxShadow: "inset 0 -26px 44px -20px rgba(0,0,0,0.75)",
          }}
        />
      </motion.div>
    </>
  );
}

function StaticCard({
  itemDistance,
  children,
}: {
  itemDistance: number;
  children: React.ReactNode;
}) {
  return (
    <div className={CARD_CLASS} style={{ marginBottom: itemDistance }}>
      {children}
    </div>
  );
}

function defaultDeck(): React.ReactNode[] {
  const cards = [
    ["Pin", "Each card slides up and locks at a fixed point in the scrollport."],
    ["Stack", "The card beneath scales down and lifts a hair, peeking behind the front."],
    ["Recede", "Depth reads as desaturation and a soft downward shadow — not a color."],
    ["Seat", "Every card settles with a 2px overshoot before it locks into the deck."],
  ];
  // One card per entry — the fallback is a real deck, not a single card.
  return cards.map(([title, body], i) => (
    <div key={title} className="space-y-2">
      <p className="text-xs font-medium tracking-widest text-white/40 tabular-nums uppercase">
        {String(i + 1).padStart(2, "0")}
      </p>
      <p className="text-xl font-semibold tracking-tight">{title}</p>
      <p className="max-w-sm text-sm text-white/60">{body}</p>
    </div>
  ));
}

/**
 * Shingle — a scroll-pinned card stack. A vertical run of full-width cards pins
 * at a fixed point in the scrollport and piles into a receding deck: each
 * incoming card slides up and seats with a tiny 2px settle-and-lock, while the
 * cards beneath scale down, lift a hair, desaturate and blur — depth carried by
 * shadow and cool desaturation rather than color. Scrolling back reverses it
 * cleanly. Drive it off an internal `overflow-y-auto` container via
 * `scrollContainer`. Under `prefers-reduced-motion` it renders a plain,
 * fully-legible vertical list with no pin, scale or blur.
 */
export function Shingle({
  children,
  scrollContainer,
  itemDistance = DEFAULTS.itemDistance,
  stackPosition = DEFAULTS.stackPosition,
  itemScale = DEFAULTS.itemScale,
  itemStackDistance = DEFAULTS.itemStackDistance,
  maxDepth = DEFAULTS.maxDepth,
  blurAmount = DEFAULTS.blurAmount,
  rotationAmount = DEFAULTS.rotationAmount,
  settle = DEFAULTS.settle,
  onStackComplete,
  className,
  ...props
}: ShingleProps) {
  const reducedMotion = useReducedMotion();
  const { scrollY } = useScroll({ container: scrollContainer });

  const items = React.useMemo(() => {
    const arr = React.Children.toArray(children);
    return arr.length > 0 ? arr : null;
  }, [children]);

  const content = React.useMemo(() => items ?? defaultDeck(), [items]);
  const completed = React.useRef(false);
  const total = content.length;
  const handleSeat = React.useCallback(
    (index: number) => {
      if (index === total - 1 && !completed.current) {
        completed.current = true;
        onStackComplete?.();
      }
    },
    [total, onStackComplete]
  );

  return (
    <div data-crucible="shingle" className={cn("relative w-full", className)} {...props}>
      {reducedMotion
        ? content.map((child, i) => (
            <StaticCard key={i} itemDistance={itemDistance}>
              {child}
            </StaticCard>
          ))
        : content.map((child, i) => (
            <ShingleCard
              key={i}
              index={i}
              total={total}
              scrollY={scrollY}
              scrollContainer={scrollContainer}
              itemDistance={itemDistance}
              stackPosition={stackPosition}
              itemScale={itemScale}
              itemStackDistance={itemStackDistance}
              maxDepth={maxDepth}
              blurAmount={blurAmount}
              rotationAmount={rotationAmount}
              settle={settle}
              onSeat={handleSeat}
            >
              {child}
            </ShingleCard>
          ))}
    </div>
  );
}
```
