{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "stat-card", "title": "Stat Card", "description": "A hand-drawn card preset for dashboard metrics with an optional trend arrow.", "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/stat-card.tsx", "content": "\"use client\";\n\nimport {\n useCallback,\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 randomSeed,\n resolveRoughVars,\n stableSeed,\n type CrumbleColorProps,\n type CrumbleTheme,\n} from \"@/lib/rough\";\n\nexport type TrendDirection = \"up\" | \"down\" | \"flat\";\n\nexport interface StatCardProps\n extends HTMLAttributes,\n CrumbleColorProps {\n animateOnHover?: boolean;\n description?: ReactNode;\n id?: string;\n label: string;\n theme?: CrumbleTheme;\n trend?: TrendDirection;\n trendLabel?: string;\n value: ReactNode;\n}\n\nexport function StatCard({\n animateOnHover = true,\n className,\n description,\n fill,\n id,\n label,\n stroke,\n strokeMuted,\n theme: themeProp,\n trend,\n trendLabel,\n value,\n ...props\n}: StatCardProps) {\n const containerRef = useRef(null);\n const borderSvgRef = useRef(null);\n const trendSvgRef = useRef(null);\n const cardId = id ?? `stat-${label.toLowerCase().replace(/\\s+/g, \"-\")}`;\n const roughStyle = resolveRoughVars({ stroke, strokeMuted, fill });\n const {\n animateOnHover: animateFromContext,\n drawRect,\n svgRef,\n theme,\n } = useRough({\n stableId: cardId,\n svgRef: borderSvgRef,\n theme: themeProp,\n variant: \"border\",\n });\n const { drawLine: drawTrendLine } = useRough({\n stableId: `${cardId}-trend`,\n svgRef: trendSvgRef,\n theme: themeProp,\n variant: \"border\",\n });\n\n const draw = useCallback(\n (reseed = false) => {\n const container = containerRef.current;\n const svg = svgRef.current;\n if (!container || !svg) return;\n\n svg.replaceChildren();\n const w = container.offsetWidth;\n const h = container.offsetHeight;\n svg.setAttribute(\"width\", String(w));\n svg.setAttribute(\"height\", String(h));\n svg.setAttribute(\"viewBox\", `0 0 ${w} ${h}`);\n\n const rect = drawRect(1, 1, w - 2, h - 2, {\n fill: \"none\",\n seed: reseed ? randomSeed() : undefined,\n stroke: \"var(--cr-stroke-muted)\",\n });\n if (rect) svg.appendChild(rect);\n },\n [drawRect, svgRef],\n );\n\n const drawTrend = useCallback(() => {\n const svg = trendSvgRef.current;\n if (!svg || !trend || trend === \"flat\") return;\n\n svg.replaceChildren();\n svg.setAttribute(\"width\", \"16\");\n svg.setAttribute(\"height\", \"16\");\n svg.setAttribute(\"viewBox\", \"0 0 16 16\");\n\n const color =\n trend === \"up\" ? \"oklch(0.5 0.15 145)\" : \"var(--cr-stroke-error)\";\n const baseOpts = {\n stroke: color,\n strokeWidth: theme === \"crayon\" ? 2 : 1.3,\n };\n\n if (trend === \"up\") {\n const left = drawTrendLine(3, 12, 8, 4, baseOpts);\n const right = drawTrendLine(8, 4, 13, 12, {\n ...baseOpts,\n seed: stableSeed(`${cardId}-trend-r`),\n });\n if (left) svg.appendChild(left);\n if (right) svg.appendChild(right);\n } else {\n const left = drawTrendLine(3, 4, 8, 12, baseOpts);\n const right = drawTrendLine(8, 12, 13, 4, {\n ...baseOpts,\n seed: stableSeed(`${cardId}-trend-r`),\n });\n if (left) svg.appendChild(left);\n if (right) svg.appendChild(right);\n }\n }, [cardId, drawTrendLine, theme, trend]);\n\n useEffect(() => {\n const id = requestAnimationFrame(() => {\n draw();\n drawTrend();\n });\n return () => cancelAnimationFrame(id);\n }, [draw, drawTrend]);\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n const ro = new ResizeObserver(() => draw());\n ro.observe(container);\n return () => ro.disconnect();\n }, [draw]);\n\n const trendColor =\n trend === \"up\"\n ? \"text-[oklch(0.5_0.15_145)]\"\n : trend === \"down\"\n ? \"text-destructive\"\n : \"text-muted-foreground\";\n\n return (\n {\n if (animateOnHover && animateFromContext) draw(true);\n }}\n onMouseLeave={() => {\n if (animateOnHover && animateFromContext) draw(false);\n }}\n {...props}\n >\n \n
\n

\n {label}\n

\n

{value}

\n {trend || trendLabel || description ? (\n
\n {trend ? (\n \n ) : null}\n {trendLabel ? (\n {trendLabel}\n ) : null}\n {description ? (\n {description}\n ) : null}\n
\n ) : null}\n
\n \n );\n}\n", "type": "registry:ui", "target": "components/crumble/ui/stat-card.tsx" } ], "type": "registry:ui" }