← Components

Clip-Path Slider

Full-bleed slides that open through a clip-path while the old one zooms back.

Gallery · FreeInstall ↓
Open preview ↗
Loading the demoNew tab ↗

Customize

1.25 s
1.35
0.0 s
Your code
<ClipSlider slides={slides} />

Changes show live and stay in the address, so a link keeps them.

Code

"use client";

import { useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { gsap } from "gsap";
import { Observer } from "gsap/Observer";
import { useGSAP } from "@gsap/react";

// A full-bleed image slider. The next photo opens over the old one through a clip-path while the
// old one slowly zooms back, a framed copy of the photo in the middle opens from the opposite edge,
// and the counter and title roll to the new slide. Every layer is in the page from the start; the
// slider only moves clip-paths and transforms, so a change never waits for React or an image.

if (typeof window !== "undefined") gsap.registerPlugin(Observer, useGSAP);

export type ClipSliderSlide = {
  /** The photo, shown full-bleed and in the frame */
  src: string;
  /** Rolled in at the bottom left. Leave every title out to hide the line. */
  title?: string;
  /** Describes the photo for screen readers. Empty means the photo is decoration. */
  alt?: string;
};

export type ClipSliderProps = {
  /** The slides, in order. Two or more to slide; it loops at both ends. */
  slides: ClipSliderSlide[];
  /** How long one change takes, in seconds. */
  duration?: number;
  /** How far the incoming photo is zoomed in before it settles to 1. The old photo zooms half as far. */
  zoom?: number;
  /** The GSAP ease of a change. */
  ease?: string;
  /** Which way the photos open: up and down, or sideways. */
  direction?: "vertical" | "horizontal";
  /** Seconds between automatic changes. 0 turns it off (it's always off with reduced motion). */
  autoplay?: number;
  /** Show the rolling "01 / 06" counter. */
  showCounter?: boolean;
  /** Show the framed copy of the photo in the middle. */
  showFrame?: boolean;
  /** How visitors change slides: the wheel (and swipes on touch), arrow buttons, or both. Arrow keys always work. */
  controls?: "wheel" | "buttons" | "both";
  /** Names the slider for screen readers. */
  label?: string;
  /** Called with the new slide's index when a change starts. */
  onChange?: (index: number) => void;
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
  /** Content laid over the slides (a logo, a nav). Its own links and buttons stay clickable. */
  children?: ReactNode;
};

// A clip-path that hides the layer by squashing it against one edge, so opening it to OPEN
// sweeps the photo in from that edge
const OPEN = "inset(0% 0% 0% 0%)";
const closedAt = { top: "inset(0% 0% 100% 0%)", bottom: "inset(100% 0% 0% 0%)", left: "inset(0% 100% 0% 0%)", right: "inset(0% 0% 0% 100%)" } as const;

// prefers-reduced-motion, live
function subscribeToMotion(onChange: () => void) {
  const query = window.matchMedia("(prefers-reduced-motion: reduce)");
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}
const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// The layers that exist (the frame's are missing when it's hidden)
const present = (...elements: (HTMLElement | undefined)[]) => elements.filter((element): element is HTMLElement => element !== undefined);

const pad = (n: number) => String(n).padStart(2, "0");

type Step = 1 | -1;
type SliderApi = { go: (step: Step) => void; togglePause: () => void };

export function ClipSlider({
  slides,
  duration = 1.25,
  zoom = 1.35,
  ease = "power3.inOut",
  direction = "vertical",
  autoplay = 0,
  showCounter = true,
  showFrame = true,
  controls = "both",
  label = "Image slider",
  onChange,
  className = "",
  children,
}: ClipSliderProps) {
  const boxRef = useRef<HTMLElement>(null);
  const progressRef = useRef<HTMLSpanElement>(null);
  const api = useRef<SliderApi | null>(null);
  // Kept across rebuilds (new props), so the slider stays on the slide it's showing
  const currentRef = useRef(0);
  const [index, setIndex] = useState(0);
  const [paused, setPaused] = useState(false);
  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);

  // Read when a change starts, so tweaking them never rebuilds the slider
  const settings = useRef({ duration, zoom, ease, direction, onChange });
  settings.current = { duration, zoom, ease, direction, onChange };

  const count = slides.length;
  const slideKey = slides.map((slide) => slide.src).join("|");
  const hasTitles = slides.some((slide) => slide.title);
  const autoplaying = autoplay > 0 && !reducedMotion && count > 1;

  useGSAP(
    (_context, contextSafe) => {
      const box = boxRef.current;
      if (!box || !contextSafe || count === 0) return;

      const all = (name: string) => Array.from(box.querySelectorAll<HTMLElement>(`[data-clip-${name}]`));
      const layers = all("slide");
      const photos = all("photo");
      const frames = all("frame");
      const framePhotos = all("frame-photo");
      const numbers = all("number");
      const titles = all("title");

      let current = Math.min(currentRef.current, count - 1);
      currentRef.current = current;
      setIndex(current);
      let busy = false;
      let gestureReady = true;
      let inView = true;
      let userPaused = false;
      let top = 1;
      let timer: gsap.core.Tween | null = null;

      // Lay out the slide we're on: open and on top. Every other layer waits, hidden.
      layers.forEach((layer, i) => gsap.set(layer, { clipPath: i === current ? OPEN : closedAt.bottom, visibility: i === current ? "visible" : "hidden", zIndex: i === current ? 1 : 0, opacity: 1 }));
      frames.forEach((frame, i) => gsap.set(frame, { clipPath: i === current ? OPEN : closedAt.top, visibility: i === current ? "visible" : "hidden", zIndex: i === current ? 1 : 0, opacity: 1 }));
      gsap.set(photos, { scale: 1 });
      if (framePhotos.length) gsap.set(framePhotos, { xPercent: 0, yPercent: 0 });
      // The counter and the title roll: the current one in its box, the rest waiting below it
      const rolling = [numbers, titles].filter((list) => list.length === count);
      rolling.forEach((list) => gsap.set(list, { y: 0, yPercent: (i: number) => (i === current ? 0 : 110) }));

      // The autoplay clock is a tween on the progress line: when the line is full, the slider moves on
      const startClock = contextSafe(() => {
        timer?.kill();
        timer = null;
        if (!autoplaying) return;
        timer = gsap.fromTo(progressRef.current ?? {}, { scaleX: 0 }, { scaleX: 1, duration: autoplay, ease: "none", onComplete: () => go(1) });
        if (userPaused || !inView) timer.pause();
      });

      const go = contextSafe((step: Step) => {
        if (busy || count < 2) return;
        busy = true;
        timer?.kill();

        const from = current;
        const to = (current + step + count) % count;
        current = to;
        currentRef.current = to;
        setIndex(to);
        settings.current.onChange?.(to);

        const { duration: time, zoom: zoomIn, ease: easing, direction: way } = settings.current;
        const vertical = way === "vertical";
        // Next opens the photo from the bottom (or right) and the frame from the top (or left); back is the mirror
        const photoEdge = vertical ? (step === 1 ? "bottom" : "top") : step === 1 ? "right" : "left";
        const frameEdge = vertical ? (step === 1 ? "top" : "bottom") : step === 1 ? "left" : "right";
        const shift = vertical ? "yPercent" : "xPercent";
        top += 1;

        const finish = () => {
          gsap.set(present(layers[from], frames[from]), { visibility: "hidden" });
          gsap.set(photos[from]!, { scale: 1 });
          if (framePhotos[from]) gsap.set(framePhotos[from], { xPercent: 0, yPercent: 0 });
          busy = false;
          startClock();
        };

        // Reduced motion: no sweeping or zooming, a short cross-fade and the numbers swap in place
        if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
          gsap.set(present(layers[to], frames[to]), { zIndex: top, visibility: "visible", clipPath: OPEN, opacity: 0 });
          gsap.set(photos[to]!, { scale: 1 });
          rolling.forEach((list) => gsap.set(list, { yPercent: (i: number) => (i === to ? 0 : 110) }));
          gsap.to(present(layers[to], frames[to]), { opacity: 1, duration: 0.35, ease: "power1.out", onComplete: finish });
          return;
        }

        gsap.set(layers[to]!, { zIndex: top, visibility: "visible", clipPath: closedAt[photoEdge] });
        gsap.set(photos[to]!, { scale: zoomIn });
        if (frames[to]) {
          gsap.set(frames[to], { zIndex: top, visibility: "visible", clipPath: closedAt[frameEdge] });
          gsap.set(framePhotos[to]!, { xPercent: 0, yPercent: 0, [shift]: -step * 50 });
        }
        rolling.forEach((list) => gsap.set(list[to]!, { yPercent: step * 110 }));

        const timeline = gsap.timeline({ defaults: { duration: time, ease: easing }, onComplete: finish });
        timeline
          .to(layers[to]!, { clipPath: OPEN }, 0)
          .to(photos[to]!, { scale: 1 }, 0)
          // The old photo drifts further in while it's covered, so the new one seems to push past it
          .to(photos[from]!, { scale: 1 + (zoomIn - 1) / 2 }, 0);
        if (frames[to] && frames[from]) {
          timeline
            .to(frames[to], { clipPath: OPEN }, 0)
            .to(framePhotos[to]!, { [shift]: 0 }, 0)
            .to(framePhotos[from]!, { [shift]: step * 50 }, 0);
        }
        rolling.forEach((list) => timeline.to(list[from]!, { yPercent: -step * 110 }, 0).to(list[to]!, { yPercent: 0 }, 0));
      });

      const togglePause = contextSafe(() => {
        userPaused = !userPaused;
        setPaused(userPaused);
        if (userPaused) timer?.pause();
        else if (inView) timer?.resume();
      });

      api.current = { go, togglePause };
      startClock();

      // The wheel and touch swipes. wheelSpeed -1 makes wheel-down and swipe-up both "up" (next).
      // One change per gesture: a trackpad keeps sending wheel events long after the flick, so the
      // next change waits until the wheel has been still for a moment.
      let observer: Observer | null = null;
      if (controls !== "buttons" && count > 1) {
        const fromGesture = (step: Step) => {
          if (!gestureReady || busy) return;
          gestureReady = false;
          go(step);
        };
        observer = Observer.create({
          target: box,
          type: "wheel,touch",
          wheelSpeed: -1,
          tolerance: 10,
          preventDefault: true,
          ignore: box.querySelectorAll("[data-clip-ui]"),
          onUp: () => fromGesture(1),
          onDown: () => fromGesture(-1),
          onLeft: () => fromGesture(1),
          onRight: () => fromGesture(-1),
          onStopDelay: 0.18,
          onStop: () => {
            gestureReady = true;
          },
          onRelease: () => {
            gestureReady = true;
          },
        });
      }

      // Arrow keys work when focus is inside the slider, or on the page itself while the slider fills
      // most of the screen (so a full-screen slider answers the keyboard straight away). From the page,
      // PageUp/PageDown are left alone, and on the first or last slide the arrows hand back to the page
      // when it can scroll, so a keyboard user is never stuck on a hero slider that loops forever.
      const onKeyDown = (event: KeyboardEvent) => {
        if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
        const target = event.target instanceof Element ? event.target : null;
        const inside = target !== null && box.contains(target);
        const onPage = target === null || target === document.body || target === document.documentElement;
        if (!inside && !(onPage && inView)) return;
        if (target?.closest("input, textarea, select, [contenteditable]")) return;
        const forward = inside ? ["ArrowDown", "ArrowRight", "PageDown"] : ["ArrowDown", "ArrowRight"];
        const back = inside ? ["ArrowUp", "ArrowLeft", "PageUp"] : ["ArrowUp", "ArrowLeft"];
        const step: Step | 0 = forward.includes(event.key) ? 1 : back.includes(event.key) ? -1 : 0;
        if (!step) return;
        if (!inside) {
          const atEnd = step === 1 ? current === count - 1 : current === 0;
          const pageScrolls = document.documentElement.scrollHeight > window.innerHeight + 1;
          if (atEnd && pageScrolls) return;
        }
        event.preventDefault();
        go(step);
      };
      window.addEventListener("keydown", onKeyDown);

      // Off screen: the autoplay clock stops, and the keys stop answering
      const visibility = new IntersectionObserver(
        ([entry]) => {
          inView = (entry?.intersectionRatio ?? 1) >= 0.5;
          if (!inView) timer?.pause();
          else if (!userPaused) timer?.resume();
        },
        { threshold: [0, 0.5, 1] },
      );
      visibility.observe(box);

      return () => {
        observer?.kill();
        window.removeEventListener("keydown", onKeyDown);
        visibility.disconnect();
        timer?.kill();
        api.current = null;
      };
    },
    { scope: boxRef, dependencies: [slideKey, count, controls, autoplay, autoplaying, showFrame, showCounter, hasTitles], revertOnUpdate: true },
  );

  const showButtons = controls !== "wheel" && count > 1;
  const vertical = direction === "vertical";
  const current = slides[index];

  return (
    <section
      ref={boxRef}
      aria-roledescription="carousel"
      aria-label={label}
      className={`relative overflow-hidden bg-neutral-950 text-white select-none ${className}`}
      style={{ containerType: "inline-size", touchAction: controls === "buttons" ? "auto" : "none" }}
    >
      {/* isolate keeps the slides' growing z-index inside this layer, under the frame and the text */}
      <div className="absolute inset-0 isolate">
        {slides.map((slide, i) => (
        <div
          key={`${i}-${slide.src}`}
          data-clip-slide=""
          role="group"
          aria-roledescription="slide"
          aria-label={`${i + 1} of ${count}`}
          aria-hidden={i !== index}
          className="absolute inset-0 overflow-hidden"
          style={{ clipPath: i === 0 ? OPEN : closedAt.bottom, visibility: i === 0 ? "visible" : "hidden", willChange: "clip-path" }}
        >
          <img data-clip-photo="" src={slide.src} alt={slide.alt ?? ""} className="absolute inset-0 block h-full w-full object-cover" style={{ willChange: "transform" }} draggable={false} decoding="async" />
        </div>
        ))}
      </div>

      {/* Shade on the top, left and bottom so overlaid text reads on any photo, even a pale sky */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 z-[1]"
        style={{ background: "linear-gradient(to bottom, rgba(0,0,0,0.32), rgba(0,0,0,0) 22%), linear-gradient(to right, rgba(0,0,0,0.45), rgba(0,0,0,0) 55%), linear-gradient(to top, rgba(0,0,0,0.5), rgba(0,0,0,0) 34%)" }}
      />

      {showFrame && count > 0 ? (
        // The framed copy is decoration: the full-bleed photo already carries the alt text
        <div
          aria-hidden="true"
          className="pointer-events-none absolute top-[46%] left-1/2 z-[2] -translate-x-1/2 -translate-y-1/2 shadow-[0_40px_90px_rgba(0,0,0,0.35)]"
          style={{ height: "min(max(225px, 27.5cqw), 64%)", aspectRatio: "4 / 5" }}
        >
          {slides.map((slide, i) => (
            <div
              key={`${i}-${slide.src}`}
              data-clip-frame=""
              className="absolute inset-0 overflow-hidden"
              style={{ clipPath: i === 0 ? OPEN : closedAt.top, visibility: i === 0 ? "visible" : "hidden", willChange: "clip-path" }}
            >
              <img data-clip-frame-photo="" src={slide.src} alt="" className="absolute inset-0 block h-full w-full object-cover" style={{ willChange: "transform" }} draggable={false} decoding="async" />
            </div>
          ))}
        </div>
      ) : null}

      {children ? <div className="pointer-events-none absolute inset-0 z-[3] [&_a]:pointer-events-auto [&_button]:pointer-events-auto">{children}</div> : null}

      <div className="pointer-events-none absolute inset-x-0 bottom-0 z-[4] flex items-end justify-between gap-6 px-[clamp(20px,2.2cqw,32px)] pb-[clamp(20px,1.9cqw,28px)]">
        {/* Every title sits in the same grid cell, so the line is as wide as the longest one */}
        {hasTitles ? (
          <p aria-hidden="true" className="grid min-w-0 overflow-hidden text-[clamp(26px,3.4cqw,52px)] leading-[1.08] font-medium tracking-[-0.035em]">
            {slides.map((slide, i) => (
              <span key={`${i}-${slide.src}`} data-clip-title="" className="truncate [grid-area:1/1]" style={{ willChange: "transform" }}>
                {slide.title ?? ""}
              </span>
            ))}
          </p>
        ) : (
          <span />
        )}

        <div className="flex shrink-0 items-center gap-4">
          {showCounter && count > 0 ? (
            <p aria-hidden="true" className="flex items-baseline gap-[0.18em] text-[14px] leading-4 tracking-[0.05em] tabular-nums [text-shadow:0_1px_8px_rgba(0,0,0,0.35)]">
              <span className="grid h-4 overflow-hidden">
                {slides.map((slide, i) => (
                  <span key={`${i}-${slide.src}`} data-clip-number="" className="[grid-area:1/1]" style={{ willChange: "transform" }}>
                    {pad(i + 1)}
                  </span>
                ))}
              </span>
              <span className="opacity-60">/</span>
              <span className="opacity-60">{pad(count)}</span>
            </p>
          ) : null}

          {autoplaying ? (
            <button
              type="button"
              data-clip-ui=""
              onClick={() => api.current?.togglePause()}
              aria-label={paused ? "Play slideshow" : "Pause slideshow"}
              className="pointer-events-auto relative flex h-10 items-center gap-2 rounded-full px-1 text-[12px] tracking-[0.05em] uppercase outline-none focus-visible:ring-2 focus-visible:ring-white"
            >
              <span className="relative h-px w-12 overflow-hidden bg-white/30">
                <span ref={progressRef} className="absolute inset-0 origin-left scale-x-0 bg-white" />
              </span>
              <span className="w-[3.2em] text-left opacity-80">{paused ? "Play" : "Pause"}</span>
            </button>
          ) : null}

          {count > 1 ? (
            // With wheel-only controls the buttons stay hidden until a keyboard user tabs to them
            <div data-clip-ui="" className={`flex gap-2 ${showButtons ? "" : "opacity-0 focus-within:opacity-100"}`}>
              {([-1, 1] as const).map((step) => (
                <button
                  key={step}
                  type="button"
                  onClick={() => api.current?.go(step)}
                  aria-label={step === 1 ? "Next slide" : "Previous slide"}
                  className={`flex h-10 w-10 items-center justify-center rounded-full border border-white/35 bg-white/0 backdrop-blur-[2px] transition-colors duration-300 outline-none hover:border-white hover:bg-white hover:text-neutral-950 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950 ${showButtons ? "pointer-events-auto" : "pointer-events-none focus:pointer-events-auto"}`}
                >
                  <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true" style={{ transform: `rotate(${(vertical ? 90 : 0) + (step === 1 ? 0 : 180)}deg)` }}>
                    <path d="M2 8h11M9 4l4 4-4 4" strokeLinecap="round" strokeLinejoin="round" />
                  </svg>
                </button>
              ))}
            </div>
          ) : null}
        </div>
      </div>

      <p className="sr-only" aria-live={autoplaying && !paused ? "off" : "polite"} aria-atomic="true">
        {current ? `Slide ${index + 1} of ${count}${current.title ? `: ${current.title}` : ""}` : ""}
      </p>
    </section>
  );
}

