{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "shutter-slider",
  "type": "registry:block",
  "title": "Shutter Slider",
  "description": "An autoplaying feature slider. Each image opens through vertical slats that alternate from the top and bottom, the headline letters roll in like drum faces, copy lines rise through masks and the caption pulls into focus — all mirrored when you travel back.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/shutter-slider.tsx",
      "type": "registry:component",
      "target": "components/tweenui/shutter-slider.tsx",
      "content": "'use client';\n\nimport { useRef, type ComponentPropsWithoutRef } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { CustomEase } from 'gsap/CustomEase';\nimport { SplitText } from 'gsap/SplitText';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, CustomEase, SplitText);\nif (!CustomEase.get('shutter-out')) CustomEase.create('shutter-out', '0.16,1,0.3,1');\nif (!CustomEase.get('shutter-in')) CustomEase.create('shutter-in', '0.6,0,0.9,0.35');\nif (!CustomEase.get('shutter-in-out')) CustomEase.create('shutter-in-out', '0.72,0,0.18,1');\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n/** Hover pauses autoplay — only where there is a real hover. */\nconst canHover = () =>\n  typeof window !== 'undefined' && window.matchMedia('(hover: hover) and (pointer: fine)').matches;\n\n/** Vertical slats the incoming image opens through. */\nconst SLATS = 6;\n\nconst pad = (n: number) => String(n).padStart(2, '0');\n\nexport interface ShutterSlide {\n  /** Large serif title above the image. */\n  heading: string;\n  /** Image URL. Any aspect works — it is cropped to a 2:1 frame. */\n  image: string;\n  /** Alt text for the image. Empty marks it decorative. */\n  imageAlt?: string;\n  /** Paragraph under the pagination. */\n  description: string;\n  /** Short uppercase line at the bottom. */\n  caption?: string;\n}\n\nexport interface ShutterSliderProps extends ComponentPropsWithoutRef<'section'> {\n  /** Slides, in order. Defaults to a three-slide sample. */\n  slides?: ShutterSlide[];\n  /** Seconds each slide holds before advancing. `0` turns autoplay off. */\n  autoplay?: number;\n}\n\nconst unsplash = (id: string) =>\n  `https://images.unsplash.com/${id}?w=1600&h=800&fit=crop&auto=format&q=80`;\n\nconst DEFAULT_SLIDES: ShutterSlide[] = [\n  {\n    heading: 'Light first',\n    image: unsplash('photo-1512917774080-9991f1c4c750'),\n    imageAlt: 'A white modern house with floor-to-ceiling glass, a palm and a pool in the sun',\n    description:\n      'Every room is planned around the path of the sun, so mornings start bright and evenings stay warm without reaching for a switch.',\n    caption: 'Designed around daylight',\n  },\n  {\n    heading: 'Room to breathe',\n    image: unsplash('photo-1600607687939-ce8a6c25118c'),\n    imageAlt: 'An open-plan living room flowing into a kitchen under a high white ceiling',\n    description:\n      'Open plans, tall ceilings and quiet corners give every home the space to gather, to work and to slow down.',\n    caption: 'Space that adapts',\n  },\n  {\n    heading: 'Built to last',\n    image: unsplash('photo-1600585154340-be6161a56a0c'),\n    imageAlt: 'A timber-clad house lit warmly from inside at dusk, under a large tree',\n    description:\n      'Stone, timber and lime plaster, chosen to age gracefully and hold their character for decades.',\n    caption: 'Honest materials',\n  },\n];\n\ninterface SlideParts {\n  slide: HTMLElement;\n  heading: HTMLElement;\n  copy: HTMLElement;\n  caption: HTMLElement | null;\n  base: HTMLElement;\n  shutter: HTMLElement;\n  slats: HTMLElement[];\n  slatImgs: HTMLElement[];\n  chars: Element[];\n  lines: Element[];\n  capChars: Element[];\n  lineSplit: SplitText | null;\n}\n\nconst LINE_SPLIT = { type: 'lines', linesClass: 'ss-line', mask: 'lines' } as const;\nconst CHAR_SPLIT = { type: 'words,chars', wordsClass: 'ss-word', charsClass: 'ss-char' } as const;\n\n/** Resting pose: everything in place, base image showing, shutter parked. */\nfunction settle(p: SlideParts) {\n  gsap.set(p.chars, { rotationX: 0, opacity: 1 });\n  gsap.set(p.lines, { yPercent: 0, rotation: 0 });\n  gsap.set(p.capChars, { opacity: 1, yPercent: 0 });\n  if (p.caption) gsap.set(p.caption, { filter: 'none' });\n  gsap.set(p.base, { autoAlpha: 1, scale: 1, filter: 'none' });\n  gsap.set(p.shutter, { autoAlpha: 0 });\n}\n\n/** \"About to enter\" pose, mirrored for `dir` (1 = next, -1 = previous). */\nfunction prime(p: SlideParts, dir: number) {\n  // Letters turn around an axis behind them, like faces on a drum.\n  const depth = parseFloat(getComputedStyle(p.heading).fontSize || '16') * -0.4;\n  gsap.set(p.chars, { rotationX: -95 * dir, opacity: 0, transformOrigin: `50% 50% ${depth}px` });\n  gsap.set(p.lines, { yPercent: 105 * dir, rotation: 2.5 * dir, transformOrigin: '0% 100%' });\n  gsap.set(p.capChars, { opacity: 0, yPercent: 40 * dir });\n  if (p.caption) gsap.set(p.caption, { filter: 'blur(6px)' });\n  gsap.set(p.base, { autoAlpha: 0, scale: 1, filter: 'brightness(1)' });\n  gsap.set(p.shutter, { autoAlpha: 1 });\n  // Alternate slats open from opposite ends — swapped when travelling back.\n  gsap.set(p.slats, {\n    clipPath: (i: number) =>\n      (i % 2 === 0) === dir > 0 ? 'inset(100% 0% 0% 0%)' : 'inset(0% 0% 100% 0%)',\n  });\n  gsap.set(p.slatImgs, { scale: 1.25, yPercent: (i: number) => (i % 2 ? -6 : 6) * dir });\n}\n\nfunction enter(p: SlideParts, dir: number) {\n  const from = dir > 0 ? 'start' : 'end';\n  return (\n    gsap\n      .timeline()\n      // Slats open in a wave from the side we're travelling toward.\n      .to(\n        p.slats,\n        {\n          clipPath: 'inset(0% 0% 0% 0%)',\n          duration: 1.1,\n          ease: 'shutter-in-out',\n          stagger: { each: 0.07, from },\n        },\n        0\n      )\n      .to(\n        p.slatImgs,\n        {\n          scale: 1,\n          yPercent: 0,\n          duration: 1.5,\n          ease: 'shutter-out',\n          stagger: { each: 0.07, from },\n        },\n        0\n      )\n      // Hand over to the single base image so no slat seams remain at rest.\n      .set(p.base, { autoAlpha: 1 })\n      .set(p.shutter, { autoAlpha: 0 })\n      .to(\n        p.chars,\n        {\n          rotationX: 0,\n          opacity: 1,\n          duration: 1,\n          ease: 'shutter-out',\n          stagger: { each: 0.03, from },\n        },\n        0.3\n      )\n      .to(\n        p.lines,\n        { yPercent: 0, rotation: 0, duration: 1, ease: 'shutter-out', stagger: 0.08 },\n        0.45\n      )\n      // Caption pulls focus from the centre outward.\n      .to(\n        p.capChars,\n        {\n          opacity: 1,\n          yPercent: 0,\n          duration: 0.8,\n          ease: 'shutter-out',\n          stagger: { each: 0.012, from: 'center' },\n        },\n        0.65\n      )\n      .to(p.caption, { filter: 'blur(0px)', duration: 0.9, ease: 'shutter-out' }, 0.65)\n  );\n}\n\nfunction exit(p: SlideParts, dir: number) {\n  const from = dir > 0 ? 'start' : 'end';\n  return (\n    gsap\n      .timeline()\n      .to(\n        p.chars,\n        {\n          rotationX: 95 * dir,\n          opacity: 0,\n          duration: 0.55,\n          ease: 'shutter-in',\n          stagger: { each: 0.02, from },\n        },\n        0\n      )\n      .to(\n        p.lines,\n        {\n          yPercent: -105 * dir,\n          rotation: -1.5 * dir,\n          duration: 0.5,\n          ease: 'shutter-in',\n          stagger: 0.05,\n        },\n        0\n      )\n      .to(\n        p.capChars,\n        { opacity: 0, duration: 0.3, ease: 'none', stagger: { each: 0.008, from: 'edges' } },\n        0\n      )\n      // The old picture sinks back and dims under the incoming slats.\n      .to(\n        p.base,\n        { scale: 1.12, filter: 'brightness(0.55)', duration: 1.3, ease: 'shutter-in-out' },\n        0\n      )\n  );\n}\n\nfunction Chevron({ back }: { back?: boolean }) {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      className={cn('size-4', back && 'rotate-180')}\n      fill=\"none\"\n      aria-hidden=\"true\"\n    >\n      <path\n        d=\"m6 3.5 4.5 4.5L6 12.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.4\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nexport default function ShutterSlider({\n  slides = DEFAULT_SLIDES,\n  autoplay = 6,\n  className,\n  'aria-label': ariaLabel = 'Highlights',\n  ...props\n}: ShutterSliderProps) {\n  const rootRef = useRef<HTMLElement>(null);\n  const total = slides.length;\n\n  useGSAP(\n    (_context, contextSafe) => {\n      const root = rootRef.current;\n      if (!root || !total) return;\n\n      const reduced = prefersReducedMotion();\n      const disposers: (() => void)[] = [];\n      let cancelled = false;\n\n      const init = contextSafe!(() => {\n        if (cancelled) return;\n\n        const q = <T extends HTMLElement>(sel: string, scope: ParentNode = root) =>\n          scope.querySelector<T>(sel)!;\n        const currentEl = q('[data-shutter-count=\"current\"]');\n        const nextEl = q('[data-shutter-count=\"next\"]');\n        const fill = q('[data-shutter-progress]');\n\n        const parts: SlideParts[] = gsap.utils\n          .toArray<HTMLElement>('[data-shutter-slide]', root)\n          .map((slide) => {\n            const heading = q('[data-shutter-heading]', slide);\n            const copy = q('[data-shutter-copy]', slide);\n            const caption = slide.querySelector<HTMLElement>('[data-shutter-caption]');\n            // Nothing animates under reduced motion, so the text is left unsplit.\n            const lineSplit = reduced ? null : SplitText.create(copy, LINE_SPLIT);\n            return {\n              slide,\n              heading,\n              copy,\n              caption,\n              base: q('[data-shutter-base]', slide),\n              shutter: q('[data-shutter-slats]', slide),\n              slats: gsap.utils.toArray<HTMLElement>('[data-shutter-slat]', slide),\n              slatImgs: gsap.utils.toArray<HTMLElement>('[data-shutter-slat] > img', slide),\n              chars: reduced ? [] : SplitText.create(heading, CHAR_SPLIT).chars,\n              lines: lineSplit?.lines ?? [],\n              capChars: caption && !reduced ? SplitText.create(caption, CHAR_SPLIT).chars : [],\n              lineSplit,\n            };\n          });\n\n        let index = 0;\n        let busy = false;\n        let revealed = false;\n        let inView = typeof IntersectionObserver === 'undefined';\n        let hovering = false;\n        let clock: gsap.core.Tween | null = null;\n\n        // All slides share one grid cell, so switching is only visibility —\n        // the section keeps the tallest slide's height and never jumps.\n        const show = (p: SlideParts, on: boolean) =>\n          gsap.set(p.slide, { visibility: on ? 'visible' : 'hidden', zIndex: on ? 1 : 'auto' });\n        parts.forEach((p, i) => show(p, i === 0));\n\n        // Odometer: the old number rolls out as the new one rolls in.\n        const roll = (el: HTMLElement, value: number, dir: number, instant?: boolean) => {\n          const old = el.lastElementChild as HTMLElement | null;\n          const next = document.createElement('span');\n          next.textContent = pad(value);\n          el.appendChild(next);\n          if (instant || !old || reduced) return old?.remove();\n          gsap.fromTo(\n            next,\n            { yPercent: 100 * dir },\n            { yPercent: 0, duration: 0.7, ease: 'shutter-out' }\n          );\n          gsap.to(old, {\n            yPercent: -100 * dir,\n            duration: 0.7,\n            ease: 'shutter-out',\n            onComplete: () => old.remove(),\n          });\n        };\n        const counters = (dir: number, instant?: boolean) => {\n          roll(currentEl, index + 1, dir, instant);\n          roll(nextEl, ((index + 1) % total) + 1, dir, instant);\n        };\n        currentEl.textContent = '';\n        nextEl.textContent = '';\n        counters(1, true);\n\n        const go = (dir: number) => {\n          if (busy || total < 2) return;\n          busy = true;\n          const out = parts[index];\n          index = (index + dir + total) % total;\n          const inn = parts[index];\n          counters(dir);\n          root.setAttribute('data-index', String(index));\n\n          show(inn, true);\n          gsap.set(out.slide, { zIndex: 0 });\n\n          if (reduced) {\n            settle(inn);\n            show(out, false);\n            busy = false;\n            return;\n          }\n\n          prime(inn, dir);\n          gsap\n            .timeline({\n              onComplete: () => {\n                show(out, false);\n                gsap.set(inn.slide, { zIndex: 1 });\n                busy = false;\n              },\n            })\n            .add(exit(out, dir), 0)\n            .add(enter(inn, dir), 0.15);\n        };\n\n        // Autoplay: the progress line is the clock.\n        const stop = () => {\n          clock?.kill();\n          clock = null;\n          gsap.set(fill, { scaleX: 0 });\n        };\n        const start = () => {\n          stop();\n          if (reduced || autoplay <= 0 || total < 2 || !inView || document.hidden) return;\n          clock = gsap.fromTo(\n            fill,\n            { scaleX: 0 },\n            {\n              scaleX: 1,\n              duration: autoplay,\n              ease: 'none',\n              paused: hovering,\n              onComplete: () => {\n                go(1);\n                start();\n              },\n            }\n          );\n        };\n        const step = (dir: number) => {\n          if (busy) return;\n          go(dir);\n          start();\n        };\n\n        const listen = <K extends keyof HTMLElementEventMap>(\n          target: HTMLElement,\n          type: K,\n          fn: (event: HTMLElementEventMap[K]) => void\n        ) => {\n          const handler = contextSafe!(fn as (event: Event) => void);\n          target.addEventListener(type, handler as EventListener);\n          disposers.push(() => target.removeEventListener(type, handler as EventListener));\n        };\n\n        listen(q('[data-shutter-prev]'), 'click', () => step(-1));\n        listen(q('[data-shutter-next]'), 'click', () => step(1));\n        listen(root, 'keydown', (event) => {\n          if (event.key === 'ArrowLeft') step(-1);\n          if (event.key === 'ArrowRight') step(1);\n        });\n\n        // Horizontal swipe on touch and pen.\n        let sx = 0;\n        let sy = 0;\n        listen(root, 'pointerdown', (event) => {\n          sx = event.clientX;\n          sy = event.clientY;\n        });\n        listen(root, 'pointerup', (event) => {\n          if (event.pointerType === 'mouse') return;\n          const dx = event.clientX - sx;\n          const dy = event.clientY - sy;\n          if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) step(dx < 0 ? 1 : -1);\n        });\n\n        if (canHover()) {\n          listen(root, 'pointerenter', () => {\n            hovering = true;\n            clock?.pause();\n          });\n          listen(root, 'pointerleave', () => {\n            hovering = false;\n            clock?.resume();\n          });\n        }\n\n        const onVisibility = contextSafe!(() => (document.hidden ? stop() : start()));\n        document.addEventListener('visibilitychange', onVisibility);\n        disposers.push(() => document.removeEventListener('visibilitychange', onVisibility));\n\n        // Copy lines depend on the width they were split at: re-split after a\n        // resize so each line mask matches the real wrapping again.\n        let splitWidth = root.offsetWidth;\n        let resizeTimer: ReturnType<typeof setTimeout> | undefined;\n        const resplit = contextSafe!(() => {\n          clearTimeout(resizeTimer);\n          resizeTimer = setTimeout(\n            contextSafe!(() => {\n              if (reduced || root.offsetWidth === splitWidth) return;\n              if (busy) return resplit();\n              splitWidth = root.offsetWidth;\n              parts.forEach((p, i) => {\n                p.lineSplit?.revert();\n                p.lineSplit = SplitText.create(p.copy, LINE_SPLIT);\n                p.lines = p.lineSplit.lines;\n                const shown = i === index && revealed;\n                gsap.set(\n                  p.lines,\n                  shown\n                    ? { yPercent: 0 }\n                    : { yPercent: 105, rotation: 2.5, transformOrigin: '0% 100%' }\n                );\n              });\n            }),\n            150\n          );\n        });\n        const resizeObserver = new ResizeObserver(resplit);\n        resizeObserver.observe(root);\n        disposers.push(() => {\n          resizeObserver.disconnect();\n          clearTimeout(resizeTimer);\n        });\n\n        // First slide plays in the first time the slider is on screen.\n        const first = parts[0];\n        const reveal = () => {\n          if (revealed) return;\n          revealed = true;\n          if (reduced) settle(first);\n          else enter(first, 1).delay(0.2);\n        };\n\n        if (reduced) settle(first);\n        else prime(first, 1);\n        parts.slice(1).forEach((p) => (reduced ? settle(p) : prime(p, 1)));\n        root.setAttribute('data-ready', '');\n\n        if (typeof IntersectionObserver === 'undefined') {\n          reveal();\n          start();\n        } else {\n          const io = new IntersectionObserver(\n            contextSafe!(([entry]: IntersectionObserverEntry[]) => {\n              inView = entry.isIntersecting;\n              if (!inView) return stop();\n              reveal();\n              start();\n            }),\n            { threshold: 0.25 }\n          );\n          io.observe(root);\n          disposers.push(() => io.disconnect());\n        }\n\n        disposers.push(() => clock?.kill());\n      });\n\n      // Split only once web fonts are in, so line breaks match the final text;\n      // give up after a beat so a slow font can't hold the slider hidden.\n      const fonts = typeof document !== 'undefined' ? document.fonts?.ready : undefined;\n      if (fonts) Promise.race([fonts, new Promise((r) => setTimeout(r, 1500))]).then(init);\n      else init();\n\n      return () => {\n        cancelled = true;\n        disposers.forEach((dispose) => dispose());\n        root.removeAttribute('data-ready');\n      };\n    },\n    { scope: rootRef, dependencies: [slides, autoplay] }\n  );\n\n  return (\n    <section\n      ref={rootRef}\n      data-shutter-slider\n      data-index=\"0\"\n      aria-roledescription=\"carousel\"\n      aria-label={ariaLabel}\n      className={cn(\n        'group/shutter relative overflow-hidden bg-[#eef1f4] px-6 py-20 text-[#1b2a41] md:px-10 md:py-28 dark:bg-[#15130f] dark:text-[#efe6d8]',\n        className\n      )}\n      {...props}\n    >\n      <div className=\"mx-auto grid max-w-[1200px] grid-cols-10 gap-x-4\">\n        <div className=\"relative col-span-10 flex flex-col justify-center md:col-span-8 md:col-start-2\">\n          {/* Mirrors a slide's middle row so the pagination lands just under the image. */}\n          <div className=\"pointer-events-none absolute inset-x-0 top-1/2 z-10 flex -translate-y-1/2 flex-col items-center\">\n            <div className=\"h-16 shrink-0\" />\n            <div aria-hidden=\"true\" className=\"aspect-[2/1] w-[min(100%,50rem)] shrink-0\" />\n            <div className=\"h-4 shrink-0\" />\n            <div\n              className={cn(\n                'pointer-events-auto flex h-12 items-center justify-center gap-8 text-xs text-[#a9532f] dark:text-[#e39468]',\n                total < 2 && 'hidden'\n              )}\n            >\n              <button\n                type=\"button\"\n                data-shutter-prev\n                aria-label=\"Previous slide\"\n                className=\"flex h-12 cursor-pointer items-center gap-4 pl-2 transition-opacity hover:opacity-60 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-current motion-reduce:transition-none\"\n              >\n                <Chevron back />\n                <span\n                  data-shutter-count=\"current\"\n                  aria-hidden=\"true\"\n                  className=\"grid h-[1em] w-6 overflow-clip text-center leading-none tabular-nums [&>span]:[grid-area:1/1]\"\n                >\n                  {pad(1)}\n                </span>\n              </button>\n              <div className=\"h-px w-40 bg-[#1b2a41]/15 dark:bg-[#efe6d8]/15\">\n                <div\n                  data-shutter-progress\n                  className=\"h-full w-full origin-left scale-x-0 bg-current\"\n                />\n              </div>\n              <button\n                type=\"button\"\n                data-shutter-next\n                aria-label=\"Next slide\"\n                className=\"flex h-12 cursor-pointer items-center gap-4 pr-2 transition-opacity hover:opacity-60 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-current motion-reduce:transition-none\"\n              >\n                <span\n                  data-shutter-count=\"next\"\n                  aria-hidden=\"true\"\n                  className=\"grid h-[1em] w-6 overflow-clip text-center leading-none tabular-nums [&>span]:[grid-area:1/1]\"\n                >\n                  {pad(Math.min(2, total))}\n                </span>\n                <Chevron />\n              </button>\n            </div>\n          </div>\n\n          {/* Hidden until GSAP has posed the first slide, so nothing flashes in place. */}\n          <div className=\"grid opacity-0 group-data-[ready]/shutter:opacity-100\">\n            {slides.map((slide, i) => (\n              <div\n                key={`${slide.heading}-${i}`}\n                data-shutter-slide\n                role=\"group\"\n                aria-roledescription=\"slide\"\n                aria-label={`${i + 1} of ${total}`}\n                className={cn(\n                  'grid min-w-0 grid-rows-[1fr_auto_1fr] [grid-area:1/1]',\n                  i > 0 && 'invisible'\n                )}\n              >\n                <div className=\"flex flex-col justify-end pb-8\">\n                  <h3\n                    data-shutter-heading\n                    className=\"text-center font-serif text-[clamp(2.5rem,6.25vw,6rem)] leading-[0.9] font-normal tracking-tight uppercase [&_.ss-char]:inline-block [&_.ss-char]:will-change-transform [&_.ss-char]:backface-hidden motion-reduce:[&_.ss-char]:will-change-auto [&_.ss-word]:inline-block [&_.ss-word]:perspective-[700px]\"\n                  >\n                    {slide.heading}\n                  </h3>\n                </div>\n\n                <div className=\"flex flex-col items-center py-4\">\n                  {/* clip-path as well as overflow: WebKit can let composited\n                      (scaled, will-change) children escape overflow clipping. */}\n                  <div className=\"relative aspect-[2/1] w-[min(100%,50rem)] shrink-0 overflow-clip [clip-path:inset(0)]\">\n                    <img\n                      data-shutter-base\n                      src={slide.image}\n                      alt={slide.imageAlt ?? ''}\n                      className=\"absolute inset-0 size-full max-w-none object-cover\"\n                      draggable={false}\n                    />\n                    <div\n                      data-shutter-slats\n                      aria-hidden=\"true\"\n                      className=\"pointer-events-none invisible absolute inset-0\"\n                    >\n                      {Array.from({ length: SLATS }, (_, s) => (\n                        <div\n                          key={s}\n                          data-shutter-slat\n                          className=\"absolute inset-y-0 overflow-clip\"\n                          style={{ left: `${(s * 100) / SLATS}%`, width: `${100 / SLATS}%` }}\n                        >\n                          {/* A full-frame copy shifted into place, so the slats read as one picture. */}\n                          <img\n                            src={slide.image}\n                            alt=\"\"\n                            className=\"absolute top-0 h-full max-w-none object-cover will-change-transform motion-reduce:will-change-auto\"\n                            style={{ width: `${SLATS * 100}%`, left: `${-s * 100}%` }}\n                            draggable={false}\n                          />\n                        </div>\n                      ))}\n                    </div>\n                  </div>\n                </div>\n\n                {/* pt-16 = pagination (h-12) + the gap under it. */}\n                <div className=\"flex flex-col pt-16\">\n                  <p\n                    data-shutter-copy\n                    className=\"mx-auto max-w-[36rem] text-center text-base leading-normal [&_.ss-line]:will-change-transform [&_.ss-line]:backface-hidden motion-reduce:[&_.ss-line]:will-change-auto\"\n                  >\n                    {slide.description}\n                  </p>\n                  {slide.caption && (\n                    <p\n                      data-shutter-caption\n                      className=\"mx-auto mt-6 max-w-[18rem] text-center text-xs leading-snug font-semibold tracking-[0.08em] uppercase [&_.ss-char]:inline-block [&_.ss-word]:inline-block\"\n                    >\n                      {slide.caption}\n                    </p>\n                  )}\n                </div>\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/shutter-slider\nGSAP plugins (CustomEase, SplitText) are registered inside the component, so there is nothing to set up.\nThe sample content loads images from images.unsplash.com. Replace them with your own before shipping."
}