{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "scribble", "title": "Scribble", "description": "Draw rough freehand scribbles over content for emphasis, redaction, or playful UI effects.", "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/scribble.tsx", "content": "\"use client\";\n\n/**\n * Scribble\n *\n * A rough freehand scribble drawn over content — like a redaction mark,\n * a \"wrong answer\" cross-out, or an emphasis blob.\n *\n * Unlike rough-notation's `crossed-off` (just two diagonal lines),\n * Scribble fills a wobbly polygon over the content using rough.js's\n * polygon renderer with noise-distorted vertices.\n *\n * Types:\n * \"redact\" — dense filled blob, like a marker redaction\n * \"scrawl\" — loose irregular X shape drawn with 3–4 passes\n * \"circle\" — wobbly filled circle / \"wrong\" emphasis ring\n * \"blob\" — organic filled shape for highlighting a region\n */\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 { stableSeed, type CrumbleTheme } from \"@/lib/rough\";\n\nexport type ScribbleType = \"redact\" | \"scrawl\" | \"circle\" | \"blob\";\n\nexport interface ScribbleProps extends HTMLAttributes {\n children?: ReactNode;\n color?: string;\n id?: string;\n opacity?: number;\n padding?: number;\n theme?: CrumbleTheme;\n type?: ScribbleType;\n}\n\nfunction ensureKeyframes() {\n if (typeof window === \"undefined\") return;\n if ((window as any).__crumble_ann_kf) return;\n const s = document.createElement(\"style\");\n s.textContent = `@keyframes crumble-annotation-dash { to { stroke-dashoffset: 0; } }`;\n document.head.appendChild(s);\n (window as any).__crumble_ann_kf = true;\n}\n\n/** Distort a rectangle into a noisy polygon */\nfunction noisyRect(\n x: number,\n y: number,\n w: number,\n h: number,\n jitter: number,\n seed: number,\n steps = 5,\n): [number, number][] {\n const points: [number, number][] = [];\n const rng = mulberry32(seed);\n\n // Top edge\n for (let i = 0; i <= steps; i++) {\n const t = i / steps;\n points.push([\n x + t * w + (rng() - 0.5) * jitter,\n y + (rng() - 0.5) * jitter,\n ]);\n }\n // Right edge\n for (let i = 1; i <= steps; i++) {\n const t = i / steps;\n points.push([\n x + w + (rng() - 0.5) * jitter,\n y + t * h + (rng() - 0.5) * jitter,\n ]);\n }\n // Bottom edge (reversed)\n for (let i = steps - 1; i >= 0; i--) {\n const t = i / steps;\n points.push([\n x + t * w + (rng() - 0.5) * jitter,\n y + h + (rng() - 0.5) * jitter,\n ]);\n }\n // Left edge (reversed)\n for (let i = steps - 1; i >= 1; i--) {\n const t = i / steps;\n points.push([\n x + (rng() - 0.5) * jitter,\n y + t * h + (rng() - 0.5) * jitter,\n ]);\n }\n return points;\n}\n\nfunction mulberry32(seed: number) {\n let s = seed;\n return () => {\n s |= 0;\n s = (s + 0x6d2b79f5) | 0;\n let t = Math.imul(s ^ (s >>> 15), 1 | s);\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n };\n}\n\nexport function Scribble({\n children,\n className,\n color = \"currentColor\",\n id,\n opacity = 0.85,\n padding = 4,\n style,\n theme: themeProp,\n type = \"scrawl\",\n ...props\n}: ScribbleProps) {\n const containerRef = useRef(null);\n const svgRef = useRef(null);\n const seed = stableSeed(id ?? `scribble-${type}`);\n const { drawEllipse, drawLine, drawPolygon, getOptions, theme } = useRough({\n variant: \"border\",\n stableId: `scribble-${type}`,\n svgRef,\n theme: themeProp,\n });\n\n const strokeW = theme === \"crayon\" ? 3 : theme === \"ink\" ? 2.2 : 1.8;\n const jitter = theme === \"crayon\" ? 6 : theme === \"ink\" ? 2 : 3;\n\n const draw = useCallback(() => {\n const container = containerRef.current;\n const svg = svgRef.current;\n if (!container || !svg) return;\n\n ensureKeyframes();\n svg.replaceChildren();\n\n const w = container.offsetWidth;\n const h = container.offsetHeight;\n const pad = padding;\n\n const svgW = w + pad * 2 + 8;\n const svgH = h + pad * 2 + 8;\n svg.setAttribute(\"width\", String(svgW));\n svg.setAttribute(\"height\", String(svgH));\n svg.setAttribute(\"viewBox\", `0 0 ${svgW} ${svgH}`);\n\n const cx = pad + 4;\n const cy = pad + 4;\n\n const baseOpts = getOptions({\n seed,\n stroke: color,\n strokeWidth: strokeW,\n });\n\n switch (type) {\n case \"redact\": {\n const pts1 = noisyRect(\n cx - pad,\n cy - pad,\n w + pad * 2,\n h + pad * 2,\n jitter,\n seed,\n );\n const pts2 = noisyRect(\n cx - pad,\n cy - pad,\n w + pad * 2,\n h + pad * 2,\n jitter,\n seed + 7,\n );\n const node1 = drawPolygon(pts1, {\n ...baseOpts,\n fill: color,\n fillStyle: \"solid\",\n roughness: 2,\n stroke: color,\n strokeWidth: strokeW * 0.5,\n }) as SVGGElement | null;\n const node2 = drawPolygon(pts2, {\n ...baseOpts,\n fill: color,\n fillStyle: \"hachure\",\n hachureGap: 3,\n roughness: 2.5,\n stroke: color,\n strokeWidth: strokeW * 0.3,\n }) as SVGGElement | null;\n if (node1) {\n node1.style.opacity = String(opacity);\n svg.appendChild(node1);\n }\n if (node2) {\n node2.style.opacity = String(opacity * 0.6);\n svg.appendChild(node2);\n }\n break;\n }\n\n case \"scrawl\": {\n const lines = [\n [cx - pad, cy - pad, cx + w + pad, cy + h + pad],\n [cx + w + pad, cy - pad, cx - pad, cy + h + pad],\n ];\n lines.forEach((l, li) => {\n for (let pass = 0; pass < 2; pass++) {\n const node = drawLine(l[0], l[1], l[2], l[3], {\n ...baseOpts,\n roughness: theme === \"crayon\" ? 3 : 2,\n seed: seed + li * 10 + pass,\n strokeWidth: strokeW * (pass === 0 ? 1 : 0.7),\n }) as SVGGElement | null;\n if (!node) return;\n svg.appendChild(node);\n node.querySelectorAll(\"path\").forEach((path) => {\n const len = path.getTotalLength();\n const dur = 200;\n const delay = (li * 2 + pass) * 120;\n path.style.strokeDasharray = String(len);\n path.style.strokeDashoffset = String(len);\n path.style.animation = `crumble-annotation-dash ${dur}ms ease-out ${delay}ms forwards`;\n });\n }\n });\n break;\n }\n\n case \"circle\": {\n const cxe = cx + w / 2;\n const cye = cy + h / 2;\n const ew = w + pad * 3.5;\n const eh = h + pad * 3.5;\n\n const fillNode = drawEllipse(cxe, cye, ew, eh, {\n ...baseOpts,\n fill: color,\n fillStyle: \"hachure\",\n hachureGap: 4,\n roughness: 2.5,\n stroke: \"none\",\n }) as SVGGElement | null;\n const strokeNode = drawEllipse(cxe, cye, ew, eh, {\n ...baseOpts,\n fill: \"none\",\n roughness: 2,\n strokeWidth: strokeW * 1.2,\n }) as SVGGElement | null;\n\n if (fillNode) {\n fillNode.style.opacity = String(opacity * 0.35);\n svg.appendChild(fillNode);\n }\n if (strokeNode) {\n strokeNode.style.opacity = String(opacity);\n svg.appendChild(strokeNode);\n strokeNode.querySelectorAll(\"path\").forEach((path) => {\n const len = path.getTotalLength();\n path.style.strokeDasharray = String(len);\n path.style.strokeDashoffset = String(len);\n path.style.animation = `crumble-annotation-dash 500ms ease-out 0ms forwards`;\n });\n }\n break;\n }\n\n case \"blob\": {\n const blobPts = noisyRect(\n cx - pad * 1.5,\n cy - pad * 1.5,\n w + pad * 3,\n h + pad * 3,\n jitter * 1.5,\n seed,\n 8,\n );\n const node = drawPolygon(blobPts, {\n ...baseOpts,\n fill: color,\n fillStyle: \"solid\",\n roughness: 1.5,\n stroke: color,\n strokeWidth: strokeW * 0.6,\n }) as SVGGElement | null;\n const outline = drawPolygon(blobPts, {\n ...baseOpts,\n fill: \"none\",\n roughness: 2,\n seed: seed + 3,\n stroke: color,\n strokeWidth: strokeW,\n }) as SVGGElement | null;\n if (node) {\n node.style.opacity = String(opacity * 0.28);\n svg.appendChild(node);\n }\n if (outline) {\n outline.style.opacity = String(opacity * 0.7);\n svg.appendChild(outline);\n }\n break;\n }\n }\n }, [\n color,\n drawEllipse,\n drawLine,\n drawPolygon,\n getOptions,\n jitter,\n opacity,\n padding,\n seed,\n strokeW,\n theme,\n type,\n ]);\n\n useEffect(() => {\n const raf = requestAnimationFrame(() => draw());\n return () => cancelAnimationFrame(raf);\n }, [draw]);\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 return (\n \n {children}\n \n \n );\n}\n", "type": "registry:ui", "target": "components/crumble/ui/scribble.tsx" } ], "type": "registry:ui" }