{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "timeline", "title": "Timeline", "description": "A vertical event list with rough circle nodes and connecting lines.", "dependencies": [ "roughjs" ], "registryDependencies": [ "https://www.bydefaulthuman.fun/r/use-rough.json", "utils", "https://www.bydefaulthuman.fun/r/crumble-theme.json", "https://www.bydefaulthuman.fun/r/rough-lib.json" ], "files": [ { "path": "registry/new-york/ui/timeline.tsx", "content": "\"use client\";\n\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useRef,\n type HTMLAttributes,\n type ReactNode,\n} from \"react\";\nimport { useRough } from \"@/hooks/use-rough\";\nimport { cn } from \"@/lib/utils\";\nimport {\n CrumbleContext,\n resolveRoughVars,\n stableSeed,\n type CrumbleColorProps,\n type CrumbleTheme,\n} from \"@/lib/rough\";\n\n// ---------- context ----------\n\ninterface TimelineContextValue {\n theme: CrumbleTheme;\n}\n\nconst TimelineThemeContext = createContext({\n theme: \"pencil\",\n});\n\n// ---------- root ----------\n\nexport interface TimelineProps\n extends HTMLAttributes, CrumbleColorProps {\n theme?: CrumbleTheme;\n}\n\nexport function Timeline({\n children,\n className,\n fill,\n stroke,\n strokeMuted,\n theme: themeProp,\n ...props\n}: TimelineProps) {\n const { theme: contextTheme } = useContext(CrumbleContext);\n const theme = themeProp ?? contextTheme;\n const roughStyle = resolveRoughVars({ stroke, strokeMuted, fill });\n return (\n \n \n {children}\n \n \n );\n}\n\n// ---------- item ----------\n\nexport type TimelineStatus = \"complete\" | \"active\" | \"pending\";\n\nexport interface TimelineItemProps extends Omit<\n HTMLAttributes,\n \"title\"\n> {\n description?: ReactNode;\n id?: string;\n isLast?: boolean;\n status?: TimelineStatus;\n time?: ReactNode;\n title: ReactNode;\n}\n\nconst NODE_SIZE = 20;\nconst LINE_X = NODE_SIZE / 2;\n\nexport function TimelineItem({\n className,\n description,\n id,\n isLast = false,\n status = \"pending\",\n time,\n title,\n ...props\n}: TimelineItemProps) {\n const { theme } = useContext(TimelineThemeContext);\n const nodeSvgRef = useRef(null);\n const lineSvgRef = useRef(null);\n const lineContainerRef = useRef(null);\n\n const itemId =\n id ??\n `timeline-${typeof title === \"string\" ? title.toLowerCase().replace(/\\s+/g, \"-\") : \"item\"}`;\n const { drawCircle: drawNodeCircle, drawLine: drawNodeLine } = useRough({\n variant: \"interactive\",\n stableId: itemId,\n svgRef: nodeSvgRef,\n theme,\n });\n const { drawLine: drawConnectorLine } = useRough({\n variant: \"border\",\n stableId: `${itemId}-line`,\n svgRef: lineSvgRef,\n theme,\n });\n\n const drawNode = useCallback(() => {\n const svg = nodeSvgRef.current;\n if (!svg) return;\n\n svg.replaceChildren();\n svg.setAttribute(\"width\", String(NODE_SIZE));\n svg.setAttribute(\"height\", String(NODE_SIZE));\n svg.setAttribute(\"viewBox\", `0 0 ${NODE_SIZE} ${NODE_SIZE}`);\n\n const cx = NODE_SIZE / 2;\n const cy = NODE_SIZE / 2;\n const stroke =\n status === \"pending\" ? \"var(--cr-stroke-muted)\" : \"var(--cr-stroke)\";\n\n if (status === \"complete\") {\n // Filled circle\n const circleEl = drawNodeCircle(cx, cy, NODE_SIZE - 4, {\n fill: \"currentColor\",\n fillStyle: theme === \"ink\" ? \"solid\" : \"hachure\",\n fillWeight: 0.8,\n stroke,\n });\n if (circleEl) svg.appendChild(circleEl);\n\n // Tick mark\n const tick1 = drawNodeLine(5, cy + 1, cx - 1, NODE_SIZE - 4, {\n stroke: \"hsl(var(--background))\",\n strokeWidth: 1.5,\n });\n const tick2 = drawNodeLine(cx - 1, NODE_SIZE - 4, NODE_SIZE - 4, 4, {\n seed: stableSeed(`${itemId}-tick`),\n stroke: \"hsl(var(--background))\",\n strokeWidth: 1.5,\n });\n if (tick1) svg.appendChild(tick1);\n if (tick2) svg.appendChild(tick2);\n } else if (status === \"active\") {\n // Outlined circle with inner dot\n const outer = drawNodeCircle(cx, cy, NODE_SIZE - 4, {\n fill: \"none\",\n stroke,\n });\n const inner = drawNodeCircle(cx, cy, NODE_SIZE / 2 - 2, {\n fill: \"currentColor\",\n fillStyle: \"solid\",\n seed: stableSeed(`${itemId}-dot`),\n stroke: \"none\",\n });\n if (outer) svg.appendChild(outer);\n if (inner) svg.appendChild(inner);\n } else {\n // Empty circle\n const pending = drawNodeCircle(cx, cy, NODE_SIZE - 4, {\n fill: \"none\",\n stroke,\n });\n if (pending) svg.appendChild(pending);\n }\n }, [drawNodeCircle, drawNodeLine, itemId, status, theme]);\n\n const drawLine = useCallback(() => {\n const container = lineContainerRef.current;\n const svg = lineSvgRef.current;\n if (!container || !svg || isLast) return;\n\n const h = container.offsetHeight;\n svg.replaceChildren();\n svg.setAttribute(\"width\", String(NODE_SIZE));\n svg.setAttribute(\"height\", String(h));\n svg.setAttribute(\"viewBox\", `0 0 ${NODE_SIZE} ${h}`);\n\n const lineEl = drawConnectorLine(LINE_X, 0, LINE_X, h, {\n stroke:\n status === \"pending\"\n ? \"var(--cr-stroke-muted)\"\n : \"var(--cr-stroke-muted)\",\n strokeWidth: 1,\n });\n if (lineEl) svg.appendChild(lineEl);\n }, [drawConnectorLine, isLast, status]);\n\n useEffect(() => {\n const id1 = requestAnimationFrame(() => {\n drawNode();\n drawLine();\n });\n return () => cancelAnimationFrame(id1);\n }, [drawLine, drawNode]);\n\n useEffect(() => {\n const container = lineContainerRef.current;\n if (!container) return;\n const ro = new ResizeObserver(() => drawLine());\n ro.observe(container);\n return () => ro.disconnect();\n }, [drawLine]);\n\n return (\n \n {/* Left column: node + connector line */}\n \n \n {!isLast ? (\n \n \n \n ) : null}\n \n\n {/* Right column: content */}\n
\n
\n \n {title}\n \n {time ? (\n \n {time}\n \n ) : null}\n
\n {description ? (\n

{description}

\n ) : null}\n
\n \n );\n}\n", "type": "registry:ui", "target": "components/crumble/ui/timeline.tsx" } ], "type": "registry:ui" }