{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "testimonial-line-sweep",
  "type": "registry:block",
  "title": "Testimonial Line Sweep",
  "description": "A paged testimonial pair whose text sweeps out line by line behind masks while the portrait irises shut, then the next quote arrives the same way. Pages with buttons or arrow keys. GSAP + SplitText.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/testimonial-line-sweep.tsx",
      "type": "registry:component",
      "target": "components/tweenui/testimonial-line-sweep.tsx",
      "content": "'use client';\n\nimport { useRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport SplitText from 'gsap/SplitText';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, ScrollTrigger, SplitText);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst CLIP_CLOSED = 'circle(0% at 50% 50%)';\nconst CLIP_OPEN = 'circle(80% at 50% 50%)';\n\nfunction ArrowIcon({ direction, className }: { direction: 'left' | 'right'; className?: string }) {\n  return (\n    <svg viewBox=\"0 0 24 24\" className={className} aria-hidden=\"true\" fill=\"none\">\n      <path\n        d={direction === 'left' ? 'M19 12H5m0 0 6-6m-6 6 6 6' : 'M5 12h14m0 0-6-6m6 6-6 6'}\n        stroke=\"currentColor\"\n        strokeWidth=\"1.6\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction StarIcon({ className }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 24 24\" className={className} aria-hidden=\"true\">\n      <path\n        d=\"M12 2.6l2.9 5.9 6.5.9-4.7 4.6 1.1 6.4-5.8-3-5.8 3 1.1-6.4L2.6 9.4l6.5-.9L12 2.6Z\"\n        fill=\"currentColor\"\n      />\n    </svg>\n  );\n}\n\nexport interface TestimonialLineSweepItem {\n  /** Quote text before the highlighted clause. */\n  quoteStart: string;\n  /** The clause rendered in full contrast. */\n  quoteHighlight: string;\n  /** Quote text after the highlighted clause. */\n  quoteEnd: string;\n  /** Reviewer name. */\n  name: string;\n  /** Role shown under the name. */\n  jobTitle: string;\n  /** Portrait URL. */\n  image: string;\n  /** Alt text for the portrait. */\n  imageAlt?: string;\n}\n\nexport interface TestimonialLineSweepProps extends Omit<\n  ComponentPropsWithoutRef<'section'>,\n  'title'\n> {\n  /** Section heading. */\n  title?: ReactNode;\n  /** Supporting line under the heading. */\n  description?: string;\n  /** Quotes to page through. Shown two at a time from `md` up. */\n  items?: TestimonialLineSweepItem[];\n}\n\nconst DEFAULT_TITLE = 'What our clients say about us';\n\nconst DEFAULT_DESCRIPTION =\n  \"Hear from the people who use our platform every day, and see how it's helping them achieve their goals.\";\n\nconst PORTRAIT = (id: string) => `https://images.unsplash.com/${id}?w=420&h=340&fit=crop`;\n\nconst DEFAULT_ITEMS: TestimonialLineSweepItem[] = [\n  {\n    quoteStart: 'We replaced four tools with one workflow, and',\n    quoteHighlight: 'the team shipped its first release two weeks early.',\n    quoteEnd: 'Nobody has asked to go back.',\n    name: 'Maya Collins',\n    jobTitle: 'Head of Operations',\n    image: PORTRAIT('photo-1494790108377-be9c29b29330'),\n  },\n  {\n    quoteStart: 'Reporting used to take a full day each month.',\n    quoteHighlight: 'Now it runs itself and lands in our inbox.',\n    quoteEnd: 'That time went straight back into the roadmap.',\n    name: 'Ethan Brooks',\n    jobTitle: 'President of Sales',\n    image: PORTRAIT('photo-1519085360753-af0119f7cbe7'),\n  },\n  {\n    quoteStart: 'Onboarding took an afternoon, not a quarter.',\n    quoteHighlight: 'Every handoff is visible to the whole team,',\n    quoteEnd: 'so nothing slips between design and build.',\n    name: 'Liam Harper',\n    jobTitle: 'Product Lead',\n    image: PORTRAIT('photo-1507003211169-0a1dd7228f2d'),\n  },\n  {\n    quoteStart: 'Support tickets dropped by a third in one month.',\n    quoteHighlight: 'Customers find answers before they ask us,',\n    quoteEnd: 'and the team finally has room to think.',\n    name: 'Ava Sinclair',\n    jobTitle: 'Marketing Head',\n    image: PORTRAIT('photo-1534528741775-53994a69daeb'),\n  },\n];\n\nexport default function TestimonialLineSweep({\n  title = DEFAULT_TITLE,\n  description = DEFAULT_DESCRIPTION,\n  items = DEFAULT_ITEMS,\n  className,\n  ...props\n}: TestimonialLineSweepProps) {\n  const rootRef = useRef<HTMLElement>(null);\n\n  useGSAP(\n    () => {\n      const root = rootRef.current;\n      if (!root) return;\n\n      const select = gsap.utils.selector(root);\n      const cardEls = gsap.utils.toArray<HTMLElement>('[data-testimonial-item]', root);\n      if (cardEls.length === 0) return;\n\n      const prevBtn = select('[data-prev]')[0] as HTMLButtonElement | undefined;\n      const nextBtn = select('[data-next]')[0] as HTMLButtonElement | undefined;\n      const mdQuery = window.matchMedia('(min-width: 768px)');\n      const reduceMotion = prefersReducedMotion();\n\n      let perView = mdQuery.matches ? 2 : 1;\n      let pageCount = Math.ceil(cardEls.length / perView);\n      let activePage = 0;\n      let isAnimating = false;\n      let isInView = true;\n\n      type Card = {\n        el: HTMLElement;\n        image: HTMLElement | null;\n        splits: SplitText[];\n        lines: () => Element[];\n      };\n\n      const cards: Card[] = cardEls.map((el) => {\n        const within = gsap.utils.selector(el);\n        const targets = [\n          ...within('[data-testimonial-text]'),\n          ...within('[data-testimonial-split]'),\n        ] as HTMLElement[];\n\n        let splits: SplitText[] = [];\n        // SplitText needs real layout to find line breaks; if there is none\n        // (SSR snapshot, jsdom) fall back to fading whole cards.\n        try {\n          splits = reduceMotion\n            ? []\n            : targets.map((target) => SplitText.create(target, { type: 'lines', mask: 'lines' }));\n        } catch {\n          splits = [];\n        }\n\n        return {\n          el,\n          image: (within('[data-testimonial-img]')[0] as HTMLElement | undefined) ?? null,\n          splits,\n          lines() {\n            return this.splits.flatMap((split) => split.lines ?? []);\n          },\n        };\n      });\n\n      const onPage = (page: number) => cards.slice(page * perView, page * perView + perView);\n\n      const setPageState = (page: number, active: boolean) => {\n        onPage(page).forEach((card) => {\n          card.el.setAttribute('aria-hidden', String(!active));\n          gsap.set(card.el, {\n            autoAlpha: active ? 1 : 0,\n            pointerEvents: active ? 'auto' : 'none',\n            zIndex: active ? 1 : 0,\n          });\n        });\n      };\n\n      const applyPages = () => {\n        pageCount = Math.ceil(cards.length / perView);\n        activePage = Math.min(activePage, pageCount - 1);\n        for (let page = 0; page < pageCount; page++) setPageState(page, page === activePage);\n      };\n\n      applyPages();\n\n      cards.forEach((card, index) => {\n        const active = Math.floor(index / perView) === activePage;\n        gsap.set(card.lines(), { yPercent: active ? 0 : 110, force3D: true });\n        if (card.image) gsap.set(card.image, { clipPath: active ? CLIP_OPEN : CLIP_CLOSED });\n      });\n\n      const goTo = (nextPage: number) => {\n        if (isAnimating || nextPage === activePage || pageCount < 2) return;\n        isAnimating = true;\n\n        const outgoing = onPage(activePage);\n        const incoming = onPage(nextPage);\n        const settle = () => {\n          setPageState(activePage, false);\n          setPageState(nextPage, true);\n          activePage = nextPage;\n          isAnimating = false;\n        };\n\n        const tl = gsap.timeline({ onComplete: settle });\n\n        if (reduceMotion || cards.every((card) => card.splits.length === 0)) {\n          tl.to(\n            outgoing.map((card) => card.el),\n            { autoAlpha: 0, duration: 0.4, ease: 'power2' },\n            0\n          ).fromTo(\n            incoming.map((card) => card.el),\n            { autoAlpha: 0 },\n            { autoAlpha: 1, duration: 0.4, ease: 'power2' },\n            0\n          );\n          return;\n        }\n\n        outgoing.forEach((card) => gsap.set(card.el, { zIndex: 1 }));\n        incoming.forEach((card) =>\n          gsap.set(card.el, { autoAlpha: 1, pointerEvents: 'auto', zIndex: 2 })\n        );\n        gsap.set(\n          incoming.flatMap((card) => card.lines()),\n          { yPercent: 110, force3D: true }\n        );\n\n        // Outgoing lines sweep up out of their masks while the portrait irises\n        // shut; the incoming set arrives a beat later, so the two never collide.\n        outgoing.forEach((card) => {\n          tl.to(\n            card.lines(),\n            {\n              yPercent: -110,\n              duration: 0.6,\n              ease: 'power4.inOut',\n              stagger: { amount: 0.25 },\n              force3D: true,\n            },\n            0\n          );\n          if (card.image) {\n            tl.to(card.image, { clipPath: CLIP_CLOSED, duration: 0.9, ease: 'power4.inOut' }, 0);\n          }\n        });\n\n        incoming.forEach((card) => {\n          tl.to(\n            card.lines(),\n            {\n              yPercent: 0,\n              duration: 0.7,\n              ease: 'power4.inOut',\n              stagger: { amount: 0.4 },\n              force3D: true,\n            },\n            0.3\n          );\n          if (card.image) {\n            tl.fromTo(\n              card.image,\n              { clipPath: CLIP_CLOSED },\n              { clipPath: CLIP_OPEN, duration: 1.1, ease: 'power4.inOut' },\n              0.3\n            );\n          }\n        });\n\n        tl.set(\n          outgoing.map((card) => card.el),\n          { autoAlpha: 0 }\n        );\n      };\n\n      const step = (delta: number) => goTo((activePage + delta + pageCount) % pageCount);\n\n      const onBreakpoint = () => {\n        const next = mdQuery.matches ? 2 : 1;\n        if (next === perView) return;\n        const firstCard = activePage * perView;\n        perView = next;\n        activePage = Math.floor(firstCard / perView);\n        applyPages();\n      };\n\n      const onPrev = () => step(-1);\n      const onNext = () => step(1);\n\n      // Arrow keys only while the section is on screen, so they don't hijack\n      // the page from somewhere else.\n      const onKeyDown = (event: KeyboardEvent) => {\n        if (!isInView) return;\n        const target = event.target as HTMLElement | null;\n        if (target && (target.tagName === 'INPUT' || target.isContentEditable)) return;\n        if (event.key === 'ArrowRight') {\n          event.preventDefault();\n          step(1);\n        }\n        if (event.key === 'ArrowLeft') {\n          event.preventDefault();\n          step(-1);\n        }\n      };\n\n      mdQuery.addEventListener('change', onBreakpoint);\n      prevBtn?.addEventListener('click', onPrev);\n      nextBtn?.addEventListener('click', onNext);\n      window.addEventListener('keydown', onKeyDown);\n\n      const trigger = ScrollTrigger.create({\n        trigger: root,\n        start: 'top bottom',\n        end: 'bottom top',\n        onEnter: () => (isInView = true),\n        onEnterBack: () => (isInView = true),\n        onLeave: () => (isInView = false),\n        onLeaveBack: () => (isInView = false),\n      });\n\n      return () => {\n        trigger.kill();\n        cards.forEach((card) => card.splits.forEach((split) => split.revert()));\n        mdQuery.removeEventListener('change', onBreakpoint);\n        prevBtn?.removeEventListener('click', onPrev);\n        nextBtn?.removeEventListener('click', onNext);\n        window.removeEventListener('keydown', onKeyDown);\n      };\n    },\n    { scope: rootRef, dependencies: [items] }\n  );\n\n  return (\n    <section\n      ref={rootRef}\n      data-testimonial-line-sweep\n      className={cn('w-full px-5 py-14 md:py-16', className)}\n      {...props}\n    >\n      <div className=\"mx-auto max-w-[1064px] space-y-10\">\n        <div className=\"space-y-3 text-center\">\n          <h2 className=\"text-3xl font-medium tracking-tight text-[#12161f] md:text-5xl dark:text-white\">\n            {title}\n          </h2>\n          <p className=\"mx-auto max-w-[550px] text-base text-[#18181b]/60 md:text-lg dark:text-white/60\">\n            {description}\n          </p>\n        </div>\n\n        <div className=\"space-y-8\">\n          <div className=\"grid grid-cols-1 rounded-[20px] bg-white p-5 md:grid-cols-2 md:p-6 dark:bg-[#161b22]\">\n            {items.map((item, index) => (\n              <div\n                key={item.name}\n                data-testimonial-item\n                className={cn(\n                  'col-start-1 row-start-1 space-y-5',\n                  'motion-reduce:transform-none motion-reduce:transition-none',\n                  index % 2 === 0\n                    ? 'md:pr-10'\n                    : 'md:col-start-2 md:border-l md:border-[#18181b]/10 md:pl-10 dark:md:border-white/10'\n                )}\n              >\n                <div className=\"space-y-4\">\n                  <div\n                    className=\"flex items-center gap-x-1.5 text-[#045f64] dark:text-[#c6f56f]\"\n                    aria-label=\"5 out of 5 stars\"\n                  >\n                    {Array.from({ length: 5 }, (_, star) => (\n                      <StarIcon key={star} className=\"size-4\" />\n                    ))}\n                  </div>\n\n                  <p\n                    data-testimonial-text\n                    className=\"text-base leading-relaxed text-[#18181b]/60 dark:text-white/60\"\n                  >\n                    {item.quoteStart}{' '}\n                    <span className=\"text-[#12161f] dark:text-white\">{item.quoteHighlight}</span>{' '}\n                    {item.quoteEnd}\n                  </p>\n                </div>\n\n                <div className=\"flex flex-col items-center space-y-4 text-center md:items-start md:text-left\">\n                  <figure className=\"h-[120px] w-[160px] overflow-hidden rounded-[20px]\">\n                    <img\n                      data-testimonial-img\n                      src={item.image}\n                      alt={item.imageAlt ?? item.name}\n                      loading=\"lazy\"\n                      className=\"size-full object-cover\"\n                    />\n                  </figure>\n                  <div className=\"space-y-1\">\n                    <h3\n                      data-testimonial-split\n                      className=\"text-sm font-medium text-[#12161f] dark:text-white\"\n                    >\n                      {item.name}\n                    </h3>\n                    <p\n                      data-testimonial-split\n                      className=\"text-xs text-[#18181b]/60 dark:text-white/60\"\n                    >\n                      {item.jobTitle}\n                    </p>\n                  </div>\n                </div>\n              </div>\n            ))}\n          </div>\n\n          <div className=\"flex items-center justify-center gap-x-2\">\n            <button\n              type=\"button\"\n              data-prev\n              aria-label=\"Previous testimonials\"\n              className=\"grid h-8 w-12 cursor-pointer place-items-center rounded-full border border-[#18181b]/10 bg-white text-[#12161f] transition-colors hover:bg-[#f1f3f4] motion-reduce:transition-none dark:border-white/10 dark:bg-[#161b22] dark:text-white dark:hover:bg-white/10\"\n            >\n              <ArrowIcon direction=\"left\" className=\"size-4\" />\n            </button>\n            <button\n              type=\"button\"\n              data-next\n              aria-label=\"Next testimonials\"\n              className=\"grid h-8 w-12 cursor-pointer place-items-center rounded-full border border-[#18181b]/10 bg-white text-[#12161f] transition-colors hover:bg-[#f1f3f4] motion-reduce:transition-none dark:border-white/10 dark:bg-[#161b22] dark:text-white dark:hover:bg-white/10\"\n            >\n              <ArrowIcon direction=\"right\" className=\"size-4\" />\n            </button>\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/testimonial-line-sweep\nGSAP plugins (ScrollTrigger, 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."
}