The component. Installs to components/dimaac/clip-slider.tsx.

Install

With the shadcn CLI

npx shadcn@latest add https://ui.dimaac.com/r/clip-slider.json

It adds components/dimaac/clip-slider.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/clip-slider.

By hand

npm install gsap @gsap/react
  1. Install the packages it uses: gsap, @gsap/react.
  2. Copy the component file below into components/dimaac/clip-slider.tsx.
  3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
  4. Import it with import { ClipSlider } from "@/components/dimaac/clip-slider" and give its parent a size: it fills it.

Props

PropTypeDefaultDescription
slides (required)ClipSliderSlide[]-The slides, in order. Two or more to slide; it loops at both ends.
durationnumber1.25How long one change takes, in seconds.
zoomnumber1.35How far the incoming photo is zoomed in before it settles to 1. The old photo zooms half as far.
easestring"power3.inOut"The GSAP ease of a change.
direction"vertical" | "horizontal""vertical"Which way the photos open: up and down, or sideways.
autoplaynumber0Seconds between automatic changes. 0 turns it off (it's always off with reduced motion).
showCounterbooleantrueShow the rolling "01 / 06" counter.
showFramebooleantrueShow the framed copy of the photo in the middle.
controls"wheel" | "buttons" | "both""both"How visitors change slides: the wheel (and swipes on touch), arrow buttons, or both. Arrow keys always work.
labelstring"Image slider"Names the slider for screen readers.
onChange(index: number) => void-Called with the new slide's index when a change starts.
classNamestring""Extra classes for the outer box. Give it a size (it fills it).
childrenReactNode-Content laid over the slides (a logo, a nav). Its own links and buttons stay clickable.

Use with AI

Agent prompt

Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.

Prompt
Add the Clip-Path Slider component (<ClipSlider />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/clip-slider
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/clip-slider.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/clip-slider.json

Then use it like this:
import { ClipSlider } from "@/components/dimaac/clip-slider";
<ClipSlider slides={slides} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/clip-slider.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt

Files agents read

llms.txt
Every component and how to install it
clip-slider.md
This page as Markdown: install, props and the full source
clip-slider.json
The shadcn registry item

About

A full-bleed image slider for an agency's selected work. Each change opens the next photo through a clip-path from the bottom while the old photo zooms back under it, a framed copy in the middle opens from the opposite edge with a little parallax, and the counter and title roll to the new slide. It moves on the wheel (one change per gesture, so trackpads don't skip slides), swipes on touch, arrow buttons and the arrow keys, loops at both ends, can play by itself with a pause button, and swaps with a short cross-fade when the visitor prefers reduced motion. Only clip-paths and transforms are animated, with GSAP and its Observer plugin.

Installs to
components/dimaac/clip-slider.tsx
Uses
gsap, @gsap/react
Tags
gsap, clip-path, slider, gallery, hero, wheel

Alpine Dawn Serenity by Viri Gutiérrez on Lummi (Lummi License). Desert Dunes and Mountain Range by Pablo Stanley on Lummi (Lummi License). Serene Minimalist Scene with Palm Trees by Binks AI on Lummi (Lummi License). Serene Ocean Aerial View by Mikiwa on Lummi (Lummi License). Solitary Tree in Desert by alexaperios on Lummi (Lummi License). Minimalist Urban Design by Ricardo Matos on Lummi (Lummi License).