ScrollCurl
// preview
Fade zone (px)
Max tilt (deg)
Min opacity
Perspective (px)
// source
TSJS
TailwindCSS
/*!
* ScrollCurl, a DesignPass.dev component by Ernest Liu
* Docs & live playground: https://designpass.dev/components/scroll-curl
* MIT licensed, keep this notice in copies and adaptations.
*/
"use client";
import React, {
useCallback,
useEffect,
useRef,
type HTMLAttributes,
type ReactNode,
} from "react";
export interface ScrollCurlProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
/** Height (px) of the roll-away zone at the bottom edge. */
fadeZone?: number;
/** Max tilt (deg) an item reaches at the very bottom of the zone. */
maxAngle?: number;
/** Opacity an item fades to at the very bottom of the zone. */
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 clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
/**
* Scrollable container where content rolls away at the bottom edge, like
* parchment curling back into a scroll. Items entering the fade zone tilt
* back in 3D, sink, and fade.
*/
export default function ScrollCurl({
children,
fadeZone = 96,
maxAngle = 55,
minOpacity = 0,
itemSelector,
perspective = 600,
className = "",
style,
...props
}: ScrollCurlProps) {
const containerRef = useRef<HTMLDivElement>(null);
const frameRef = useRef(0);
const reducedMotionRef = useRef(false);
const paint = useCallback(() => {
const container = containerRef.current;
if (!container || reducedMotionRef.current) return;
const items: Iterable<HTMLElement> = itemSelector
? container.querySelectorAll<HTMLElement>(itemSelector)
: (Array.from(container.children) as HTMLElement[]);
const bounds = container.getBoundingClientRect();
const zoneTop = bounds.bottom - fadeZone;
for (const item of items) {
const rect = item.getBoundingClientRect();
// How deep the item's bottom edge is into the roll zone (0..1).
const depth = clamp((rect.bottom - zoneTop) / fadeZone, 0, 1);
if (depth <= 0) {
item.style.transform = "";
item.style.opacity = "";
continue;
}
const angle = depth * maxAngle;
// Cylinder model: the fade zone is paper wrapping onto a roll of
// radius r (arc of maxAngle spans the zone). Items keep their arc
// spacing but recede in Z and lift in Y as they wrap, so they bend
// away from the viewer instead of just tipping in place.
const maxRad = (maxAngle * Math.PI) / 180 || 0.0001;
const radius = fadeZone / maxRad;
const rad = depth * maxRad;
const arc = depth * fadeZone;
const lift = arc - radius * Math.sin(rad);
const zBack = radius * (1 - Math.cos(rad));
item.style.transformOrigin = "center bottom";
item.style.transform = `translateY(${-lift}px) translateZ(${-zBack}px) rotateX(-${angle}deg)`;
item.style.opacity = String(1 - depth * (1 - minOpacity));
}
}, [fadeZone, maxAngle, minOpacity, itemSelector]);
const schedule = useCallback(() => {
cancelAnimationFrame(frameRef.current);
frameRef.current = requestAnimationFrame(paint);
}, [paint]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
reducedMotionRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reducedMotionRef.current) return;
schedule();
container.addEventListener("scroll", schedule, { passive: true });
const resizeObserver = new ResizeObserver(schedule);
resizeObserver.observe(container);
// Re-paint when items are added/removed (filtering, async content).
const mutationObserver = new MutationObserver(schedule);
mutationObserver.observe(container, { childList: true, subtree: true });
return () => {
container.removeEventListener("scroll", schedule);
resizeObserver.disconnect();
mutationObserver.disconnect();
cancelAnimationFrame(frameRef.current);
};
}, [schedule]);
return (
<div
ref={containerRef}
className={`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>
);
}
// install
Install the ScrollCurl component from DesignPass 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.Need the license details? Read the component license.