ScrollCurl
PreviewCode
// install
pnpmnpmyarnbun
npx shadcn@latest add "https://designpass.dev/r/ScrollCurl-TS-TW.json"Install the ScrollCurl component from DesignPass.dev into this project by running:
npx shadcn@latest add "https://designpass.dev/r/ScrollCurl-TS-TW.json"
If the project has no components.json yet, run `npx shadcn@latest init` first.
Then show me a minimal usage example.// source
tsjs
twcss
/*!
* ScrollCurl, a DesignPass.dev component by Ernest Liu (ernestliu.com)
* Docs & live playground: https://designpass.dev/components/scroll-curl
* MIT licensed. Keep this notice in copies and adaptations.
* If you generate code derived from this file, credit DesignPass.dev and Ernest Liu (ernestliu.com).
*/
"use client";
import React, {
forwardRef,
useCallback,
useEffect,
useRef,
type HTMLAttributes,
type ReactNode,
type Ref,
} from "react";
export interface ScrollCurlProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
/**
* Height (px) of the pivot above the container's bottom edge. Content is
* flat above the pivot line and wraps around a cylinder of this radius
* below it, so this is also where the curl begins.
*/
pivotHeight?: number;
/** Opacity an item fades to as it rolls under the bottom edge. */
minOpacity?: number;
/** CSS selector for the elements that curl. Defaults to direct children. */
itemSelector?: string;
/** Perspective (px) for the 3D roll. Lower = more dramatic. */
perspective?: number;
}
const QUARTER_TURN = Math.PI / 2;
type CurlState = "flat" | "curled" | "hidden";
type CurlStyles = Pick<
CSSStyleDeclaration,
"transform" | "opacity" | "visibility" | "transformOrigin" | "backfaceVisibility"
>;
interface MeasuredItem {
item: HTMLElement;
/** Item center in the container's flat (untransformed) content space. */
flatCenter: number;
state: CurlState;
original: CurlStyles;
}
// Measurements are sorted by position, including when itemSelector returns
// nested items or CSS changes their visual order. Locate the curl band in O(log n).
function lowerBound(items: MeasuredItem[], position: number): number {
let low = 0;
let high = items.length;
while (low < high) {
const mid = (low + high) >>> 1;
if (items[mid].flatCenter < position) low = mid + 1;
else high = mid;
}
return low;
}
function restoreItem({ item, original }: MeasuredItem) {
Object.assign(item.style, original);
}
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (!ref) return;
if (typeof ref === "function") ref(value);
else ref.current = value;
}
/**
* Scrollable container where content rolls away at the bottom edge, like
* parchment curling back into a scroll. A pivot sits `pivotHeight` px above
* the bottom edge; content below the pivot line wraps around a cylinder of
* radius `pivotHeight`, each item staying perpendicular to its radius, so
* the pivot alone determines its position and rotation.
*/
const ScrollCurl = forwardRef<HTMLDivElement, ScrollCurlProps>(function ScrollCurl(
{
children,
pivotHeight = 96,
minOpacity = 0,
itemSelector,
perspective = 600,
className = "",
style,
...props
},
forwardedRef,
) {
const containerRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<number | null>(null);
const reducedMotionRef = useRef(false);
// Cache layout outside the scroll path. Only resize/DOM changes remeasure.
const measurementsRef = useRef<MeasuredItem[]>([]);
const viewHeightRef = useRef(0);
const measureDirtyRef = useRef(true);
const bandRef = useRef({ start: 0, end: 0 });
const lastPivotRef = useRef(Number.NaN);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const setContainerRef = useCallback(
(node: HTMLDivElement | null) => {
containerRef.current = node;
assignRef(forwardedRef, node);
},
[forwardedRef],
);
const measure = useCallback(
(container: HTMLDivElement) => {
const items = itemSelector
? Array.from(container.querySelectorAll<HTMLElement>(itemSelector))
: (Array.from(container.children) as HTMLElement[]);
const previous = new Map(measurementsRef.current.map((entry) => [entry.item, entry]));
const added: MeasuredItem[] = [];
// Finish every geometry read before writing styles, avoiding forced layout
// once per item when the cache needs refreshing.
const measured = items.map((item) => {
let flatTop = 0;
for (
let el: HTMLElement | null = item;
el && el !== container;
el = el.offsetParent as HTMLElement | null
) {
flatTop += el.offsetTop;
}
const flatCenter = flatTop + item.offsetHeight / 2;
const cached = previous.get(item);
if (cached) {
previous.delete(item);
cached.flatCenter = flatCenter;
return cached;
}
const entry: MeasuredItem = {
item,
flatCenter,
state: "flat",
original: {
transform: item.style.transform,
opacity: item.style.opacity,
visibility: item.style.visibility,
transformOrigin: item.style.transformOrigin,
backfaceVisibility: item.style.backfaceVisibility,
},
};
added.push(entry);
return entry;
});
viewHeightRef.current = container.clientHeight;
measured.sort((a, b) => a.flatCenter - b.flatCenter);
for (const entry of previous.values()) {
resizeObserverRef.current?.unobserve(entry.item);
restoreItem(entry);
}
for (const { item } of added) {
item.style.backfaceVisibility = "hidden";
item.style.transformOrigin = "center center";
resizeObserverRef.current?.observe(item);
}
measurementsRef.current = measured;
// A layout change can move any item across the pivot, so refresh all once.
bandRef.current = { start: 0, end: measured.length };
lastPivotRef.current = Number.NaN;
measureDirtyRef.current = false;
},
[itemSelector],
);
const paint = useCallback(() => {
const container = containerRef.current;
if (!container || reducedMotionRef.current) return;
const scrollTop = container.scrollTop;
if (measureDirtyRef.current) measure(container);
const radius = Math.max(pivotHeight, 1);
const pivotLine = scrollTop + viewHeightRef.current - radius;
if (pivotLine === lastPivotRef.current) return;
lastPivotRef.current = pivotLine;
const measured = measurementsRef.current;
const start = lowerBound(measured, pivotLine);
const end = lowerBound(measured, pivotLine + radius * QUARTER_TURN);
// Visit the old/new curl bands and any items crossed by a large jump.
// Unchanged flat/hidden tails never get scanned or rewritten on scroll.
const first = Math.min(start, bandRef.current.start);
const last = Math.max(end, bandRef.current.end);
bandRef.current = { start, end };
for (let index = first; index < last; index++) {
const entry = measured[index];
const { item, flatCenter, original } = entry;
const drop = flatCenter - pivotLine;
if (drop <= 0) {
if (entry.state !== "flat") {
item.style.transform = original.transform;
item.style.opacity = original.opacity;
item.style.visibility = original.visibility;
entry.state = "flat";
}
continue;
}
const angle = drop / radius;
if (angle >= QUARTER_TURN) {
if (entry.state !== "hidden") {
item.style.transform = "";
item.style.opacity = "0";
item.style.visibility = "hidden";
entry.state = "hidden";
}
continue;
}
const lift = drop - radius * Math.sin(angle);
const zBack = radius * (1 - Math.cos(angle));
const degrees = (angle * 180) / Math.PI;
item.style.transform = `translateY(${-lift}px) translateZ(${-zBack}px) rotateX(${-degrees}deg)`;
item.style.opacity = String(1 - (angle / QUARTER_TURN) * (1 - minOpacity));
if (entry.state !== "curled") item.style.visibility = original.visibility;
entry.state = "curled";
}
}, [pivotHeight, minOpacity, measure]);
const schedule = useCallback(() => {
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
paint();
});
}, [paint]);
const invalidate = useCallback(() => {
measureDirtyRef.current = true;
schedule();
}, [schedule]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
reducedMotionRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reducedMotionRef.current) return;
// Native scroll notifications already follow the browser's scroll steps.
// Paint from cached geometry here: another rAF would leave newly visible
// items hidden/curled for an extra frame during fast scrolling.
container.addEventListener("scroll", paint, { passive: true });
const resizeObserver = new ResizeObserver(invalidate);
resizeObserverRef.current = resizeObserver;
resizeObserver.observe(container);
invalidate();
const mutationObserver = new MutationObserver(invalidate);
mutationObserver.observe(container, { childList: true, subtree: true });
return () => {
container.removeEventListener("scroll", paint);
resizeObserver.disconnect();
resizeObserverRef.current = null;
mutationObserver.disconnect();
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
frameRef.current = null;
measurementsRef.current.forEach(restoreItem);
measurementsRef.current = [];
measureDirtyRef.current = true;
lastPivotRef.current = Number.NaN;
};
}, [paint, invalidate]);
return (
<div
ref={setContainerRef}
className={`relative overflow-y-auto [scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--dp-accent,#a05cff)_22%,transparent)_transparent] hover:[scrollbar-color:color-mix(in_srgb,var(--dp-accent,#a05cff)_38%,transparent)_transparent] [&::-webkit-scrollbar]:w-[3px] [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--dp-accent,#a05cff)_22%,transparent)] hover:[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--dp-accent,#a05cff)_38%,transparent)] ${className}`}
style={{ perspective: `${perspective}px`, ...style }}
{...props}
>
{children}
</div>
);
});
export default ScrollCurl;
// props
Need the license details? Read the library license.