{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "faq-scroll-accordion", "type": "registry:ui", "title": "FAQ Scroll Accordion", "description": "Scroll-aware FAQ with center-zone detection via IntersectionObserver, auto-cascading open, staggered entrance, and grid-template-rows expand.", "dependencies": [], "files": [ { "path": "registry/ruixenui/faq-scroll-accordion.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ═══════════════════════════════════════════════════════════\n FAQ Scroll Accordion — Self-Contained Scroll Cascade.\n\n A scroll-aware FAQ inside its OWN scrollable container.\n The IntersectionObserver is scoped to the container, NOT\n the page viewport — items only respond to scrolling\n WITHIN the component, never to the main page scroll.\n\n Four FAQ paradigms in ruixen, zero overlap:\n • faq-auto-accordion: spring traveling accent, motion physics\n • faq-chat-accordion: conversational message exchange\n • staggered-faq: BlurredStagger text reveal\n • faq-scroll-accordion: SELF-CONTAINED scroll cascade\n\n Container architecture:\n ┌─────────────────────────────────┐\n │ ░ gradient fade (top 5%) │\n │ ───────────────────────────── │\n │ Question 1 ∨ │ ← visible\n │ Answer text... │\n │ ───────────────────────────── │\n │ Question 2 ∨ │ ← center zone\n │ ───────────────────────────── │\n │ Question 3 ∨ │ ← visible\n │ ░ gradient fade (bottom 5%) │\n └───── scroll ↕ ─────────────────┘\n\n IntersectionObserver { root: scrollContainer }\n rootMargin: \"-40% 0px -40% 0px\"\n → center detection zone = middle 20% of container\n → an item must reach the container's center to trigger\n\n This means:\n • Page scroll has ZERO effect on which item opens\n • Only scrolling WITHIN the container triggers changes\n • In previews/iframes, the container IS the scroll context\n\n Scroll UX:\n Click: opens item + smoothly scrolls container to center\n it, then pauses scroll-driven for 3s\n Scroll: items cascade open as they reach center zone\n\n Visual cues for scrollability:\n • Gradient masks at top/bottom (5%) — content fades at edges\n • Hidden scrollbar (scrollbar-width: none + webkit hide)\n • Items below the fold create implicit scroll affordance\n\n Zero dependencies. No GSAP, no framer-motion, no lucide.\n ═══════════════════════════════════════════════════════════ */\n\nexport interface FAQItem {\n question: string;\n answer: string;\n}\n\nexport interface FAQScrollAccordionProps {\n title?: string;\n subtitle?: string;\n items?: FAQItem[];\n /** Enable scroll-driven auto-open. Default: true. */\n scrollDriven?: boolean;\n /** Index of the initially open item. null = all closed. */\n defaultActive?: number | null;\n className?: string;\n}\n\nconst defaultItems: FAQItem[] = [\n {\n question: \"What is Ruixen UI?\",\n answer:\n \"A curated collection of beautifully designed, production-ready components built with React and Tailwind CSS. Every component is crafted with animation-first thinking and zero unnecessary dependencies.\",\n },\n {\n question: \"How do I install components?\",\n answer:\n \"Use the shadcn CLI to add any component directly into your project. Each component lives in your codebase — no node_modules, full ownership, complete customization.\",\n },\n {\n question: \"Is it open-source?\",\n answer:\n \"Yes, fully open-source under the MIT license. Use it in personal projects, commercial products, client work — no restrictions.\",\n },\n {\n question: \"Do components work with dark mode?\",\n answer:\n \"Every component uses CSS custom properties and theme tokens. They adapt to light and dark modes automatically with no extra configuration.\",\n },\n {\n question: \"Can I customize the animations?\",\n answer:\n \"Absolutely. All transitions use standard CSS properties — timing, easing, and duration are easy to adjust. No animation library lock-in.\",\n },\n];\n\nexport function FAQScrollAccordion({\n title = \"Frequently asked questions\",\n subtitle = \"Everything you need to know.\",\n items = defaultItems,\n scrollDriven = true,\n defaultActive = 0,\n className,\n}: FAQScrollAccordionProps) {\n const [active, setActive] = React.useState(defaultActive);\n const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n const scrollPausedRef = React.useRef(false);\n const pauseTimerRef = React.useRef>();\n\n // Callback ref for the scroll container — triggers re-render\n // when mounted so the IntersectionObserver effect can use it.\n const [scrollContainer, setScrollContainer] =\n React.useState(null);\n\n // ── Scroll-driven auto-open ────────────────────────────\n // Scoped to the scroll container, NOT the page viewport.\n // rootMargin \"-40% 0px -40% 0px\" = center 20% of container.\n React.useEffect(() => {\n if (!scrollDriven || !scrollContainer) return;\n\n const observers: IntersectionObserver[] = [];\n\n itemRefs.current.forEach((el, i) => {\n if (!el) return;\n\n const observer = new IntersectionObserver(\n ([entry]) => {\n if (scrollPausedRef.current) return;\n if (entry.isIntersecting) {\n setActive(i);\n }\n },\n {\n root: scrollContainer,\n rootMargin: \"-40% 0px -40% 0px\",\n threshold: 0.5,\n },\n );\n\n observer.observe(el);\n observers.push(observer);\n });\n\n return () => observers.forEach((o) => o.disconnect());\n }, [scrollDriven, scrollContainer, items]);\n\n // ── Click handler ──────────────────────────────────────\n // Opens item, scrolls container to center it, then pauses\n // scroll-driven behavior for 3s so the observer doesn't\n // immediately override the user's selection.\n const toggle = React.useCallback(\n (i: number) => {\n scrollPausedRef.current = true;\n setActive((prev) => {\n const next = prev === i ? null : i;\n\n // Smooth-scroll the container to center the clicked item\n if (next !== null && scrollContainer) {\n const el = itemRefs.current[next];\n if (el) {\n requestAnimationFrame(() => {\n const scrollTop =\n el.offsetTop -\n scrollContainer.clientHeight / 2 +\n el.offsetHeight / 2;\n scrollContainer.scrollTo({\n top: Math.max(0, scrollTop),\n behavior: \"smooth\",\n });\n });\n }\n }\n\n return next;\n });\n\n if (pauseTimerRef.current) clearTimeout(pauseTimerRef.current);\n pauseTimerRef.current = setTimeout(() => {\n scrollPausedRef.current = false;\n }, 3000);\n },\n [scrollContainer],\n );\n\n React.useEffect(() => {\n return () => {\n if (pauseTimerRef.current) clearTimeout(pauseTimerRef.current);\n };\n }, []);\n\n return (\n
\n
\n {/* ── Header (outside scroll container) ───────── */}\n {title && (\n
\n

\n {title}\n

\n {subtitle && (\n

{subtitle}

\n )}\n
\n )}\n\n {/* ── Scrollable container ────────────────────── */}\n \n {items.map((item, i) => {\n const isActive = i === active;\n\n return (\n {\n itemRefs.current[i] = el;\n }}\n className={cn(\"border-b border-border\", i === 0 && \"border-t\")}\n >\n toggle(i)}\n >\n \n {item.question}\n \n\n {/* Chevron — inline SVG, no lucide */}\n \n \n \n \n\n {/* ── Expandable answer ───────────────── */}\n \n
\n \n {item.answer}\n

\n
\n
\n \n );\n })}\n \n \n\n {/* Hide webkit scrollbar */}\n \n
\n );\n}\n", "type": "registry:ui", "target": "components/ruixen/faq-scroll-accordion.tsx" } ] }