{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tab-wipe",
  "type": "registry:block",
  "title": "Tab Wipe",
  "description": "A centered screenshot stage that wipes between slides on a clip-path edge while the incoming shot settles out of a slight scale and drift. The thumbnail strip is a real tablist, and the stage autoplays between clicks.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/tab-wipe.tsx",
      "type": "registry:component",
      "target": "components/tweenui/tab-wipe.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 { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, CustomEase);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst DURATION = 1.15;\nconst AUTO_DELAY = 4;\nconst FROM_SCALE = 1.1;\nconst EMPTY_CLIP = 'polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)';\n\n/** The wipe edge, travelling down from the top or up from the bottom. */\nconst clipPolygon = (progress: number, fromTop: boolean) => {\n  const edge = progress * 100;\n  return fromTop\n    ? `polygon(0% 0%, 100% 0%, 100% ${edge}%, 0% ${edge}%)`\n    : `polygon(0% ${100 - edge}%, 100% ${100 - edge}%, 100% 100%, 0% 100%)`;\n};\n\nexport interface TabWipeSlide {\n  /** Full-bleed image for the stage. */\n  image: string;\n  /** Alt text, also the slide key and the tab's label. */\n  alt: string;\n  /** Thumbnail for the tab strip. Falls back to `image`. */\n  tabImage?: string;\n}\n\nexport interface TabWipeProps extends ComponentPropsWithoutRef<'section'> {\n  /** Slides in the stage. The tab strip mirrors this list. */\n  slides?: TabWipeSlide[];\n}\n\nconst SHOT = (id: string) => `https://images.unsplash.com/${id}?w=1200&h=750&fit=crop`;\n\nconst DEFAULT_SLIDES: TabWipeSlide[] = [\n  { image: SHOT('photo-1551650975-87deedd944c3'), alt: 'Workflow board' },\n  { image: SHOT('photo-1460925895917-afdab827c52f'), alt: 'Performance dashboard' },\n  { image: SHOT('photo-1552664730-d307ca884978'), alt: 'Team planning session' },\n];\n\nexport default function TabWipe({ slides = DEFAULT_SLIDES, className, ...props }: TabWipeProps) {\n  const rootRef = useRef<HTMLElement>(null);\n  const slideRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const mediaRefs = useRef<(HTMLImageElement | null)[]>([]);\n  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);\n\n  useGSAP(\n    () => {\n      const slideEls = slideRefs.current.filter(Boolean) as HTMLDivElement[];\n      const mediaEls = mediaRefs.current.filter(Boolean) as HTMLImageElement[];\n      const tabEls = tabRefs.current.filter(Boolean) as HTMLButtonElement[];\n      if (slideEls.length === 0) return;\n\n      const ease = CustomEase.get('tab-wipe') || CustomEase.create('tab-wipe', '0.625, 0.05, 0, 1');\n\n      let current = 0;\n      let isAnimating = false;\n      let autoCall: gsap.core.Tween | undefined;\n\n      const setTabState = (index: number) => {\n        tabEls.forEach((tab, i) => {\n          const selected = i === index;\n          tab.dataset.state = selected ? 'selected' : '';\n          tab.setAttribute('aria-selected', selected ? 'true' : 'false');\n        });\n      };\n\n      const resetSlide = (index: number, visible: boolean) => {\n        gsap.set(slideEls[index], {\n          zIndex: visible ? 1 : 0,\n          clipPath: visible ? 'none' : EMPTY_CLIP,\n        });\n        gsap.set(mediaEls[index], { scale: 1, yPercent: 0, force3D: true });\n      };\n\n      const applyInstant = (next: number) => {\n        slideEls.forEach((_, i) => resetSlide(i, i === next));\n        current = next;\n        setTabState(current);\n      };\n\n      const restartAuto = () => {\n        autoCall?.kill();\n        autoCall = gsap.delayedCall(AUTO_DELAY, () => goTo((current + 1) % slideEls.length, true));\n      };\n\n      const goTo = (next: number, fromAuto = false) => {\n        if (isAnimating || next === current || next < 0 || next >= slideEls.length) return;\n\n        autoCall?.kill();\n\n        if (prefersReducedMotion()) {\n          applyInstant(next);\n          restartAuto();\n          return;\n        }\n\n        const incoming = slideEls[next];\n        const outgoing = slideEls[current];\n        const incomingMedia = mediaEls[next];\n        const outgoingMedia = mediaEls[current];\n        // Auto-advance always wipes downward; a tab click wipes toward the tab.\n        const fromTop = fromAuto || next > current;\n        const drift = fromTop ? -8 : 8;\n\n        isAnimating = true;\n        const outgoingIndex = current;\n        current = next;\n        setTabState(current);\n\n        gsap.set(outgoing, { zIndex: 1, clipPath: 'none' });\n        gsap.set(incoming, { zIndex: 2, clipPath: clipPolygon(0, fromTop) });\n        gsap.set(incomingMedia, { scale: FROM_SCALE, yPercent: drift, force3D: true });\n        gsap.set(outgoingMedia, { scale: 1, yPercent: 0, force3D: true });\n\n        const proxy = { p: 0 };\n        gsap\n          .timeline({\n            defaults: { duration: DURATION, ease },\n            onComplete: () => {\n              resetSlide(next, true);\n              resetSlide(outgoingIndex, false);\n              isAnimating = false;\n              restartAuto();\n            },\n          })\n          .to(\n            proxy,\n            {\n              p: 1,\n              onUpdate: () => gsap.set(incoming, { clipPath: clipPolygon(proxy.p, fromTop) }),\n            },\n            0\n          )\n          .to(incomingMedia, { scale: 1, yPercent: 0 }, 0)\n          .to(outgoingMedia, { scale: FROM_SCALE, yPercent: -drift * 0.4 }, 0);\n      };\n\n      slideEls.forEach((_, i) => resetSlide(i, i === 0));\n      setTabState(0);\n\n      const handlers = tabEls.map((tab, index) => {\n        const onClick = () => goTo(index);\n        tab.addEventListener('click', onClick);\n        return { tab, onClick };\n      });\n\n      restartAuto();\n\n      return () => {\n        autoCall?.kill();\n        handlers.forEach(({ tab, onClick }) => tab.removeEventListener('click', onClick));\n      };\n    },\n    { scope: rootRef, dependencies: [slides.length] }\n  );\n\n  return (\n    <section\n      ref={rootRef}\n      data-tab-wipe\n      className={cn('w-full px-5 py-14 md:py-16', className)}\n      {...props}\n    >\n      <div className=\"mx-auto w-full max-w-[720px] space-y-4\">\n        <div className=\"rounded-[20px] border border-white/60 bg-white/40 p-2 shadow-[inset_0_1px_0_0_rgba(255,255,255,0.9)] backdrop-blur-[34px] dark:border-white/10 dark:bg-white/5 dark:shadow-none\">\n          <figure className=\"relative aspect-16/10 w-full overflow-hidden rounded-2xl\">\n            {slides.map((slide, index) => (\n              <div\n                key={slide.alt}\n                ref={(node) => {\n                  slideRefs.current[index] = node;\n                }}\n                data-tab-wipe-slide\n                className={cn('absolute inset-0 overflow-hidden', index === 0 && 'z-[1]')}\n                style={index === 0 ? undefined : { clipPath: EMPTY_CLIP }}\n              >\n                <img\n                  ref={(node) => {\n                    mediaRefs.current[index] = node;\n                  }}\n                  src={slide.image}\n                  alt={slide.alt}\n                  loading={index === 0 ? 'eager' : 'lazy'}\n                  className=\"size-full object-cover\"\n                />\n              </div>\n            ))}\n          </figure>\n        </div>\n\n        <div\n          role=\"tablist\"\n          aria-label=\"Choose a screen\"\n          className=\"flex h-[110px] w-full items-center gap-x-1.5 rounded-xl border border-white/60 p-2 dark:border-white/10\"\n        >\n          {slides.map((slide, index) => (\n            <button\n              key={`${slide.alt}-tab`}\n              type=\"button\"\n              role=\"tab\"\n              aria-label={slide.alt}\n              aria-selected={index === 0}\n              data-state={index === 0 ? 'selected' : ''}\n              ref={(node) => {\n                tabRefs.current[index] = node;\n              }}\n              className=\"relative size-full flex-1 cursor-pointer overflow-hidden rounded-md after:absolute after:inset-0 after:bg-white/60 after:opacity-100 after:transition-opacity after:duration-300 after:content-[''] data-[state=selected]:after:opacity-0 motion-reduce:after:transition-none dark:after:bg-black/50\"\n            >\n              <img\n                src={slide.tabImage ?? slide.image}\n                alt=\"\"\n                loading=\"lazy\"\n                className=\"size-full object-cover\"\n              />\n            </button>\n          ))}\n        </div>\n      </div>\n    </section>\n  );\n}\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/tab-wipe\nGSAP plugins (CustomEase) 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."
}