Shingle
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.
"use client";
import {
animate,
motion,
useMotionValue,
useMotionValueEvent,
useScroll,
useTransform,
type MotionValue,
} from "motion/react";
import * as React from "react";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/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;
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,
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.
const depth = useTransform(() => {
remeasure.get();
const { pinStart, spacing } = layout.current;
return clamp((scrollY.get() - pinStart) / spacing, 0, maxDepth);
});
// 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() {
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."],
];
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 completed = React.useRef(false);
const total = items ? items.length : 4;
const handleSeat = React.useCallback(
(index: number) => {
if (index === total - 1 && !completed.current) {
completed.current = true;
onStackComplete?.();
}
},
[total, onStackComplete]
);
const content = items ?? [<DefaultDeck key="__default" />];
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}
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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/shingleManual — install dependencies, then copy the source
npm install motionProps
| 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 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.