{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "faq-chat-accordion", "type": "registry:ui", "title": "FAQ Chat Accordion", "description": "Conversational FAQ with right/left message bubbles, typing indicator dots, spring message entrance, and layout FLIP repositioning.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/faq-chat-accordion.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ═══════════════════════════════════════════════════════════\n FAQ Chat Accordion — Conversational Message Exchange.\n\n A FAQ that feels like texting a support bot. Questions are\n right-aligned bubbles (you sent them), answers are left-\n aligned bubbles (the system replied). Click a question and\n a single bubble opens — first showing typing dots, then\n morphing smoothly into the answer text.\n\n ALL height transitions use CSS grid-template-rows — this\n changes actual DOM height (not transforms), so siblings\n follow the transition naturally without FLIP tricks or\n layout springs. Zero jank. Zero double-shift.\n\n Motion is used sparingly: spring y+opacity for the bubble\n entrance feel, spring whileTap for tactile press. That's\n it. Everything else is CSS — because CSS grid transitions\n are inherently smoother than transform-based layout hacks.\n\n Animation architecture:\n ┌─ Outer grid (0fr ↔ 1fr) ─── bubble enter/exit\n │ └─ motion.div (y + opacity) ─── spring entrance\n │ └─ Bubble container (bg-muted, rounded)\n │ ├─ Inner grid A (dots: 1fr ↔ 0fr)\n │ └─ Inner grid B (text: 0fr ↔ 1fr)\n └─ Siblings follow naturally (real height, not transforms)\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 • faq-scroll-accordion: self-contained scroll cascade\n • staggered-faq: BlurredStagger text reveal, split\n\n Dependencies: motion (only for whileTap + spring entrance).\n ═══════════════════════════════════════════════════════════ */\n\nexport interface FAQItem {\n question: string;\n answer: string;\n}\n\nexport interface FAQChatAccordionProps {\n title?: string;\n items?: FAQItem[];\n /** Milliseconds to show typing dots before answer. 0 to skip. */\n typingDelay?: number;\n className?: string;\n}\n\nconst defaultItems: FAQItem[] = [\n {\n question: \"How late does the internet close?\",\n answer: \"The internet never closes — it runs 24/7, 365 days a year.\",\n },\n {\n question: \"Do I need a license to browse?\",\n answer:\n \"No license required. Just open your browser and you're good to go.\",\n },\n {\n question: \"What flavour are the cookies?\",\n answer:\n \"Our cookies are digital. They help us remember your preferences, not satisfy cravings.\",\n },\n {\n question: \"Can I get lost here?\",\n answer:\n \"You might explore deeper than planned, but there's always a way back.\",\n },\n {\n question: \"What if I click the wrong button?\",\n answer:\n \"Nothing breaks. Hit back, refresh, or just try again. We're forgiving.\",\n },\n];\n\nconst EXPO = \"cubic-bezier(0.16, 1, 0.3, 1)\";\nconst EXPO_STRONG = \"cubic-bezier(0.22, 1, 0.36, 1)\";\n\nexport function FAQChatAccordion({\n title = \"Have questions?\",\n items = defaultItems,\n typingDelay = 400,\n className,\n}: FAQChatAccordionProps) {\n const [active, setActive] = React.useState(null);\n const [showAnswer, setShowAnswer] = React.useState(null);\n const timerRef = React.useRef>();\n\n const handleClick = React.useCallback(\n (i: number) => {\n if (timerRef.current) clearTimeout(timerRef.current);\n\n if (active === i) {\n // Close — don't clear showAnswer so bubble\n // exits with current content (no reverse morph)\n setActive(null);\n return;\n }\n\n // Open — clear showAnswer to start typing phase\n setShowAnswer(null);\n setActive(i);\n\n if (typingDelay > 0) {\n timerRef.current = setTimeout(() => setShowAnswer(i), typingDelay);\n } else {\n setShowAnswer(i);\n }\n },\n [active, typingDelay],\n );\n\n React.useEffect(() => {\n return () => {\n if (timerRef.current) clearTimeout(timerRef.current);\n };\n }, []);\n\n return (\n
\n
\n {/* ── Header ────────────────────────────────────── */}\n {title && (\n

\n {title}\n

\n )}\n\n {/* ── Date separator ────────────────────────────── */}\n
\n
\n \n Today\n \n
\n
\n\n {/* ── Message thread ────────────────────────────── */}\n
\n {items.map((item, i) => {\n const isActive = active === i;\n const hasAnswer = showAnswer === i;\n\n return (\n
\n {/* ── Question bubble (right-aligned) ──── */}\n
\n handleClick(i)}\n aria-expanded={isActive}\n >\n {item.question}\n \n
\n\n {/* ── Answer area ─────────────────────────\n Outer CSS grid handles enter/exit height.\n Siblings follow naturally — real DOM height,\n not transforms.\n ──────────────────────────────────────── */}\n \n
\n {/* Spring entrance feel (y + opacity only) */}\n \n
\n
\n {/* ── Dots section ───────────── */}\n {/* Collapses when answer ready */}\n \n
\n
\n \n \n \n
\n
\n
\n\n {/* ── Text section ───────────── */}\n {/* Expands when answer ready */}\n \n
\n \n {item.answer}\n

\n
\n
\n
\n
\n \n
\n
\n
\n );\n })}\n
\n \n\n \n
\n );\n}\n", "type": "registry:ui", "target": "components/ruixen/faq-chat-accordion.tsx" } ] }