Skip to content

Depth Rail

A lightweight CSS 3D coverflow with parallax and springy throws.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/depth-rail.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/depth-rail/depth-rail.tsx
    "use client";
    
    import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type PointerEvent, type ReactNode } from "react";
    import { AnimatePresence, animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, type AnimationPlaybackControls, type MotionValue } from "motion/react";
    import { duration, ease } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    
    export interface DepthRailItem {
      id: string | number;
      title: string;
      subtitle?: string;
      /** Small extra line, e.g. a location or a year. */
      meta?: string;
      /** Image URL. */
      image: string;
      /** CSS object-position for the image, e.g. "50% 30%". */
      imagePosition?: string;
      /** Alt text; defaults to the title. */
      alt?: string;
    }
    
    export interface DepthRailProps<T extends DepthRailItem> {
      /** Cards on the rail; 6–12 work best. */
      items: T[];
      /** Accessible name of the carousel. */
      label: string;
      /** Front card (controlled). Pair with onIndexChange. */
      index?: number;
      /** Fires when the front card changes. */
      onIndexChange?: (index: number) => void;
      /** Front card on first render when uncontrolled. @default 0 */
      defaultIndex?: number;
      /** Custom caption under the rail, e.g. stats and a save button. Defaults to title, subtitle and meta. */
      renderCaption?: (item: T, index: number) => ReactNode;
      /** Classes for the root. */
      className?: string;
    }
    
    const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
    const SPREAD = 62;
    
    /** Offset from the front card → horizontal position in % of a card width (outer cards pack tighter). */
    const spread = (offset: number) => {
      const t = Math.abs(offset);
      return Math.sign(offset) * (t < 1 ? t * SPREAD : SPREAD + (t - 1) * 24);
    };
    
    function RailCard<T extends DepthRailItem>({
      item,
      index,
      total,
      front,
      position,
      reduced,
      onSelect,
    }: {
      item: T;
      index: number;
      total: number;
      front: number;
      position: MotionValue<number>;
      reduced: MotionValue<number>;
      onSelect: (index: number) => void;
    }) {
      // Every visual is a function of the rail position, so nothing re-renders while it moves.
      const x = useTransform(position, (p) => `${spread(index - p)}%`);
      const rotateY = useTransform([position, reduced], ([p, r]: number[]) => (r ? 0 : -Math.sign(index - p) * Math.min(Math.abs(index - p), 1) * 38));
      const z = useTransform(position, (p) => -Math.min(Math.abs(index - p), 1) * 120 - Math.max(0, Math.abs(index - p) - 1) * 50);
      const scale = useTransform(position, (p) => 1 - Math.min(Math.abs(index - p), 3) * 0.06);
      const shade = useTransform(position, (p) => Math.min(Math.abs(index - p), 3) * 0.16);
      const zIndex = useTransform(position, (p) => 100 - Math.round(Math.abs(index - p) * 10));
      const visibility = useTransform(position, (p) => (Math.abs(index - p) > 3.6 ? "hidden" : "visible"));
      const drift = useTransform([position, reduced], ([p, r]: number[]) => (r ? "0%" : `${clamp(index - p, -1.5, 1.5) * -5}%`));
      const isFront = index === front;
      return (
        <motion.div
          role="group"
          aria-roledescription="slide"
          aria-label={`${index + 1} of ${total}: ${item.title}`}
          aria-hidden={!isFront}
          onClick={() => onSelect(index)}
          style={{ x, rotateY, z, scale, zIndex, visibility }}
          className={cn("absolute top-4 left-1/2 -ml-[calc(var(--card)/2)] aspect-[4/5] w-(--card) [transform-style:preserve-3d]", !isFront && "cursor-pointer")}
        >
          {/* Grounded shadow */}
          <div aria-hidden="true" className="absolute inset-x-[8%] -bottom-3 h-6 rounded-[50%] bg-black/30 blur-lg" />
          <div className="relative size-full overflow-hidden rounded-2xl bg-surface-2 shadow-[0_1px_2px_rgb(0_0_0/0.08),0_12px_32px_-12px_rgb(0_0_0/0.35)]">
            <motion.img
              src={item.image}
              alt={item.alt ?? item.title}
              draggable={false}
              style={{ x: drift, objectPosition: item.imagePosition }}
              className="absolute inset-y-0 -left-[7.5%] h-full w-[115%] max-w-none object-cover"
            />
            <motion.div aria-hidden="true" className="absolute inset-0 bg-black" style={{ opacity: shade }} />
          </div>
          {/* Quiet reflection: a flipped copy fading out under a gradient mask. */}
          <div aria-hidden="true" className="absolute inset-x-0 top-full mt-2 h-[42%] overflow-hidden rounded-2xl opacity-25 [mask-image:linear-gradient(to_bottom,black,transparent_75%)]">
            <motion.img
              src={item.image}
              alt=""
              draggable={false}
              style={{ x: drift, objectPosition: item.imagePosition }}
              className="absolute top-0 -left-[7.5%] h-[238%] w-[115%] max-w-none -scale-y-100 object-cover"
            />
          </div>
        </motion.div>
      );
    }
    
    /**
     * A lightweight coverflow built from DOM, CSS 3D transforms and Motion values (no WebGL): the
     * front card stands forward while the rest recede, turn toward the center, dim and drift inside
     * their frames.
     */
    export function DepthRail<T extends DepthRailItem>({ items, label, index, onIndexChange, defaultIndex = 0, renderCaption, className }: DepthRailProps<T>) {
      const count = items.length;
      const initial = clamp(Math.round(index ?? defaultIndex), 0, Math.max(0, count - 1));
      const position = useMotionValue(initial);
      // Reduced motion lives in a motion value set after mount, so server and client render the same styles.
      const reduced = useMotionValue(0);
      const reduceMotion = useReducedMotion();
      const [front, setFront] = useState(initial);
      const [direction, setDirection] = useState(1);
      const root = useRef<HTMLDivElement>(null);
      const target = useRef(initial);
      const frontRef = useRef(initial);
      const playback = useRef<AnimationPlaybackControls | null>(null);
      const drag = useRef({ id: -1, active: false, startX: 0, startPosition: 0, startTime: 0, dragged: false });
    
      useEffect(() => {
        reduced.set(reduceMotion ? 1 : 0);
      }, [reduceMotion, reduced]);
    
      const cardWidth = () => clamp((root.current?.clientWidth ?? 600) * 0.38, 168, 272);
    
      const goTo = useCallback(
        (next: number, velocity?: number) => {
          const destination = clamp(Math.round(next), 0, Math.max(0, count - 1));
          if (destination !== target.current) {
            target.current = destination;
            onIndexChange?.(destination);
          }
          playback.current?.stop();
          if (reduced.get()) {
            position.jump(destination);
            return;
          }
          // The spring starts at the release speed, so throws land smoothly.
          playback.current = animate(position, destination, { type: "spring", stiffness: 260, damping: 30, velocity: velocity ?? position.getVelocity() });
        },
        [count, onIndexChange, position, reduced],
      );
    
      // The front card follows the position (passing cards update the caption and the slide labels).
      useMotionValueEvent(position, "change", (p) => {
        const next = clamp(Math.round(p), 0, Math.max(0, count - 1));
        if (next === frontRef.current) return;
        setDirection(next > frontRef.current ? 1 : -1);
        frontRef.current = next;
        setFront(next);
        if (drag.current.active) {
          target.current = next;
          onIndexChange?.(next);
        }
      });
    
      // Controlled index: animate to it when it changes from outside.
      useEffect(() => {
        if (index !== undefined && index !== target.current) goTo(index, 0);
      }, [index, goTo]);
    
      // Sideways trackpad scroll moves the rail directly, then settles with momentum.
      useEffect(() => {
        const element = root.current;
        if (!element) return;
        let timer = 0;
        const onWheel = (event: WheelEvent) => {
          if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return;
          event.preventDefault();
          playback.current?.stop();
          const max = Math.max(0, count - 1);
          const raw = position.get() + event.deltaX / (cardWidth() * (SPREAD / 100));
          position.set(raw < 0 ? raw * 0.35 : raw > max ? max + (raw - max) * 0.35 : raw);
          window.clearTimeout(timer);
          timer = window.setTimeout(() => goTo(position.get() + position.getVelocity() * 0.15), 120);
        };
        element.addEventListener("wheel", onWheel, { passive: false });
        return () => {
          element.removeEventListener("wheel", onWheel);
          window.clearTimeout(timer);
        };
      }, [count, goTo, position]);
    
      const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
        if (event.pointerType === "mouse" && event.button !== 0) return;
        drag.current = { id: event.pointerId, active: false, startX: event.clientX, startPosition: position.get(), startTime: performance.now(), dragged: false };
      };
      const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
        const state = drag.current;
        if (state.id !== event.pointerId) return;
        const dx = event.clientX - state.startX;
        if (!state.active) {
          if (Math.abs(dx) < 6) return;
          state.active = true;
          state.dragged = true;
          playback.current?.stop();
          event.currentTarget.setPointerCapture(event.pointerId);
        }
        const max = Math.max(0, count - 1);
        const raw = state.startPosition - dx / (cardWidth() * (SPREAD / 100));
        // Rubber band past the first and last card.
        position.set(raw < 0 ? raw * 0.35 : raw > max ? max + (raw - max) * 0.35 : raw);
      };
      const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
        const state = drag.current;
        if (state.id !== event.pointerId) return;
        state.id = -1;
        if (!state.active) return;
        state.active = false;
        const p = position.get();
        const velocity = clamp(position.getVelocity(), -25, 25);
        let next = Math.round(p + velocity * 0.22);
        const from = Math.round(state.startPosition);
        // A quick flick always moves at least one card.
        const quick = performance.now() - state.startTime < 300 && Math.abs(p - state.startPosition) > 0.08;
        if (next === from && (Math.abs(velocity) > 0.8 || quick)) next = from + Math.sign(Math.abs(velocity) > 0.8 ? velocity : p - state.startPosition);
        goTo(next, velocity);
      };
      const onClickCapture = (event: MouseEvent) => {
        // A drag never counts as a click on a card.
        if (drag.current.dragged) {
          event.stopPropagation();
          drag.current.dragged = false;
        }
      };
      const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
        const keys: Record<string, number> = { ArrowRight: target.current + 1, ArrowLeft: target.current - 1, Home: 0, End: count - 1 };
        if (!(event.key in keys)) return;
        event.preventDefault();
        goTo(keys[event.key]);
      };
    
      const item = items[front];
      return (
        <div
          ref={root}
          role="region"
          aria-roledescription="carousel"
          aria-label={label}
          tabIndex={0}
          onKeyDown={onKeyDown}
          className={cn("@container relative w-full rounded-2xl outline-none select-none focus-visible:ring-2 focus-visible:ring-ring", className)}
        >
          <div
            onPointerDown={onPointerDown}
            onPointerMove={onPointerMove}
            onPointerUp={onPointerUp}
            onPointerCancel={onPointerUp}
            onClickCapture={onClickCapture}
            className="relative h-[calc(var(--card)*1.25+var(--card)*0.55+1rem)] touch-pan-y overflow-hidden [--card:clamp(168px,38cqw,272px)] [perspective:1100px]"
          >
            {items.map((entry, i) => (
              <RailCard key={entry.id} item={entry} index={i} total={count} front={front} position={position} reduced={reduced} onSelect={(n) => n !== target.current && goTo(n)} />
            ))}
          </div>
          <div className="relative mx-auto mt-1 min-h-14 max-w-sm overflow-hidden px-4 text-center">
            <AnimatePresence initial={false} custom={direction} mode="popLayout">
              <motion.div
                key={front}
                custom={direction}
                variants={{
                  enter: (d: number) => ({ opacity: 0, x: reduceMotion ? 0 : 18 * d, y: reduceMotion ? 0 : 6 }),
                  center: { opacity: 1, x: 0, y: 0 },
                  exit: (d: number) => ({ opacity: 0, x: reduceMotion ? 0 : -18 * d }),
                }}
                initial="enter"
                animate="center"
                exit="exit"
                transition={{ duration: reduceMotion ? duration.quick : duration.base, ease: ease.out }}
              >
                {item ? (
                  renderCaption ? (
                    renderCaption(item, front)
                  ) : (
                    <>
                      <p className="truncate text-sm font-medium text-foreground">{item.title}</p>
                      {item.subtitle || item.meta ? (
                        <p className="truncate text-[13px] text-muted-foreground">{[item.subtitle, item.meta].filter(Boolean).join(" · ")}</p>
                      ) : null}
                    </>
                  )
                ) : null}
              </motion.div>
            </AnimatePresence>
          </div>
          {/* Position ticks: clickable, but out of the tab order (the rail itself handles the keys). */}
          <div aria-hidden="true" className="mt-2 flex justify-center gap-1.5">
            {items.map((entry, i) => (
              // The front dot widens with a layout (transform) animation; its neighbors slide over the same way.
              <motion.button
                key={entry.id}
                type="button"
                tabIndex={-1}
                onClick={() => goTo(i)}
                layout
                layoutDependency={front}
                transition={{ duration: reduceMotion ? 0 : duration.base, ease: ease.out }}
                style={{ borderRadius: 3 }}
                className={cn("h-1.5 cursor-pointer transition-[background-color] duration-300 motion-reduce:transition-none", i === front ? "w-5 bg-foreground" : "w-1.5 bg-foreground/20 hover:bg-foreground/40")}
              />
            ))}
          </div>
          <p aria-live="polite" aria-atomic="true" className="sr-only">
            {item ? `${front + 1} of ${count}: ${item.title}` : ""}
          </p>
        </div>
      );
    }

