DesignPass.dev

ScrollCurl

// preview

Ancient recipes
Star charts
Forgotten maps
Royal decrees
Alchemy notes
Sea shanties
Battle plans
Trade ledgers
Love letters
Prophecies
Family trees
Spell fragments
Herbal remedies
Constellation myths
Border treaties
Guild secrets
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.

// updates

Know when new components drop

A short email when something new lands in the library. No noise, unsubscribe anytime.