Skip to content

Parallax Slider

Layers move at different speeds while sliding.

Installation

CLI

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

Manual

  1. Install the dependencies

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

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

  3. Copy the source

    components/ui/parallax-slider/parallax-slider.tsx
    "use client";
    
    import { useEffect, useId, useRef, useState, type ComponentProps, type KeyboardEvent, type ReactNode } from "react";
    import { animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue, type PanInfo } from "motion/react";
    import { ChevronLeft, ChevronRight } from "lucide-react";
    import { cn } from "@/lib/utils";
    
    export interface ParallaxLayer {
      /** Layer content, e.g. part of an illustration. */
      content: ReactNode;
      /** 0 moves with the slide, 1 stays put; the far background usually sits around 0.6. */
      depth: number;
    }
    
    export interface ParallaxSlide {
      /** Stable identity. */
      id: string;
      /** Slide name, used for the slide's accessible label. */
      label: string;
      /** Background layers, back to front. */
      layers: ParallaxLayer[];
      /** Foreground content (e.g. a heading); it travels a little faster than the slide. */
      children?: ReactNode;
    }
    
    export interface ParallaxSliderProps
      extends Omit<ComponentProps<"section">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Slides, in order. */
      slides: ParallaxSlide[];
      /** Controlled index of the current slide. */
      index?: number;
      /** Initial slide when uncontrolled. @default 0 */
      defaultIndex?: number;
      /** Called when the current slide changes. */
      onIndexChange?: (index: number) => void;
      /** How much faster the foreground travels than the slide (negative depth). @default 0.3 */
      foregroundSpeed?: number;
    }
    
    const SNAP = { type: "spring", stiffness: 260, damping: 34 } as const;
    
    function Layer({ progress, width, depth, children }: { progress: MotionValue<number>; width: number; depth: number; children: ReactNode }) {
      // As the slide moves by one width, this layer moves back by depth × width: it lags behind, so it looks farther away.
      const x = useTransform(progress, (p) => -p * width * depth);
      return (
        <motion.div className="absolute inset-y-0 -right-1/2 -left-1/2" style={{ x }}>
          {children}
        </motion.div>
      );
    }
    
    function Slide({
      slide,
      i,
      count,
      track,
      width,
      active,
      foregroundSpeed,
      reduceMotion,
    }: {
      slide: ParallaxSlide;
      i: number;
      count: number;
      track: MotionValue<number>;
      width: number;
      active: boolean;
      foregroundSpeed: number;
      reduceMotion: boolean;
    }) {
      // 0 when this slide is centered, −1 when it has moved one slide to the left, +1 to the right.
      const progress = useTransform(track, (x) => (width ? (x + i * width) / width : 0));
      const textX = useTransform(progress, (p) => (reduceMotion ? 0 : p * width * foregroundSpeed));
      const textOpacity = useTransform(progress, (p) => 1 - Math.min(1, Math.abs(p) * 1.4));
    
      return (
        <div
          role="group"
          aria-roledescription="slide"
          aria-label={`${i + 1} of ${count}: ${slide.label}`}
          aria-hidden={!active || undefined}
          inert={!active || undefined}
          className="relative h-full w-full shrink-0 overflow-hidden"
        >
          {slide.layers.map((layer, l) => (
            <Layer key={l} progress={progress} width={width} depth={reduceMotion ? 0 : layer.depth}>
              {layer.content}
            </Layer>
          ))}
          {slide.children ? (
            <motion.div className="absolute inset-0" style={{ x: textX, opacity: textOpacity }}>
              {slide.children}
            </motion.div>
          ) : null}
        </div>
      );
    }
    
    export function ParallaxSlider({
      slides,
      index,
      defaultIndex = 0,
      onIndexChange,
      foregroundSpeed = 0.3,
      className,
      "aria-label": ariaLabel = "Slides",
      ...props
    }: ParallaxSliderProps) {
      const reduceMotion = useReducedMotion() ?? false;
      const viewportRef = useRef<HTMLDivElement>(null);
      const [internal, setInternal] = useState(defaultIndex);
      const current = Math.min(Math.max(index ?? internal, 0), slides.length - 1);
      const [width, setWidth] = useState(0);
      const track = useMotionValue(0);
      const liveId = useId();
    
      // Links and images inside start the browser's own drag-and-drop, which would cancel the swipe.
      useEffect(() => {
        const el = viewportRef.current;
        if (!el) return;
        const block = (event: DragEvent) => event.preventDefault();
        el.addEventListener("dragstart", block);
        return () => el.removeEventListener("dragstart", block);
      }, []);
    
      useEffect(() => {
        const el = viewportRef.current;
        if (!el) return;
        const observer = new ResizeObserver(() => setWidth(el.offsetWidth));
        observer.observe(el);
        return () => observer.disconnect();
      }, []);
    
      // Keep the track on the current slide (on resize, and when a controlled index changes).
      useEffect(() => {
        if (!width) return;
        const controls = animate(track, -current * width, reduceMotion ? { duration: 0 } : SNAP);
        return () => controls.stop();
      }, [current, width, reduceMotion, track]);
    
      const go = (next: number) => {
        const clamped = Math.min(Math.max(next, 0), slides.length - 1);
        if (index === undefined) setInternal(clamped);
        onIndexChange?.(clamped);
        // Re-snap even when the index doesn't change (e.g. a short drag).
        if (clamped === current && width) animate(track, -clamped * width, reduceMotion ? { duration: 0 } : SNAP);
      };
    
      const onDragEnd = (_: unknown, info: PanInfo) => {
        const projected = -track.get() - info.velocity.x * 0.25;
        go(Math.round(projected / (width || 1)));
      };
    
      const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
        if (event.key === "ArrowRight") go(current + 1);
        else if (event.key === "ArrowLeft") go(current - 1);
        else return;
        event.preventDefault();
      };
    
      const arrow =
        "inline-flex size-9 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-foreground shadow-xs outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40";
    
      return (
        <section aria-roledescription="carousel" aria-label={ariaLabel} onKeyDown={onKeyDown} className={cn("w-full", className)} {...props}>
          <div ref={viewportRef} className="relative aspect-[16/10] overflow-hidden rounded-2xl bg-muted sm:aspect-[16/8]">
            <motion.div
              className="flex h-full cursor-grab touch-pan-y active:cursor-grabbing"
              style={{ x: track }}
              drag="x"
              dragConstraints={{ left: -(slides.length - 1) * width, right: 0 }}
              dragElastic={0.14}
              dragMomentum={false}
              onDragEnd={onDragEnd}
            >
              {slides.map((slide, i) => (
                <Slide
                  key={slide.id}
                  slide={slide}
                  i={i}
                  count={slides.length}
                  track={track}
                  width={width}
                  active={i === current}
                  foregroundSpeed={foregroundSpeed}
                  reduceMotion={reduceMotion}
                />
              ))}
            </motion.div>
          </div>
          <div className="mt-4 flex items-center justify-between gap-4">
            <div className="flex items-center gap-1.5" role="group" aria-label="Choose slide">
              {slides.map((slide, i) => (
                <button
                  key={slide.id}
                  type="button"
                  onClick={() => go(i)}
                  aria-label={`Go to slide ${i + 1}: ${slide.label}`}
                  aria-current={i === current || undefined}
                  className="group flex h-6 cursor-pointer items-center outline-none"
                >
                  {/* The current dot widens with a layout (transform) animation, so its neighbors slide over too. */}
                  <motion.span
                    className={cn("block h-1.5 bg-foreground group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2", i === current ? "w-[22px]" : "w-1.5")}
                    style={{ borderRadius: 3 }}
                    layout
                    layoutDependency={current}
                    initial={false}
                    animate={{ opacity: i === current ? 1 : 0.25 }}
                    transition={reduceMotion ? { duration: 0 } : SNAP}
                  />
                </button>
              ))}
            </div>
            <p id={liveId} className="sr-only" aria-live="polite">
              Slide {current + 1} of {slides.length}: {slides[current]?.label}
            </p>
            <div className="flex gap-2">
              <button type="button" className={arrow} onClick={() => go(current - 1)} disabled={current === 0} aria-label="Previous slide">
                <ChevronLeft className="size-4" aria-hidden="true" />
              </button>
              <button type="button" className={arrow} onClick={() => go(current + 1)} disabled={current === slides.length - 1} aria-label="Next slide">
                <ChevronRight className="size-4" aria-hidden="true" />
              </button>
            </div>
          </div>
        </section>
      );
    }