Usage

import { DepthRail } from "@/components/ui/depth-rail/depth-rail";

const places = [
  { id: "lisbon", title: "Lisbon", subtitle: "Portugal", meta: "Spring", image: "/places/lisbon.jpg" },
  // 6–12 items work best
];

<DepthRail items={places} label="Places" onIndexChange={(i) => console.log(places[i].title)} />

API reference

PropTypeDefaultDescription
items*T[]–Cards on the rail; 6–12 work best.
label*string–Accessible name of the carousel.
indexnumber–Front card (controlled). Pair with onIndexChange.
onIndexChange(index: number) => void–Fires when the front card changes.
defaultIndexnumber0Front card on first render when uncontrolled.
renderCaption(item: T, index: number) => ReactNode–Custom caption under the rail, e.g. stats and a save button. Defaults to title, subtitle and meta.
classNamestring–Classes for the root.
  • No WebGL: cards are DOM elements with CSS 3D transforms. One Motion value holds the rail position; every card's offset, turn, depth, scale, shade, z-index, visibility and image drift is a useTransform of it, so React never re-renders during a drag or a spring.
  • A release projects the momentum (velocity × 0.22 s), lands on that card and springs there starting at the release speed. A quick flick always moves at least one card, and the rail rubber-bands past either end. Horizontal trackpad scrolling moves it directly and settles with the same spring.
  • Cards size with the container through container query units, clamp(168px, 38cqw, 272px). Cards more than 3.6 positions from the front get visibility: hidden.
  • The reflection is a flipped copy of the image under a gradient mask; the shadow is a static blurred ellipse, so neither costs anything per frame.

Accessibility

  • The root is a labelled carousel (aria-roledescription="carousel") that takes focus: ← → move one card, Home and End jump to the ends. Each card is a slide labelled like "2 of 8: Lisbon"; cards other than the front one are aria-hidden.
  • A polite live region announces the front card after every move. The position ticks are pointer shortcuts and stay out of the tab order.
  • With reduced motion the rail jumps straight to each card, cards don't turn or drift, and only short fades remain.
  • Vertical swipes still scroll the page (touch-action: pan-y); only horizontal drags move the rail.