{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"clip-slider","type":"registry:component","title":"Clip-Path Slider","description":"Full-bleed slides that open through a clip-path while the old one zooms back.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/clip-slider/clip-slider.tsx","type":"registry:component","target":"components/dimaac/clip-slider.tsx","content":"\"use client\";\n\nimport { useRef, useState, useSyncExternalStore, type ReactNode } from \"react\";\nimport { gsap } from \"gsap\";\nimport { Observer } from \"gsap/Observer\";\nimport { useGSAP } from \"@gsap/react\";\n\n// A full-bleed image slider. The next photo opens over the old one through a clip-path while the\n// old one slowly zooms back, a framed copy of the photo in the middle opens from the opposite edge,\n// and the counter and title roll to the new slide. Every layer is in the page from the start; the\n// slider only moves clip-paths and transforms, so a change never waits for React or an image.\n\nif (typeof window !== \"undefined\") gsap.registerPlugin(Observer, useGSAP);\n\nexport type ClipSliderSlide = {\n  /** The photo, shown full-bleed and in the frame */\n  src: string;\n  /** Rolled in at the bottom left. Leave every title out to hide the line. */\n  title?: string;\n  /** Describes the photo for screen readers. Empty means the photo is decoration. */\n  alt?: string;\n};\n\nexport type ClipSliderProps = {\n  /** The slides, in order. Two or more to slide; it loops at both ends. */\n  slides: ClipSliderSlide[];\n  /** How long one change takes, in seconds. */\n  duration?: number;\n  /** How far the incoming photo is zoomed in before it settles to 1. The old photo zooms half as far. */\n  zoom?: number;\n  /** The GSAP ease of a change. */\n  ease?: string;\n  /** Which way the photos open: up and down, or sideways. */\n  direction?: \"vertical\" | \"horizontal\";\n  /** Seconds between automatic changes. 0 turns it off (it's always off with reduced motion). */\n  autoplay?: number;\n  /** Show the rolling \"01 / 06\" counter. */\n  showCounter?: boolean;\n  /** Show the framed copy of the photo in the middle. */\n  showFrame?: boolean;\n  /** How visitors change slides: the wheel (and swipes on touch), arrow buttons, or both. Arrow keys always work. */\n  controls?: \"wheel\" | \"buttons\" | \"both\";\n  /** Names the slider for screen readers. */\n  label?: string;\n  /** Called with the new slide's index when a change starts. */\n  onChange?: (index: number) => void;\n  /** Extra classes for the outer box. Give it a size (it fills it). */\n  className?: string;\n  /** Content laid over the slides (a logo, a nav). Its own links and buttons stay clickable. */\n  children?: ReactNode;\n};\n\n// A clip-path that hides the layer by squashing it against one edge, so opening it to OPEN\n// sweeps the photo in from that edge\nconst OPEN = \"inset(0% 0% 0% 0%)\";\nconst 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;\n\n// prefers-reduced-motion, live\nfunction subscribeToMotion(onChange: () => void) {\n  const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n}\nconst prefersReducedMotion = () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n// The layers that exist (the frame's are missing when it's hidden)\nconst present = (...elements: (HTMLElement | undefined)[]) => elements.filter((element): element is HTMLElement => element !== undefined);\n\nconst pad = (n: number) => String(n).padStart(2, \"0\");\n\ntype Step = 1 | -1;\ntype SliderApi = { go: (step: Step) => void; togglePause: () => void };\n\nexport function ClipSlider({\n  slides,\n  duration = 1.25,\n  zoom = 1.35,\n  ease = \"power3.inOut\",\n  direction = \"vertical\",\n  autoplay = 0,\n  showCounter = true,\n  showFrame = true,\n  controls = \"both\",\n  label = \"Image slider\",\n  onChange,\n  className = \"\",\n  children,\n}: ClipSliderProps) {\n  const boxRef = useRef<HTMLElement>(null);\n  const progressRef = useRef<HTMLSpanElement>(null);\n  const api = useRef<SliderApi | null>(null);\n  // Kept across rebuilds (new props), so the slider stays on the slide it's showing\n  const currentRef = useRef(0);\n  const [index, setIndex] = useState(0);\n  const [paused, setPaused] = useState(false);\n  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);\n\n  // Read when a change starts, so tweaking them never rebuilds the slider\n  const settings = useRef({ duration, zoom, ease, direction, onChange });\n  settings.current = { duration, zoom, ease, direction, onChange };\n\n  const count = slides.length;\n  const slideKey = slides.map((slide) => slide.src).join(\"|\");\n  const hasTitles = slides.some((slide) => slide.title);\n  const autoplaying = autoplay > 0 && !reducedMotion && count > 1;\n\n  useGSAP(\n    (_context, contextSafe) => {\n      const box = boxRef.current;\n      if (!box || !contextSafe || count === 0) return;\n\n      const all = (name: string) => Array.from(box.querySelectorAll<HTMLElement>(`[data-clip-${name}]`));\n      const layers = all(\"slide\");\n      const photos = all(\"photo\");\n      const frames = all(\"frame\");\n      const framePhotos = all(\"frame-photo\");\n      const numbers = all(\"number\");\n      const titles = all(\"title\");\n\n      let current = Math.min(currentRef.current, count - 1);\n      currentRef.current = current;\n      setIndex(current);\n      let busy = false;\n      let gestureReady = true;\n      let inView = true;\n      let userPaused = false;\n      let top = 1;\n      let timer: gsap.core.Tween | null = null;\n\n      // Lay out the slide we're on: open and on top. Every other layer waits, hidden.\n      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 }));\n      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 }));\n      gsap.set(photos, { scale: 1 });\n      if (framePhotos.length) gsap.set(framePhotos, { xPercent: 0, yPercent: 0 });\n      // The counter and the title roll: the current one in its box, the rest waiting below it\n      const rolling = [numbers, titles].filter((list) => list.length === count);\n      rolling.forEach((list) => gsap.set(list, { y: 0, yPercent: (i: number) => (i === current ? 0 : 110) }));\n\n      // The autoplay clock is a tween on the progress line: when the line is full, the slider moves on\n      const startClock = contextSafe(() => {\n        timer?.kill();\n        timer = null;\n        if (!autoplaying) return;\n        timer = gsap.fromTo(progressRef.current ?? {}, { scaleX: 0 }, { scaleX: 1, duration: autoplay, ease: \"none\", onComplete: () => go(1) });\n        if (userPaused || !inView) timer.pause();\n      });\n\n      const go = contextSafe((step: Step) => {\n        if (busy || count < 2) return;\n        busy = true;\n        timer?.kill();\n\n        const from = current;\n        const to = (current + step + count) % count;\n        current = to;\n        currentRef.current = to;\n        setIndex(to);\n        settings.current.onChange?.(to);\n\n        const { duration: time, zoom: zoomIn, ease: easing, direction: way } = settings.current;\n        const vertical = way === \"vertical\";\n        // Next opens the photo from the bottom (or right) and the frame from the top (or left); back is the mirror\n        const photoEdge = vertical ? (step === 1 ? \"bottom\" : \"top\") : step === 1 ? \"right\" : \"left\";\n        const frameEdge = vertical ? (step === 1 ? \"top\" : \"bottom\") : step === 1 ? \"left\" : \"right\";\n        const shift = vertical ? \"yPercent\" : \"xPercent\";\n        top += 1;\n\n        const finish = () => {\n          gsap.set(present(layers[from], frames[from]), { visibility: \"hidden\" });\n          gsap.set(photos[from]!, { scale: 1 });\n          if (framePhotos[from]) gsap.set(framePhotos[from], { xPercent: 0, yPercent: 0 });\n          busy = false;\n          startClock();\n        };\n\n        // Reduced motion: no sweeping or zooming, a short cross-fade and the numbers swap in place\n        if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n          gsap.set(present(layers[to], frames[to]), { zIndex: top, visibility: \"visible\", clipPath: OPEN, opacity: 0 });\n          gsap.set(photos[to]!, { scale: 1 });\n          rolling.forEach((list) => gsap.set(list, { yPercent: (i: number) => (i === to ? 0 : 110) }));\n          gsap.to(present(layers[to], frames[to]), { opacity: 1, duration: 0.35, ease: \"power1.out\", onComplete: finish });\n          return;\n        }\n\n        gsap.set(layers[to]!, { zIndex: top, visibility: \"visible\", clipPath: closedAt[photoEdge] });\n        gsap.set(photos[to]!, { scale: zoomIn });\n        if (frames[to]) {\n          gsap.set(frames[to], { zIndex: top, visibility: \"visible\", clipPath: closedAt[frameEdge] });\n          gsap.set(framePhotos[to]!, { xPercent: 0, yPercent: 0, [shift]: -step * 50 });\n        }\n        rolling.forEach((list) => gsap.set(list[to]!, { yPercent: step * 110 }));\n\n        const timeline = gsap.timeline({ defaults: { duration: time, ease: easing }, onComplete: finish });\n        timeline\n          .to(layers[to]!, { clipPath: OPEN }, 0)\n          .to(photos[to]!, { scale: 1 }, 0)\n          // The old photo drifts further in while it's covered, so the new one seems to push past it\n          .to(photos[from]!, { scale: 1 + (zoomIn - 1) / 2 }, 0);\n        if (frames[to] && frames[from]) {\n          timeline\n            .to(frames[to], { clipPath: OPEN }, 0)\n            .to(framePhotos[to]!, { [shift]: 0 }, 0)\n            .to(framePhotos[from]!, { [shift]: step * 50 }, 0);\n        }\n        rolling.forEach((list) => timeline.to(list[from]!, { yPercent: -step * 110 }, 0).to(list[to]!, { yPercent: 0 }, 0));\n      });\n\n      const togglePause = contextSafe(() => {\n        userPaused = !userPaused;\n        setPaused(userPaused);\n        if (userPaused) timer?.pause();\n        else if (inView) timer?.resume();\n      });\n\n      api.current = { go, togglePause };\n      startClock();\n\n      // The wheel and touch swipes. wheelSpeed -1 makes wheel-down and swipe-up both \"up\" (next).\n      // One change per gesture: a trackpad keeps sending wheel events long after the flick, so the\n      // next change waits until the wheel has been still for a moment.\n      let observer: Observer | null = null;\n      if (controls !== \"buttons\" && count > 1) {\n        const fromGesture = (step: Step) => {\n          if (!gestureReady || busy) return;\n          gestureReady = false;\n          go(step);\n        };\n        observer = Observer.create({\n          target: box,\n          type: \"wheel,touch\",\n          wheelSpeed: -1,\n          tolerance: 10,\n          preventDefault: true,\n          ignore: box.querySelectorAll(\"[data-clip-ui]\"),\n          onUp: () => fromGesture(1),\n          onDown: () => fromGesture(-1),\n          onLeft: () => fromGesture(1),\n          onRight: () => fromGesture(-1),\n          onStopDelay: 0.18,\n          onStop: () => {\n            gestureReady = true;\n          },\n          onRelease: () => {\n            gestureReady = true;\n          },\n        });\n      }\n\n      // Arrow keys work when focus is inside the slider, or on the page itself while the slider fills\n      // most of the screen (so a full-screen slider answers the keyboard straight away). From the page,\n      // PageUp/PageDown are left alone, and on the first or last slide the arrows hand back to the page\n      // when it can scroll, so a keyboard user is never stuck on a hero slider that loops forever.\n      const onKeyDown = (event: KeyboardEvent) => {\n        if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;\n        const target = event.target instanceof Element ? event.target : null;\n        const inside = target !== null && box.contains(target);\n        const onPage = target === null || target === document.body || target === document.documentElement;\n        if (!inside && !(onPage && inView)) return;\n        if (target?.closest(\"input, textarea, select, [contenteditable]\")) return;\n        const forward = inside ? [\"ArrowDown\", \"ArrowRight\", \"PageDown\"] : [\"ArrowDown\", \"ArrowRight\"];\n        const back = inside ? [\"ArrowUp\", \"ArrowLeft\", \"PageUp\"] : [\"ArrowUp\", \"ArrowLeft\"];\n        const step: Step | 0 = forward.includes(event.key) ? 1 : back.includes(event.key) ? -1 : 0;\n        if (!step) return;\n        if (!inside) {\n          const atEnd = step === 1 ? current === count - 1 : current === 0;\n          const pageScrolls = document.documentElement.scrollHeight > window.innerHeight + 1;\n          if (atEnd && pageScrolls) return;\n        }\n        event.preventDefault();\n        go(step);\n      };\n      window.addEventListener(\"keydown\", onKeyDown);\n\n      // Off screen: the autoplay clock stops, and the keys stop answering\n      const visibility = new IntersectionObserver(\n        ([entry]) => {\n          inView = (entry?.intersectionRatio ?? 1) >= 0.5;\n          if (!inView) timer?.pause();\n          else if (!userPaused) timer?.resume();\n        },\n        { threshold: [0, 0.5, 1] },\n      );\n      visibility.observe(box);\n\n      return () => {\n        observer?.kill();\n        window.removeEventListener(\"keydown\", onKeyDown);\n        visibility.disconnect();\n        timer?.kill();\n        api.current = null;\n      };\n    },\n    { scope: boxRef, dependencies: [slideKey, count, controls, autoplay, autoplaying, showFrame, showCounter, hasTitles], revertOnUpdate: true },\n  );\n\n  const showButtons = controls !== \"wheel\" && count > 1;\n  const vertical = direction === \"vertical\";\n  const current = slides[index];\n\n  return (\n    <section\n      ref={boxRef}\n      aria-roledescription=\"carousel\"\n      aria-label={label}\n      className={`relative overflow-hidden bg-neutral-950 text-white select-none ${className}`}\n      style={{ containerType: \"inline-size\", touchAction: controls === \"buttons\" ? \"auto\" : \"none\" }}\n    >\n      {/* isolate keeps the slides' growing z-index inside this layer, under the frame and the text */}\n      <div className=\"absolute inset-0 isolate\">\n        {slides.map((slide, i) => (\n        <div\n          key={`${i}-${slide.src}`}\n          data-clip-slide=\"\"\n          role=\"group\"\n          aria-roledescription=\"slide\"\n          aria-label={`${i + 1} of ${count}`}\n          aria-hidden={i !== index}\n          className=\"absolute inset-0 overflow-hidden\"\n          style={{ clipPath: i === 0 ? OPEN : closedAt.bottom, visibility: i === 0 ? \"visible\" : \"hidden\", willChange: \"clip-path\" }}\n        >\n          <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\" />\n        </div>\n        ))}\n      </div>\n\n      {/* Shade on the top, left and bottom so overlaid text reads on any photo, even a pale sky */}\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 z-[1]\"\n        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%)\" }}\n      />\n\n      {showFrame && count > 0 ? (\n        // The framed copy is decoration: the full-bleed photo already carries the alt text\n        <div\n          aria-hidden=\"true\"\n          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)]\"\n          style={{ height: \"min(max(225px, 27.5cqw), 64%)\", aspectRatio: \"4 / 5\" }}\n        >\n          {slides.map((slide, i) => (\n            <div\n              key={`${i}-${slide.src}`}\n              data-clip-frame=\"\"\n              className=\"absolute inset-0 overflow-hidden\"\n              style={{ clipPath: i === 0 ? OPEN : closedAt.top, visibility: i === 0 ? \"visible\" : \"hidden\", willChange: \"clip-path\" }}\n            >\n              <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\" />\n            </div>\n          ))}\n        </div>\n      ) : null}\n\n      {children ? <div className=\"pointer-events-none absolute inset-0 z-[3] [&_a]:pointer-events-auto [&_button]:pointer-events-auto\">{children}</div> : null}\n\n      <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)]\">\n        {/* Every title sits in the same grid cell, so the line is as wide as the longest one */}\n        {hasTitles ? (\n          <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]\">\n            {slides.map((slide, i) => (\n              <span key={`${i}-${slide.src}`} data-clip-title=\"\" className=\"truncate [grid-area:1/1]\" style={{ willChange: \"transform\" }}>\n                {slide.title ?? \"\"}\n              </span>\n            ))}\n          </p>\n        ) : (\n          <span />\n        )}\n\n        <div className=\"flex shrink-0 items-center gap-4\">\n          {showCounter && count > 0 ? (\n            <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)]\">\n              <span className=\"grid h-4 overflow-hidden\">\n                {slides.map((slide, i) => (\n                  <span key={`${i}-${slide.src}`} data-clip-number=\"\" className=\"[grid-area:1/1]\" style={{ willChange: \"transform\" }}>\n                    {pad(i + 1)}\n                  </span>\n                ))}\n              </span>\n              <span className=\"opacity-60\">/</span>\n              <span className=\"opacity-60\">{pad(count)}</span>\n            </p>\n          ) : null}\n\n          {autoplaying ? (\n            <button\n              type=\"button\"\n              data-clip-ui=\"\"\n              onClick={() => api.current?.togglePause()}\n              aria-label={paused ? \"Play slideshow\" : \"Pause slideshow\"}\n              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\"\n            >\n              <span className=\"relative h-px w-12 overflow-hidden bg-white/30\">\n                <span ref={progressRef} className=\"absolute inset-0 origin-left scale-x-0 bg-white\" />\n              </span>\n              <span className=\"w-[3.2em] text-left opacity-80\">{paused ? \"Play\" : \"Pause\"}</span>\n            </button>\n          ) : null}\n\n          {count > 1 ? (\n            // With wheel-only controls the buttons stay hidden until a keyboard user tabs to them\n            <div data-clip-ui=\"\" className={`flex gap-2 ${showButtons ? \"\" : \"opacity-0 focus-within:opacity-100\"}`}>\n              {([-1, 1] as const).map((step) => (\n                <button\n                  key={step}\n                  type=\"button\"\n                  onClick={() => api.current?.go(step)}\n                  aria-label={step === 1 ? \"Next slide\" : \"Previous slide\"}\n                  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\"}`}\n                >\n                  <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)` }}>\n                    <path d=\"M2 8h11M9 4l4 4-4 4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                  </svg>\n                </button>\n              ))}\n            </div>\n          ) : null}\n        </div>\n      </div>\n\n      <p className=\"sr-only\" aria-live={autoplaying && !paused ? \"off\" : \"polite\"} aria-atomic=\"true\">\n        {current ? `Slide ${index + 1} of ${count}${current.title ? `: ${current.title}` : \"\"}` : \"\"}\n      </p>\n    </section>\n  );\n}\n"}],"categories":["gallery","gsap","clip-path","slider","hero","wheel"],"docs":"import { ClipSlider } from \"@/components/dimaac/clip-slider\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/clip-slider","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/clip-slider","credits":[{"by":"Viri Gutiérrez","url":"https://www.lummi.ai/photo/an-empty-snowy-mountain-with-pink-sunrise-on-the-hori-270fdea6-887e-4d89-8205-3eab907f3fb0-HgoCN","what":"Alpine Dawn Serenity","source":"Lummi","licence":"Lummi License"},{"by":"Pablo Stanley","url":"https://www.lummi.ai/photo/desert-dunes-and-mountain-range-bfczd","what":"Desert Dunes and Mountain Range","source":"Lummi","licence":"Lummi License"},{"by":"Binks AI","url":"https://www.lummi.ai/photo/serene-minimalist-scene-with-palm-trees-pba-c","what":"Serene Minimalist Scene with Palm Trees","source":"Lummi","licence":"Lummi License"},{"by":"Mikiwa","url":"https://www.lummi.ai/photo/serene-ocean-aerial-view-kjab6","what":"Serene Ocean Aerial View","source":"Lummi","licence":"Lummi License"},{"by":"alexaperios","url":"https://www.lummi.ai/photo/solitary-tree-in-desert-wsyns","what":"Solitary Tree in Desert","source":"Lummi","licence":"Lummi License"},{"by":"Ricardo Matos","url":"https://www.lummi.ai/photo/minimalist-urban-design-abiri","what":"Minimalist Urban Design","source":"Lummi","licence":"Lummi License"}]}}