{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "grid-cascade",
  "type": "registry:block",
  "title": "Grid Cascade",
  "description": "A three-up testimonial grid that pages a whole set at a time — the current cards drop away on a stagger, then the next set cascades in from above, so the two never overlap mid-flight.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/grid-cascade.tsx",
      "type": "registry:component",
      "target": "components/tweenui/grid-cascade.tsx",
      "content": "'use client';\n\nimport {\n  useCallback,\n  useRef,\n  useState,\n  type ComponentPropsWithoutRef,\n  type ReactNode,\n} from 'react';\nimport { useGSAP } from '@gsap/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 ENTER = { y: -22, duration: 0.6, stagger: 0.08 };\nconst LEAVE = { y: 14, duration: 0.28, stagger: 0.05 };\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\nfunction XMark({ className }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 24 24\" className={className} aria-hidden=\"true\">\n      <path\n        d=\"M18.9 2.3h3.4l-7.4 8.5 8.7 11.5h-6.8l-5.4-7-6.1 7H1.9l7.9-9.1L1.5 2.3h7l4.9 6.4 5.5-6.4Zm-1.2 17.9h1.9L6.4 4.2H4.4l13.3 16Z\"\n        fill=\"currentColor\"\n      />\n    </svg>\n  );\n}\n\nfunction ChevronIcon({\n  direction,\n  className,\n}: {\n  direction: 'left' | 'right';\n  className?: string;\n}) {\n  return (\n    <svg viewBox=\"0 0 24 24\" className={className} aria-hidden=\"true\" fill=\"none\">\n      <path\n        d={direction === 'left' ? 'M15 5 8 12l7 7' : 'M9 5l7 7-7 7'}\n        stroke=\"currentColor\"\n        strokeWidth=\"1.8\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nexport interface GridCascadeItem {\n  /** Reviewer name. */\n  name: string;\n  /** Role shown under the name. */\n  position: string;\n  /** The quote. Clamped to three lines so a page stays level. */\n  review: string;\n  /** Avatar URL. */\n  image: string;\n  /** Alt text for the avatar. Falls back to `name`. */\n  imageAlt?: string;\n  /** Profile the corner button opens. Omit it and no button renders. */\n  link?: string;\n}\n\nexport interface GridCascadeProps extends Omit<ComponentPropsWithoutRef<'section'>, 'title'> {\n  /** Section heading. */\n  title?: ReactNode;\n  /** Supporting line under the heading. */\n  description?: string;\n  /** Quotes to page through. */\n  items?: GridCascadeItem[];\n  /** Cards shown at once. The grid is built for three. */\n  perPage?: number;\n  /** Wrap from the last page back to the first. */\n  loop?: boolean;\n}\n\nconst DEFAULT_TITLE = 'What our users say';\n\nconst AVATAR = (id: string) => `https://images.unsplash.com/${id}?w=96&h=96&fit=crop&crop=faces`;\n\nconst DEFAULT_ITEMS: GridCascadeItem[] = [\n  {\n    name: 'Maya Collins',\n    position: 'Head of Operations',\n    review:\n      'We replaced four tools with one workflow, and the team shipped its first release two weeks early. Nobody has asked to go back.',\n    image: AVATAR('photo-1494790108377-be9c29b29330'),\n    link: '#',\n  },\n  {\n    name: 'Ethan Brooks',\n    position: 'President of Sales',\n    review:\n      'Reporting used to take a full day each month. Now it runs itself and lands in our inbox before standup.',\n    image: AVATAR('photo-1519085360753-af0119f7cbe7'),\n    link: '#',\n  },\n  {\n    name: 'Liam Harper',\n    position: 'Product Lead',\n    review:\n      'Onboarding took an afternoon, not a quarter. Every handoff is visible, so nothing slips between design and build.',\n    image: AVATAR('photo-1507003211169-0a1dd7228f2d'),\n    link: '#',\n  },\n  {\n    name: 'Ava Sinclair',\n    position: 'Marketing Head',\n    review:\n      'Support tickets dropped by a third in one month. Customers find their answers before they ever reach us.',\n    image: AVATAR('photo-1534528741775-53994a69daeb'),\n    link: '#',\n  },\n  {\n    name: 'Noah Bennett',\n    position: 'Engineering Manager',\n    review:\n      'The API paid for the whole plan in a week. We wired it into our release pipeline and stopped thinking about it.',\n    image: AVATAR('photo-1500648767791-00dcc994a43e'),\n    link: '#',\n  },\n  {\n    name: 'Zoe Mitchell',\n    position: 'Creative Director',\n    review:\n      'It looks like our brand out of the box, which never happens. The team stopped asking me to review every asset.',\n    image: AVATAR('photo-1438761681033-6461ffad8d80'),\n    link: '#',\n  },\n];\n\nexport default function GridCascade({\n  title = DEFAULT_TITLE,\n  description,\n  items = DEFAULT_ITEMS,\n  perPage = 3,\n  loop = true,\n  className,\n  ...props\n}: GridCascadeProps) {\n  const rootRef = useRef<HTMLElement>(null);\n  const [page, setPage] = useState(0);\n  const busy = useRef(false);\n\n  const pageCount = Math.max(1, Math.ceil(items.length / perPage));\n  const visible = items.slice(page * perPage, page * perPage + perPage);\n\n  // Only the current page is rendered, so the incoming trio animates on mount\n  // rather than being toggled with `display: none`.\n  useGSAP(\n    () => {\n      if (prefersReducedMotion()) return;\n      gsap.fromTo(\n        '[data-testimonial-card]',\n        { autoAlpha: 0, y: ENTER.y },\n        {\n          autoAlpha: 1,\n          y: 0,\n          duration: ENTER.duration,\n          ease: 'power3.out',\n          stagger: ENTER.stagger,\n          clearProps: 'transform,opacity,visibility',\n        }\n      );\n    },\n    { scope: rootRef, dependencies: [page, items] }\n  );\n\n  const goTo = useCallback(\n    (next: number) => {\n      if (busy.current || next === page || pageCount < 2) return;\n\n      if (prefersReducedMotion()) {\n        setPage(next);\n        return;\n      }\n\n      busy.current = true;\n      // Drop the current trio away first, so the two sets never overlap.\n      gsap.to(gsap.utils.toArray('[data-testimonial-card]', rootRef.current), {\n        autoAlpha: 0,\n        y: LEAVE.y,\n        duration: LEAVE.duration,\n        ease: 'power2.in',\n        stagger: LEAVE.stagger,\n        onComplete: () => {\n          setPage(next);\n          busy.current = false;\n        },\n      });\n    },\n    [page, pageCount]\n  );\n\n  const step = (delta: number) => {\n    const next = page + delta;\n    if (next < 0) return loop ? goTo(pageCount - 1) : undefined;\n    if (next >= pageCount) return loop ? goTo(0) : undefined;\n    return goTo(next);\n  };\n\n  return (\n    <section\n      ref={rootRef}\n      data-grid-cascade\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-4xl dark:text-white\">\n            {title}\n          </h2>\n          {description && (\n            <p className=\"mx-auto max-w-[520px] text-sm text-[#18181b]/60 md:text-base dark:text-white/60\">\n              {description}\n            </p>\n          )}\n        </div>\n\n        <div className=\"grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3\">\n          {visible.map((item) => (\n            <article\n              key={item.name}\n              data-testimonial-card\n              className={cn(\n                'rounded-xl border border-[#18181b]/10 bg-white p-6 dark:border-white/10 dark:bg-[#161b22]',\n                'motion-reduce:transform-none motion-reduce:transition-none'\n              )}\n            >\n              <div className=\"flex items-center justify-between gap-3\">\n                <div className=\"flex min-w-0 items-center gap-x-3\">\n                  <img\n                    src={item.image}\n                    alt={item.imageAlt ?? item.name}\n                    loading=\"lazy\"\n                    className=\"size-12 shrink-0 rounded-full object-cover\"\n                  />\n                  <div className=\"min-w-0\">\n                    <p className=\"truncate text-sm font-medium text-[#12161f] dark:text-white\">\n                      {item.name}\n                    </p>\n                    <p className=\"truncate text-xs text-[#18181b]/60 dark:text-white/60\">\n                      {item.position}\n                    </p>\n                  </div>\n                </div>\n\n                {item.link && (\n                  <a\n                    href={item.link}\n                    target=\"_blank\"\n                    rel=\"noreferrer\"\n                    aria-label={`${item.name} on X`}\n                    className=\"grid size-9 shrink-0 place-items-center rounded-full bg-[#f1f3f4] text-[#12161f] transition-colors hover:bg-[#e6e9ea] motion-reduce:transition-none dark:bg-white/5 dark:text-white dark:hover:bg-white/10\"\n                  >\n                    <XMark className=\"size-3.5\" />\n                  </a>\n                )}\n              </div>\n\n              <blockquote className=\"mt-5 line-clamp-3 text-sm leading-relaxed text-[#18181b]/70 dark:text-white/70\">\n                {item.review}\n              </blockquote>\n\n              <div\n                className=\"mt-4 flex items-center gap-x-1 text-[#045f64] dark:text-[#c6f56f]\"\n                aria-label=\"5 out of 5 stars\"\n              >\n                {Array.from({ length: 5 }, (_, index) => (\n                  <StarIcon key={index} className=\"size-3.5\" />\n                ))}\n              </div>\n            </article>\n          ))}\n        </div>\n\n        {pageCount > 1 && (\n          <div className=\"flex items-center justify-center gap-x-2\">\n            <button\n              type=\"button\"\n              onClick={() => step(-1)}\n              aria-label=\"Previous testimonials\"\n              className=\"grid size-11 place-items-center rounded-full bg-[#f1f3f4] text-[#12161f] transition-colors hover:bg-[#e6e9ea] motion-reduce:transition-none dark:bg-white/5 dark:text-white dark:hover:bg-white/10\"\n            >\n              <ChevronIcon direction=\"left\" className=\"size-4\" />\n            </button>\n            <button\n              type=\"button\"\n              onClick={() => step(1)}\n              aria-label=\"Next testimonials\"\n              className=\"grid size-11 place-items-center rounded-full bg-[#f1f3f4] text-[#12161f] transition-colors hover:bg-[#e6e9ea] motion-reduce:transition-none dark:bg-white/5 dark:text-white dark:hover:bg-white/10\"\n            >\n              <ChevronIcon direction=\"right\" className=\"size-4\" />\n            </button>\n          </div>\n        )}\n      </div>\n    </section>\n  );\n}\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/grid-cascade\nThe sample content loads images from images.unsplash.com. Replace them with your own before shipping."
}