{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "thinking-steps", "title": "Thinking Steps", "description": "Collapsible AI reasoning steps with animated expand/collapse, per-step icons, sources, and nested details. Base UI flavor.", "dependencies": [ "@base-ui/react", "framer-motion" ], "registryDependencies": [ "https://zeron-ui.vercel.app/r/surfaces.json", "utils", "https://zeron-ui.vercel.app/r/springs.json", "https://zeron-ui.vercel.app/r/shape-context.json", "https://zeron-ui.vercel.app/r/icon-context.json", "https://zeron-ui.vercel.app/r/badge.json" ], "files": [ { "path": "src/components/ui/thinking-steps.tsx", "content": "\"use client\";\n\nimport {\n useState,\n useEffect,\n useLayoutEffect,\n useRef,\n useCallback,\n useContext,\n createContext,\n forwardRef,\n type ReactNode,\n type HTMLAttributes,\n} from \"react\";\nimport { motion, AnimatePresence } from \"framer-motion\";\nimport { Collapsible } from \"@base-ui/react/collapsible\";\n\n// SSR-safe layout effect (client components still server-render in Next).\nconst useIsoLayoutEffect =\n typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\nimport { cn } from \"@/lib/utils\";\nimport { useIcon } from \"@/lib/icon-context\";\nimport type { IconName } from \"@/lib/icon-context\";\nimport { spring } from \"@/lib/springs\";\nimport { useShape } from \"@/lib/shape-context\";\nimport { Badge } from \"@/components/ui/badge\";\nimport type { BadgeColor } from \"@/components/ui/badge\";\n\n// ─── Shared collapsible parts ───────────────────────────────────────────────\n//\n// ThinkingSteps and ThinkingStepDetails are both single collapsible sections,\n// built directly on Base UI's Collapsible (Root/Trigger/Panel) with the\n// library's framer-motion springs layered on top.\n\n/** Open state of the nearest ThinkingSteps root, for the header trigger/panel. */\nconst ThinkingStepsOpenContext = createContext(false);\n\ninterface TriggerRowProps extends HTMLAttributes {\n open: boolean;\n children: ReactNode;\n}\n\n/**\n * Trigger row: hover background, dual-layer variable-weight label, and a\n * chevron that rotates from right (closed) to down (open). Mirrors the\n * library's accordion trigger styling.\n */\nconst TriggerRow = forwardRef(\n ({ open, children, className, ...props }, ref) => {\n const ChevronRight = useIcon(\"chevron-right\");\n const shape = useShape();\n const [isHovered, setIsHovered] = useState(false);\n const highlighted = open || isHovered;\n\n return (\n setIsHovered(true)}\n onMouseLeave={() => setIsHovered(false)}\n >\n \n {isHovered && (\n \n )}\n \n )}\n >\n {/* Label with dual-layer text (invisible bold layer reserves width) */}\n \n \n {children}\n \n \n {children}\n \n \n\n {/* Chevron — right when collapsed, rotates 90° down when expanded */}\n \n \n \n \n \n );\n }\n);\nTriggerRow.displayName = \"ThinkingStepsTriggerRow\";\n\ninterface CollapsePanelProps {\n open: boolean;\n children: ReactNode;\n}\n\n/**\n * Collapsible panel with a framer-motion height + spring animation.\n *\n * Base UI's Panel would apply `hidden` the moment a controlled collapsible\n * closes (it can't observe the JS-driven exit animation), which is\n * `display: none` and would freeze the exit mid-flight. So we render through\n * `keepMounted` + `render`, strip Base UI's premature `hidden`, and only\n * apply the attribute ourselves once the framer exit has actually completed.\n * The persistent panel element keeps the trigger ↔ panel ARIA contract\n * intact (the trigger's `aria-controls` id lives on it).\n */\nfunction CollapsePanel({ open, children }: CollapsePanelProps) {\n // The open height is animated to a self-measured LAYOUT pixel value, not\n // `height: \"auto\"`: framer resolves an \"auto\" target by measuring the\n // element's *visual* (transformed) size, so under a scaled ancestor\n // (e.g. /demo's 1.7x card) the animation overshoots to scale× the real\n // height and snaps back when the final \"auto\" lands. offsetHeight and\n // ResizeObserver are transform-immune. Same setup as the accordions.\n const innerRef = useRef(null);\n const roRef = useRef(null);\n const [contentHeight, setContentHeight] = useState(null);\n // Panels open at mount render `initial: \"auto\"` and receive their first\n // pixel target a commit later; that hand-off must SNAP (duration 0), not\n // spring. Panels that open later spring normally.\n const needsSnap = useRef(open);\n\n const measureRef = useCallback((el: HTMLDivElement | null) => {\n roRef.current?.disconnect();\n roRef.current = null;\n innerRef.current = el;\n if (!el) return;\n if (el.offsetHeight > 0) setContentHeight(el.offsetHeight);\n const ro = new ResizeObserver(() => {\n // Ignore the 0 that fires while the panel is display:none.\n if (el.offsetHeight > 0) setContentHeight(el.offsetHeight);\n });\n ro.observe(el);\n roRef.current = ro;\n }, []);\n\n // Re-measure synchronously (pre-paint) when opening, so the spring's\n // target is the fresh layout height from its first frame.\n useIsoLayoutEffect(() => {\n if (open && innerRef.current && innerRef.current.offsetHeight > 0) {\n setContentHeight(innerRef.current.offsetHeight);\n }\n }, [open]);\n\n useEffect(() => {\n if (contentHeight !== null) needsSnap.current = false;\n }, [contentHeight]);\n\n const [exitComplete, setExitComplete] = useState(!open);\n if (open && exitComplete) {\n // Reset during render so the panel is un-hidden before the opening\n // animation's first paint.\n setExitComplete(false);\n }\n\n return (\n {\n const {\n // Applied too early for our exit animation (see above); we\n // control the attribute ourselves.\n hidden: _baseHidden,\n // Only carries the --collapsible-panel-height/width vars, which\n // stay 'auto' since Base UI never measures JS-driven animations.\n style: _baseStyle,\n ...restPanel\n } = panelProps as React.HTMLAttributes & {\n hidden?: boolean;\n };\n return (\n \n \n \n );\n }}\n />\n );\n}\n\n// ─── ThinkingSteps (root) ───────────────────────────────────────────────────\n\ninterface ThinkingStepsProps extends HTMLAttributes {\n defaultOpen?: boolean;\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n children: ReactNode;\n}\n\nconst ThinkingSteps = forwardRef(\n ({ defaultOpen = true, open, onOpenChange, children, className, ...props }, ref) => {\n // Always drive Base UI as controlled so the header/panel can read the\n // open state (chevron rotation, framer enter/exit) from context.\n const [internalOpen, setInternalOpen] = useState(defaultOpen);\n const isOpen = open ?? internalOpen;\n\n return (\n {\n if (open === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n }}\n className={cn(\"w-80 max-w-full\", className)}\n {...props}\n >\n \n {children}\n \n \n );\n }\n);\nThinkingSteps.displayName = \"ThinkingSteps\";\n\n// ─── ThinkingStepsHeader ────────────────────────────────────────────────────\n\ninterface ThinkingStepsHeaderProps extends HTMLAttributes {\n children?: ReactNode;\n}\n\nconst ThinkingStepsHeader = forwardRef<\n HTMLButtonElement,\n ThinkingStepsHeaderProps\n>(({ children = \"Thinking\", className, ...props }, ref) => {\n const isOpen = useContext(ThinkingStepsOpenContext);\n return (\n \n {children}\n \n );\n});\nThinkingStepsHeader.displayName = \"ThinkingStepsHeader\";\n\n// ─── ThinkingStepsContent ───────────────────────────────────────────────────\n\ninterface ThinkingStepsContentProps extends HTMLAttributes {\n children: ReactNode;\n}\n\nconst ThinkingStepsContent = forwardRef<\n HTMLDivElement,\n ThinkingStepsContentProps\n>(({ children, className, ...props }, ref) => {\n const isOpen = useContext(ThinkingStepsOpenContext);\n return (\n \n \n {children}\n \n \n );\n});\nThinkingStepsContent.displayName = \"ThinkingStepsContent\";\n\n// ─── ThinkingStep ───────────────────────────────────────────────────────────\n\ntype StepStatus = \"complete\" | \"active\" | \"pending\";\n\ninterface ThinkingStepProps {\n icon?: IconName;\n showIcon?: boolean;\n label: string;\n description?: string;\n status?: StepStatus;\n delay?: number;\n isLast?: boolean;\n children?: ReactNode;\n className?: string;\n}\n\nfunction ThinkingStep({\n icon = \"dot\",\n showIcon = true,\n label,\n description,\n status = \"complete\",\n delay = 0.08,\n isLast = false,\n children,\n className,\n}: ThinkingStepProps) {\n const Icon = useIcon(icon);\n const shape = useShape();\n\n if (status === \"pending\") return null;\n\n const isActive = status === \"active\";\n\n return (\n /* Outer: animates height to create space smoothly */\n \n {/* Inner: fades content in after space starts opening */}\n \n {/* Content row — this is the proximity hover target */}\n
\n {/* Icon column with continuous connector line */}\n
\n
\n {showIcon ? (\n \n ) : (\n
\n
\n
\n )}\n
\n {/* Line stretches from icon to bottom of this step */}\n {!isLast && (\n
\n )}\n
\n\n {/* Text content */}\n
\n \n {label}\n {isActive && \"…\"}\n \n {description && (\n \n {description}\n \n )}\n {children}\n
\n
\n \n \n );\n}\n\n// ─── ThinkingStepDetails (nested collapsible) ───────────────────────────────\n\ninterface ThinkingStepDetailsProps {\n summary: string;\n details?: string[];\n defaultOpen?: boolean;\n children?: ReactNode;\n className?: string;\n}\n\nfunction ThinkingStepDetails({\n summary,\n details,\n defaultOpen = false,\n children,\n className,\n}: ThinkingStepDetailsProps) {\n const [open, setOpen] = useState(defaultOpen);\n\n return (\n \n \n {summary}\n \n \n
\n {details?.map((item, i) => (\n \n {item}\n \n ))}\n {children}\n
\n
\n \n );\n}\n\n// ─── ThinkingStepSources ────────────────────────────────────────────────────\n\ninterface ThinkingStepSourcesProps extends HTMLAttributes {\n children: ReactNode;\n}\n\nconst ThinkingStepSources = forwardRef(\n ({ children, className, ...props }, ref) => {\n return (\n \n {children}\n
\n );\n }\n);\nThinkingStepSources.displayName = \"ThinkingStepSources\";\n\n// ─── ThinkingStepSource ─────────────────────────────────────────────────────\n\ninterface ThinkingStepSourceProps {\n color?: BadgeColor;\n delay?: number;\n children: ReactNode;\n className?: string;\n}\n\nfunction ThinkingStepSource({ color = \"gray\", delay = 0, children, className }: ThinkingStepSourceProps) {\n return (\n \n \n {children}\n \n \n );\n}\nThinkingStepSource.displayName = \"ThinkingStepSource\";\n\n// ─── ThinkingStepImage ──────────────────────────────────────────────────────\n\ninterface ThinkingStepImageProps {\n src: string;\n alt?: string;\n caption?: string;\n delay?: number;\n className?: string;\n}\n\nfunction ThinkingStepImage({ src, alt = \"\", caption, delay = 0, className }: ThinkingStepImageProps) {\n const shape = useShape();\n return (\n \n {/* This registry component is framework-agnostic and cannot depend on next/image. */}\n {/* eslint-disable-next-line @next/next/no-img-element */}\n \n {caption && (\n \n {caption}\n \n )}\n \n );\n}\nThinkingStepImage.displayName = \"ThinkingStepImage\";\n\n// ─── Exports ────────────────────────────────────────────────────────────────\n\nexport {\n ThinkingSteps,\n ThinkingStepsHeader,\n ThinkingStepsContent,\n ThinkingStep,\n ThinkingStepDetails,\n ThinkingStepSources,\n ThinkingStepSource,\n ThinkingStepImage,\n};\n\nexport type {\n ThinkingStepsProps,\n ThinkingStepsHeaderProps,\n ThinkingStepsContentProps,\n ThinkingStepProps,\n ThinkingStepDetailsProps,\n ThinkingStepSourcesProps,\n ThinkingStepSourceProps,\n ThinkingStepImageProps,\n StepStatus,\n};\n", "type": "registry:ui", "target": "components/ui/thinking-steps.tsx" } ], "css": { "@keyframes shimmer": { "0%": { "background-position": "0% 0" }, "100%": { "background-position": "100% 0" } }, ".shimmer-text": { "color": "transparent", "background": "linear-gradient(90deg, #a3a3a3 0%, #a3a3a3 35%, #525252 50%, #a3a3a3 65%, #a3a3a3 100%)", "background-size": "300% 100%", "background-clip": "text", "-webkit-background-clip": "text", "animation": "shimmer 1.5s ease-in-out infinite" } }, "type": "registry:ui" }