{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "strip-wipe",
  "type": "registry:block",
  "title": "Strip Wipe",
  "description": "A split testimonial slider whose photo wipes in as tiled strips beside the quote, the index ticking over on Number Flow. Prev and next reverse the wipe direction.",
  "dependencies": [
    "gsap",
    "@gsap/react",
    "@number-flow/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/strip-wipe.tsx",
      "type": "registry:component",
      "target": "components/tweenui/strip-wipe.tsx",
      "content": "'use client';\n\nimport { useRef, useState, type ComponentPropsWithoutRef } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport NumberFlow from '@number-flow/react';\nimport gsap from 'gsap';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst TILE_COUNT = 10;\nconst AUTOPLAY_DELAY = 4.8;\n\nexport interface TestimonialSlide {\n  /** Stable id used as the React key. */\n  id: string;\n  /** Photo URL for the tiled slide image. */\n  image: string;\n  /** Alt text for the photo. */\n  imageAlt: string;\n  /** Quote shown beside the photo. */\n  quote: string;\n}\n\nexport type TestimonialSplitFrom = 'left' | 'right';\n\nexport interface StripWipeProps extends Omit<ComponentPropsWithoutRef<'section'>, 'title'> {\n  /** Quotes shown in the split slider. Defaults to a 4-slide sample. */\n  testimonials?: TestimonialSlide[];\n  /**\n   * When true, prev wipes the photo right-to-left and next wipes left-to-right.\n   * When false, every change uses the original right-to-left split.\n   */\n  directional?: boolean;\n}\n\nconst DEFAULT_TESTIMONIALS: TestimonialSlide[] = [\n  {\n    id: 'handoffs',\n    image: 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=800&h=1000&fit=crop',\n    imageAlt: 'Team collaborating around a laptop',\n    quote:\n      'Before using this platform, our team was drowning in repetitive tasks across tools and spreadsheets. After setup, most daily updates now run automatically and our team finally has time for strategic work.',\n  },\n  {\n    id: 'pipeline',\n    image: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=800&h=1000&fit=crop',\n    imageAlt: 'Operator reviewing a live workflow dashboard',\n    quote:\n      'Our automation turned scattered handoffs into one clear pipeline we can monitor end to end. Approvals, status updates, and owner changes now propagate automatically, so delivery stays on track.',\n  },\n  {\n    id: 'stack',\n    image: 'https://images.unsplash.com/photo-1560250097-0b93528c311a?w=800&h=1000&fit=crop',\n    imageAlt: 'Founder speaking about connected tools',\n    quote:\n      'We plugged our CRM, billing, and support stack into the platform in a single afternoon. Triggers and webhooks keep customer records in sync, so ops and finance always see the same live health.',\n  },\n  {\n    id: 'playbooks',\n    image: 'https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=800&h=1000&fit=crop',\n    imageAlt: 'Product lead shipping a no-code playbook',\n    quote:\n      'No-code paths let success teams ship playbooks without waiting on engineering for every tweak. We still drop into advanced rules when we need them, but most lifecycle automation now ships in hours, not sprints.',\n  },\n];\n\nfunction ArrowDotLeftIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n      className={cn('fill-current', className)}\n    >\n      <path d=\"M16 5H14V7H16V5Z\" />\n      <path d=\"M13 8H11V10H13V8Z\" />\n      <path d=\"M10 11H8V13H10V11Z\" />\n      <path d=\"M13 14H11V16H13V14Z\" />\n      <path d=\"M16 17H14V19H16V17Z\" />\n    </svg>\n  );\n}\n\nfunction ArrowDotRightIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n      className={cn('fill-current', className)}\n    >\n      <path d=\"M8 5H10V7H8V5Z\" />\n      <path d=\"M11 8H13V10H11V8Z\" />\n      <path d=\"M14 11H16V13H14V11Z\" />\n      <path d=\"M11 14H13V16H11V14Z\" />\n      <path d=\"M8 17H10V19H8V17Z\" />\n    </svg>\n  );\n}\n\nfunction tileClipPath(index: number) {\n  const overlap = 0.25;\n  const left = index === 0 ? 0 : (index / TILE_COUNT) * 100 - overlap;\n  const right = index === TILE_COUNT - 1 ? 0 : 100 - ((index + 1) / TILE_COUNT) * 100 - overlap;\n  return `inset(0 ${right}% 0 ${left}%)`;\n}\n\nfunction splitFromFor(directional: boolean, direction: 'next' | 'prev'): TestimonialSplitFrom {\n  return directional && direction === 'next' ? 'left' : 'right';\n}\n\nfunction playSlideVisual(slide: HTMLElement, instant: boolean, from: TestimonialSplitFrom) {\n  const tiles = gsap.utils.toArray<HTMLElement>('[data-slide-tile]', slide);\n  const content = slide.querySelector<HTMLElement>('[data-slide-content]');\n  const fromX = from === 'left' ? -26 : 26;\n  const contentX = from === 'left' ? -16 : 16;\n\n  if (instant || prefersReducedMotion()) {\n    gsap.set(tiles, { opacity: 1, x: 0 });\n    if (content) gsap.set(content, { opacity: 1, x: 0 });\n    return;\n  }\n\n  tiles.forEach((tile, i) => {\n    gsap.fromTo(\n      tile,\n      { opacity: 0, x: fromX },\n      {\n        opacity: 1,\n        x: 0,\n        duration: 0.6,\n        delay: from === 'left' ? i * 0.08 : (tiles.length - 1 - i) * 0.08,\n        ease: 'cubic-bezier(0.22, 1, 0.36, 1)',\n      }\n    );\n  });\n\n  if (content) {\n    gsap.fromTo(\n      content,\n      { opacity: 0, x: contentX },\n      { opacity: 1, x: 0, duration: 0.55, delay: 0.15, ease: 'power2.out' }\n    );\n  }\n}\n\nexport default function StripWipe({\n  testimonials = DEFAULT_TESTIMONIALS,\n  directional = true,\n  className,\n  ...props\n}: StripWipeProps) {\n  const rootRef = useRef<HTMLElement>(null);\n  const slideRefs = useRef<Array<HTMLElement | null>>([]);\n  const loopDelayRef = useRef<gsap.core.Tween | null>(null);\n  const pausedRef = useRef(false);\n  const restartLoopRef = useRef<() => void>(() => {});\n  const isFirstSlideRef = useRef(true);\n  const navDirectionRef = useRef<'next' | 'prev'>('next');\n\n  const slideCount = testimonials.length;\n  const [activeIndex, setActiveIndex] = useState(0);\n  const [navDirection, setNavDirection] = useState<'next' | 'prev'>('next');\n  const splitFrom = splitFromFor(directional, navDirection);\n  const flowMs = prefersReducedMotion() ? 0 : 650;\n\n  useGSAP(\n    () => {\n      const slide = slideRefs.current[activeIndex];\n      if (!slide) return;\n      playSlideVisual(\n        slide,\n        isFirstSlideRef.current,\n        splitFromFor(directional, navDirectionRef.current)\n      );\n      isFirstSlideRef.current = false;\n    },\n    { scope: rootRef, dependencies: [activeIndex, slideCount, directional] }\n  );\n\n  useGSAP(\n    (_, contextSafe) => {\n      if (!contextSafe) return;\n\n      if (prefersReducedMotion() || slideCount < 2) {\n        restartLoopRef.current = () => {};\n        return;\n      }\n\n      const schedule = () => {\n        loopDelayRef.current?.kill();\n        loopDelayRef.current = gsap.delayedCall(AUTOPLAY_DELAY, () => {\n          if (!pausedRef.current) {\n            navDirectionRef.current = 'next';\n            setNavDirection('next');\n            setActiveIndex((index) => (index + 1) % slideCount);\n          }\n          schedule();\n        });\n      };\n\n      restartLoopRef.current = contextSafe(() => {\n        if (pausedRef.current) return;\n        schedule();\n      });\n\n      schedule();\n\n      return () => {\n        loopDelayRef.current?.kill();\n      };\n    },\n    { scope: rootRef, dependencies: [slideCount] }\n  );\n\n  const goTo = (index: number, direction: 'next' | 'prev') => {\n    if (slideCount < 1) return;\n    navDirectionRef.current = direction;\n    setNavDirection(direction);\n    setActiveIndex(((index % slideCount) + slideCount) % slideCount);\n    restartLoopRef.current();\n  };\n\n  const pause = () => {\n    pausedRef.current = true;\n    loopDelayRef.current?.pause();\n  };\n\n  const resume = () => {\n    pausedRef.current = false;\n    restartLoopRef.current();\n  };\n\n  const navButtonClass =\n    'inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border border-[#045f64]/20 text-[#045f64] transition-colors hover:border-transparent hover:bg-[#045f64] hover:text-[#c6f56f] focus-visible:ring-2 focus-visible:ring-[#045f64] focus-visible:ring-offset-2 focus-visible:outline-none motion-reduce:transition-none dark:border-[#045f64]/40 dark:text-[#9fd4d6] dark:ring-offset-[#12161F]';\n\n  const nav = (\n    <div className=\"relative flex w-full items-center justify-center\">\n      <div className=\"flex items-center gap-x-3\">\n        <button\n          type=\"button\"\n          aria-label=\"Previous testimonial\"\n          onClick={() => goTo(activeIndex - 1, 'prev')}\n          className={navButtonClass}\n        >\n          <ArrowDotLeftIcon className=\"block size-6\" />\n        </button>\n        <button\n          type=\"button\"\n          aria-label=\"Next testimonial\"\n          onClick={() => goTo(activeIndex + 1, 'next')}\n          className={navButtonClass}\n        >\n          <ArrowDotRightIcon className=\"block size-6\" />\n        </button>\n      </div>\n      <p className=\"absolute right-0 text-[#045f64] dark:text-[#9fd4d6]\">\n        <NumberFlow\n          value={activeIndex + 1}\n          format={{ useGrouping: false, maximumFractionDigits: 0 }}\n          transformTiming={{ duration: flowMs, easing: 'ease-out' }}\n          spinTiming={{ duration: flowMs, easing: 'ease-out' }}\n          opacityTiming={{ duration: prefersReducedMotion() ? 0 : 300, easing: 'ease-out' }}\n        />\n        <span className=\"text-[#045f64]/40 dark:text-[#9fd4d6]/40\">/{slideCount}</span>\n      </p>\n    </div>\n  );\n\n  return (\n    <section\n      ref={rootRef}\n      data-strip-wipe\n      data-active-slide={activeIndex}\n      data-split-from={splitFrom}\n      className={cn('w-full', className)}\n      {...props}\n    >\n      <div\n        className=\"relative mx-auto w-full max-w-[860px] px-3\"\n        onMouseEnter={pause}\n        onMouseLeave={resume}\n      >\n        <div className=\"relative md:h-[357px]\">\n          {testimonials.map((item, index) => (\n            <article\n              key={item.id}\n              ref={(el) => {\n                slideRefs.current[index] = el;\n              }}\n              className={cn(\n                'flex flex-col overflow-hidden rounded-[20px] border border-[#045f64]/15 bg-white p-1 md:absolute md:inset-0 md:flex-row dark:border-[#045f64]/40 dark:bg-[#12161F]',\n                index === activeIndex ? 'relative z-10' : 'hidden'\n              )}\n              aria-hidden={index !== activeIndex}\n            >\n              <figure className=\"relative h-[280px] w-full shrink-0 overflow-hidden rounded-2xl md:h-full md:w-[42%]\">\n                <img\n                  src={item.image}\n                  alt={item.imageAlt}\n                  className=\"size-full object-cover opacity-0\"\n                />\n                <div className=\"pointer-events-none absolute inset-0 overflow-hidden\">\n                  {Array.from({ length: TILE_COUNT }, (_, tileIndex) => (\n                    <span\n                      key={tileIndex}\n                      data-slide-tile\n                      className=\"absolute inset-0 opacity-0 will-change-transform backface-hidden motion-reduce:opacity-100\"\n                      style={{ clipPath: tileClipPath(tileIndex) }}\n                    >\n                      <span\n                        aria-hidden=\"true\"\n                        className=\"absolute inset-0 bg-cover bg-center bg-no-repeat\"\n                        style={{ backgroundImage: `url(\"${item.image}\")` }}\n                      />\n                    </span>\n                  ))}\n                </div>\n              </figure>\n\n              <div\n                data-slide-content\n                className=\"flex w-full flex-col justify-center p-6 opacity-0 motion-reduce:opacity-100 md:w-[58%] md:pb-16\"\n              >\n                <p className=\"text-[#045f64] dark:text-[#9fd4d6]\">&ldquo;{item.quote}&rdquo;</p>\n              </div>\n            </article>\n          ))}\n\n          <div className=\"mt-6 px-1 md:absolute md:right-1 md:bottom-6 md:left-[42%] md:z-20 md:mt-0 md:px-6\">\n            {nav}\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/strip-wipe\nThe sample content loads images from images.unsplash.com. Replace them with your own before shipping."
}