{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "process-sticky-steps",
  "type": "registry:block",
  "title": "Process Sticky Steps",
  "description": "A process block whose heading, divider and step numbers stay sticky inside the section while the cards scroll past them. No GSAP — just Intersection Observer.",
  "dependencies": [],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/process-sticky-steps.tsx",
      "type": "registry:component",
      "target": "components/tweenui/process-sticky-steps.tsx",
      "content": "'use client';\n\nimport { useEffect, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react';\nimport { cn } from '@/lib/utils';\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nexport interface ProcessStickyStep {\n  /** Stable id used as the React key and scroll target. */\n  id: string;\n  /** Step heading shown on the card. */\n  title: string;\n  /** Supporting line under the title. */\n  description: string;\n  /** Photo URL for the card. */\n  image: string;\n  /** Alt text for the photo. */\n  imageAlt: string;\n  /** Optional overlay image sitting on the photo. */\n  overlay?: string;\n  /** Alt text for the overlay image. */\n  overlayAlt?: string;\n  /** Where the overlay sits on the photo. */\n  overlayPosition?: 'right' | 'center';\n}\n\nexport interface ProcessStickyStepsProps extends Omit<\n  ComponentPropsWithoutRef<'section'>,\n  'title'\n> {\n  /** Heading shown in the sticky column. */\n  title?: ReactNode;\n  /** Process steps shown as stacked cards. Defaults to a 4-step sample. */\n  steps?: ProcessStickyStep[];\n}\n\nconst DEFAULT_TITLE = (\n  <>\n    Create stunning voiceover in{' '}\n    <span className=\"text-[#045f64] dark:text-[#c6f56f]\">4 simple steps</span>\n  </>\n);\n\nconst DEFAULT_STEPS: ProcessStickyStep[] = [\n  {\n    id: 'step-1',\n    title: 'Write or paste script',\n    description:\n      \"Quickly draft your message or drop in your ready-to-go text to get started instantly. Whether you're crafting a new idea, refining existing\",\n    image: 'https://images.unsplash.com/photo-1517842645767-c639042777db?w=800&h=520&fit=crop',\n    imageAlt: 'Notebook and pen for drafting a script',\n  },\n  {\n    id: 'step-2',\n    title: 'Choose your voice style',\n    description:\n      'Select from natural human-like voices, accents, and tones that fit your content. Browse through our extensive library of professional voices, each designed to match',\n    image: 'https://images.unsplash.com/photo-1478737270239-2f02b77fc618?w=800&h=520&fit=crop',\n    imageAlt: 'Studio microphone for choosing a voice',\n  },\n  {\n    id: 'step-3',\n    title: 'Customize the delivery',\n    description:\n      'Adjust speed, pitch, pauses, and emotions for a perfect performance. Fine-tune every aspect of your voiceover to create the exact tone and pacing you need for your project.',\n    image: 'https://images.unsplash.com/photo-1598488035139-bdbb2231ce04?w=800&h=520&fit=crop',\n    imageAlt: 'Mixing console for customizing delivery',\n  },\n  {\n    id: 'step-4',\n    title: 'Generate & download',\n    description:\n      \"Get studio-quality audio in seconds and download instantly. Your professional voiceover is ready to use in any format, whether you're creating content for videos, podcasts, or presentations.\",\n    image: 'https://images.unsplash.com/photo-1486312338219-ce68d2c6f44d?w=800&h=520&fit=crop',\n    imageAlt: 'Laptop ready to generate and download audio',\n  },\n];\n\nfunction StepCard({\n  step,\n  active,\n  cardRef,\n}: {\n  step: ProcessStickyStep;\n  active: boolean;\n  cardRef: (el: HTMLDivElement | null) => void;\n}) {\n  return (\n    <div\n      ref={cardRef}\n      id={step.id}\n      data-active={active ? 'true' : undefined}\n      className={cn(\n        'relative w-[300px] scroll-mt-8 overflow-hidden rounded-3xl border bg-white p-4 transition-colors duration-300 motion-reduce:transition-none dark:bg-[#12161F]',\n        active\n          ? 'border-[#045f64] dark:border-[#c6f56f]/60'\n          : 'border-[#045f64]/15 dark:border-[#045f64]/40'\n      )}\n    >\n      <figure className=\"relative z-10 h-44 overflow-hidden rounded-2xl bg-[#045f64]/10 p-1.5 dark:bg-[#045f64]/20\">\n        <img\n          src={step.image}\n          alt={step.imageAlt}\n          className=\"size-full rounded-[14px] object-cover object-top\"\n        />\n\n        {step.overlay ? (\n          <img\n            src={step.overlay}\n            alt={step.overlayAlt ?? ''}\n            className={cn(\n              'absolute z-20 scale-90 object-cover',\n              step.overlayPosition === 'right' && 'top-[88px] right-[-6px]',\n              step.overlayPosition === 'center' && 'top-[96px] left-1/2 -translate-x-1/2'\n            )}\n          />\n        ) : null}\n      </figure>\n\n      <div\n        className=\"pointer-events-none absolute inset-x-0 bottom-16 z-30 h-20 bg-linear-to-b from-transparent to-white dark:to-[#12161F]\"\n        aria-hidden\n      />\n\n      <div className=\"relative z-[33] mt-5 space-y-1.5\">\n        <h3 className=\"text-base leading-snug font-medium text-[#12161F] dark:text-white\">\n          {step.title}\n        </h3>\n        <p className=\"line-clamp-2 text-sm leading-relaxed text-[#045f64]/70 dark:text-[#9fd4d6]/80\">\n          {step.description}\n        </p>\n      </div>\n    </div>\n  );\n}\n\nexport default function ProcessStickySteps({\n  title = DEFAULT_TITLE,\n  steps = DEFAULT_STEPS,\n  className,\n  ...props\n}: ProcessStickyStepsProps) {\n  const [activeIndex, setActiveIndex] = useState(0);\n  const rootRef = useRef<HTMLElement>(null);\n  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const lockRef = useRef(false);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const cards = cardRefs.current.filter((ref): ref is HTMLDivElement => ref !== null);\n    if (!root || cards.length === 0 || typeof IntersectionObserver === 'undefined') {\n      return;\n    }\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        if (lockRef.current) return;\n\n        const best = entries\n          .filter((entry) => entry.isIntersecting)\n          .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];\n\n        if (!best || !(best.target instanceof HTMLElement)) return;\n\n        const index = cards.indexOf(best.target as HTMLDivElement);\n        if (index >= 0) {\n          setActiveIndex(index);\n        }\n      },\n      {\n        root,\n        rootMargin: '-20% 0px -45% 0px',\n        threshold: [0.2, 0.4, 0.6, 0.8],\n      }\n    );\n\n    cards.forEach((card) => observer.observe(card));\n    return () => observer.disconnect();\n  }, [steps]);\n\n  const goToStep = (index: number) => {\n    const root = rootRef.current;\n    const target = cardRefs.current[index];\n    lockRef.current = true;\n    setActiveIndex(index);\n\n    if (root && target) {\n      const top = Math.max(\n        0,\n        target.getBoundingClientRect().top - root.getBoundingClientRect().top + root.scrollTop - 32\n      );\n\n      if (typeof root.scrollTo === 'function') {\n        root.scrollTo({\n          top,\n          behavior: prefersReducedMotion() ? 'auto' : 'smooth',\n        });\n      } else {\n        root.scrollTop = top;\n      }\n    }\n\n    window.setTimeout(\n      () => {\n        lockRef.current = false;\n      },\n      prefersReducedMotion() ? 50 : 650\n    );\n  };\n\n  return (\n    <section\n      ref={rootRef}\n      data-process-sticky-steps\n      data-active-step={activeIndex}\n      className={cn(\n        'h-[620px] w-full overflow-x-hidden overflow-y-auto overscroll-contain',\n        className\n      )}\n      {...props}\n    >\n      <div className=\"flex min-h-full items-start justify-center gap-x-6 px-5 py-10 sm:gap-x-8 sm:px-8\">\n        <h2 className=\"sticky top-8 max-w-[18rem] min-w-[10rem] flex-1 text-2xl leading-[1.2] font-medium tracking-tight text-[#12161F] sm:text-[1.75rem] md:text-[2rem] dark:text-white\">\n          {title}\n        </h2>\n\n        <div className=\"sticky top-8 hidden h-[240px] w-px shrink-0 bg-[#045f64]/15 sm:block dark:bg-[#045f64]/40\" />\n\n        <div className=\"flex shrink-0 items-start gap-x-3\">\n          <div className=\"space-y-8 pb-20\">\n            {steps.map((step, index) => (\n              <StepCard\n                key={step.id}\n                step={step}\n                active={activeIndex === index}\n                cardRef={(el) => {\n                  cardRefs.current[index] = el;\n                }}\n              />\n            ))}\n          </div>\n\n          <div className=\"sticky top-10 z-20 flex flex-col gap-y-1.5\">\n            {steps.map((step, index) => (\n              <button\n                key={step.id}\n                type=\"button\"\n                onClick={() => goToStep(index)}\n                aria-label={`Go to step ${index + 1}: ${step.title}`}\n                aria-current={activeIndex === index ? 'step' : undefined}\n                className={cn(\n                  'flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-xs font-medium transition-colors duration-300 ease-in-out motion-reduce:transition-none',\n                  'focus-visible:ring-2 focus-visible:ring-[#045f64] focus-visible:ring-offset-2 focus-visible:outline-none dark:ring-offset-[#12161F]',\n                  activeIndex === index\n                    ? 'bg-[#045f64] text-[#c6f56f]'\n                    : 'bg-[#045f64]/10 text-[#045f64]/50 dark:bg-[#045f64]/20 dark:text-[#9fd4d6]/70'\n                )}\n              >\n                {index + 1}\n              </button>\n            ))}\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/process-sticky-steps\nThe sample content loads images from images.unsplash.com. Replace them with your own before shipping."
}