Usage

import { ParallaxSlider } from "@/components/ui/parallax-slider/parallax-slider";

export function Seasons() {
  return (
    <ParallaxSlider
      slides={[
        {
          id: "dawn",
          label: "Dawn",
          layers: [
            { depth: 0.5, content: <Sky /> },
            { depth: 0.2, content: <FarHills /> },
            { depth: 0.05, content: <NearHills /> },
          ],
          children: <h3>Start before the sun</h3>,
        },
      ]}
    />
  );
}

API reference

PropTypeDefaultDescription
slides*ParallaxSlide[]–Slides, in order.
indexnumber–Controlled index of the current slide.
defaultIndexnumber0Initial slide when uncontrolled.
onIndexChange(index: number) => void–Called when the current slide changes.
foregroundSpeednumber0.3How much faster the foreground travels than the slide (negative depth).

Also accepts every native <section> attribute, including ref and className.

  • Each layer's depth sets how much it lags behind the slide (0 moves with it, 1 stays put), so far layers drift slowly and near ones keep pace.
  • Layers are rendered twice as wide as the slide so their edges never show; keep depth at 0.5 or below.
  • Drag or swipe between slides; release velocity decides where it snaps.

Accessibility

  • Follows the WAI-ARIA carousel pattern: a labelled region, slides labelled “2 of 3: Noon”, and inactive slides inert and hidden from assistive tech.
  • Previous/next buttons, a button per slide, and left/right arrow keys; the current slide is announced.
  • With reduced motion enabled, layers don't parallax and slides change instantly. Dragging still works.