{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"ink-well","type":"registry:component","title":"Ink well","description":"Drains a glass inkwell as you spend your character limit.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/ink-well.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// The bottle, in viewBox units (100 x 124): a squat, heavy-based inkwell\n// with sloped shoulders and a short neck. OUTER is the glass surface, INNER\n// the cavity the ink fills; the gap between them is the glass itself.\nconst OUTER =\n  \"M36 36V48C36 54 8 54 8 66V108Q8 116 16 116H84Q92 116 92 108V66C92 54 64 54 64 48V36Z\";\nconst INNER =\n  \"M39 36V49C39 57 11 57 11 67V104Q11 108 15 108H85Q89 108 89 104V67C89 57 61 57 61 49V36Z\";\n// The ink line when full (just under the shoulders) and when dry.\nconst FULL_Y = 64;\nconst DRY_Y = 107.5;\nconst LEFT = 11;\nconst RIGHT = 89;\n// Ink is a physical material: indigo-black, lifted a little in the dark\n// theme so it still reads as liquid against a dark page.\nconst INK = \"light-dark(oklch(0.3 0.08 268), oklch(0.44 0.1 268))\";\n// The dip pen's wood holder, brass ferrule and steel nib, also physical.\nconst WOOD = \"oklch(0.56 0.09 52)\";\nconst BRASS = \"oklch(0.78 0.11 85)\";\nconst STEEL = \"oklch(0.8 0.01 250)\";\n// Ruled like writing paper: one rule under each 24px line, scrolling with\n// the text. The first layer paints over the 14px top padding, where the\n// repeat would otherwise put a stray rule above the first line.\nconst RULED = {\n  backgroundImage:\n    \"linear-gradient(var(--background), var(--background)), repeating-linear-gradient(to bottom, transparent 0 23px, var(--border) 23px 24px)\",\n  backgroundSize: \"100% 14px, 100% 24px\",\n  backgroundRepeat: \"no-repeat, repeat\",\n  backgroundPosition: \"0 0, 0 14px\",\n  backgroundAttachment: \"local\",\n} as const;\n// The level follows the count on a slightly loose spring so each keystroke\n// lets the ink settle rather than step.\nconst LEVEL_K = 90;\nconst LEVEL_C = 16;\n// Surface ripples die out over about a second and a half once typing stops.\nconst WAVE_DECAY = 2.4;\nconst WAVE_MAX = 3.2;\n// Below this, the surface is flat enough to stop drawing frames.\nconst REST = 0.02;\n// Remaining-count fraction where the ink turns to the danger color.\nconst WARN = 0.1;\n\ntype Bucket = \"ok\" | \"warn\" | \"full\" | \"over\";\n\nfunction bucketFor(remaining: number, limit: number): Bucket {\n  if (remaining < 0) return \"over\";\n  if (remaining === 0) return \"full\";\n  if (remaining <= Math.max(1, Math.round(limit * WARN))) return \"warn\";\n  return \"ok\";\n}\n\n// The body of ink, closed below the bottle.\nfunction surfacePath(level: number, amp: number, phase: number) {\n  return `${surfaceLine(level, amp, phase)}L${RIGHT} 124L${LEFT} 124Z`;\n}\n\n// Just the top edge, drawn again as the thin bright line where light\n// catches the meniscus.\nfunction surfaceLine(level: number, amp: number, phase: number) {\n  let d = \"\";\n  // Two detuned sines read as liquid; a single one reads as a graph.\n  for (let x = LEFT; x <= RIGHT; x += 2) {\n    const y =\n      level +\n      amp * Math.sin(x * 0.16 + phase) +\n      amp * 0.45 * Math.sin(x * 0.37 - phase * 1.4);\n    d += `${d ? \"L\" : \"M\"}${x} ${y.toFixed(2)}`;\n  }\n  return d;\n}\n\nexport function InkWell({\n  value,\n  onChange,\n  limit = 200,\n  label,\n  placeholder,\n  className,\n}: {\n  value: string;\n  onChange: (value: string) => void;\n  limit?: number;\n  label: string;\n  placeholder?: string;\n  className?: string;\n}) {\n  const id = useId();\n  const reduceMotion = useReducedMotion();\n  const remaining = limit - value.length;\n  const bucket = bucketFor(remaining, limit);\n  const fraction = Math.min(Math.max(remaining / limit, 0), 1);\n  const target = DRY_Y - (DRY_Y - FULL_Y) * fraction;\n\n  const inkRef = useRef<SVGPathElement>(null);\n  const lineRef = useRef<SVGPathElement>(null);\n  // Rendered once; after that only the rAF loop writes the surface, so a\n  // re-render mid-slosh never snaps it flat.\n  const [initialInk] = useState(() => surfacePath(target, 0, 0));\n  const [initialLine] = useState(() => surfaceLine(target, 0, 0));\n  const sim = useRef({ level: target, vel: 0, amp: 0, phase: 0, target });\n  const frame = useRef(0);\n  const lastInput = useRef(0);\n\n  // Announce only when crossing a threshold; a running count in a live\n  // region would talk over every keystroke.\n  const [announcement, setAnnouncement] = useState(\"\");\n  const lastBucket = useRef(bucket);\n  useEffect(() => {\n    if (bucket === lastBucket.current) return;\n    lastBucket.current = bucket;\n    setAnnouncement(\n      bucket === \"warn\"\n        ? `${remaining} characters left`\n        : bucket === \"full\"\n          ? \"Character limit reached\"\n          : bucket === \"over\"\n            ? \"Over the character limit\"\n            : \"\",\n    );\n    // Only the crossing matters here, not every count inside a bucket.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [bucket]);\n\n  const draw = () => {\n    const s = sim.current;\n    inkRef.current?.setAttribute(\"d\", surfacePath(s.level, s.amp, s.phase));\n    lineRef.current?.setAttribute(\"d\", surfaceLine(s.level, s.amp, s.phase));\n  };\n\n  const run = () => {\n    if (frame.current) return;\n    let last = performance.now();\n    const tick = (now: number) => {\n      const s = sim.current;\n      const dt = Math.min((now - last) / 1000, 1 / 30);\n      last = now;\n      s.vel += (-LEVEL_K * (s.level - s.target) - LEVEL_C * s.vel) * dt;\n      s.level += s.vel * dt;\n      s.amp *= Math.exp(-WAVE_DECAY * dt);\n      s.phase += dt * 7;\n      draw();\n      const still =\n        s.amp < REST &&\n        Math.abs(s.vel) < REST &&\n        Math.abs(s.level - s.target) < REST;\n      if (still) {\n        s.amp = 0;\n        s.level = s.target;\n        draw();\n        frame.current = 0;\n        return;\n      }\n      frame.current = requestAnimationFrame(tick);\n    };\n    frame.current = requestAnimationFrame(tick);\n  };\n\n  useEffect(() => {\n    const s = sim.current;\n    s.target = target;\n    if (reduceMotion) {\n      cancelAnimationFrame(frame.current);\n      frame.current = 0;\n      Object.assign(s, { level: target, vel: 0, amp: 0 });\n      draw();\n      return;\n    }\n    run();\n    // run and draw only touch refs.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [target, reduceMotion]);\n\n  useEffect(\n    () => () => {\n      cancelAnimationFrame(frame.current);\n      // Cleared too, or a remount (Strict Mode does one) would think the\n      // cancelled loop is still running and never start a new one.\n      frame.current = 0;\n    },\n    [],\n  );\n\n  const slosh = () => {\n    if (reduceMotion) return;\n    const now = performance.now();\n    const gap = now - lastInput.current;\n    lastInput.current = now;\n    // Quick typing sloshes harder; a lone key after a pause barely ripples.\n    const kick = Math.min(1, 90 / Math.max(gap, 30)) * 0.9 + 0.15;\n    const s = sim.current;\n    s.amp = Math.min(WAVE_MAX, s.amp + kick);\n    run();\n  };\n\n  const dry = remaining <= 0;\n  const hot = bucket !== \"ok\";\n\n  return (\n    <div className={cn(\"w-[min(520px,100%)]\", className)}>\n      <label\n        htmlFor={`${id}-field`}\n        className=\"mb-2 block text-sm font-medium text-foreground\"\n      >\n        {label}\n      </label>\n      <div className=\"flex items-stretch gap-4 sm:gap-6\">\n        <textarea\n          id={`${id}-field`}\n          value={value}\n          placeholder={placeholder}\n          aria-describedby={`${id}-count`}\n          aria-invalid={remaining < 0 || undefined}\n          onChange={(e) => {\n            onChange(e.target.value);\n            slosh();\n          }}\n          rows={6}\n          style={RULED}\n          className={cn(\n            \"block h-[208px] min-w-0 flex-1 resize-none rounded-xl bg-background px-4 py-3.5 text-[15px] leading-6 text-foreground shadow-raised outline-hidden transition-[box-shadow] duration-150 ease-out placeholder:text-muted-foreground focus:shadow-[0_0_0_1.5px_var(--foreground)]\",\n            remaining < 0 &&\n              \"shadow-[0_0_0_1.5px_var(--destructive)] focus:shadow-[0_0_0_1.5px_var(--destructive)]\",\n          )}\n        />\n        <div className=\"flex w-[92px] shrink-0 flex-col items-center justify-end sm:w-[120px]\">\n          <svg\n            viewBox=\"0 0 100 124\"\n            className=\"w-full overflow-visible\"\n            aria-hidden\n          >\n            <defs>\n              <clipPath id={`${id}-cavity`}>\n                <path d={INNER} />\n              </clipPath>\n            </defs>\n            {/* Where the heavy base meets the desk. */}\n            <ellipse\n              cx={50}\n              cy={117}\n              rx={46}\n              ry={4}\n              className=\"fill-black/10 blur-[2px] dark:fill-black/60\"\n            />\n            {/* The far wall of the glass, seen through the cavity. */}\n            <path d={INNER} className=\"fill-foreground/[0.04]\" />\n\n            {/* A dip pen resting in the well, leaning on the neck. The part\n                under the ink line is hidden by the ink drawn over it, so\n                it surfaces as the well drains. */}\n            <g transform=\"translate(47 101) rotate(12.2)\">\n              <path d=\"M0 0L-2.4 -11H2.4Z\" fill={STEEL} />\n              <path d=\"M0 -2V-9\" stroke=\"oklch(0.45 0.01 250)\" strokeWidth={0.6} />\n              <rect x={-3} y={-17} width={6} height={6} rx={1} fill={BRASS} />\n              <path d=\"M-2.6 -17L-2 -106Q0 -109 2 -106L2.6 -17Z\" fill={WOOD} />\n              <path\n                d=\"M-1.2 -20L-1 -100\"\n                stroke=\"oklch(1 0 0 / 0.35)\"\n                strokeWidth={0.8}\n                strokeLinecap=\"round\"\n              />\n            </g>\n\n            <g clipPath={`url(#${id}-cavity)`}>\n              {/* A dried ring where the last of the ink sat. */}\n              <ellipse\n                cx={50}\n                cy={106}\n                rx={34}\n                ry={2}\n                className={cn(\n                  \"fill-none transition-[opacity] duration-300 ease-out\",\n                  dry ? \"opacity-40\" : \"opacity-0\",\n                )}\n                stroke={INK}\n                strokeWidth={1}\n              />\n              <path\n                ref={inkRef}\n                d={initialInk}\n                className={cn(\n                  \"transition-[fill] duration-200 ease-out\",\n                  hot && \"fill-destructive\",\n                )}\n                style={hot ? undefined : { fill: INK }}\n              />\n              {/* Light catching the meniscus. */}\n              <path\n                ref={lineRef}\n                d={initialLine}\n                fill=\"none\"\n                stroke=\"oklch(1 0 0 / 0.35)\"\n                strokeWidth={1}\n                className={cn(\n                  \"transition-[opacity] duration-300 ease-out\",\n                  dry && \"opacity-0\",\n                )}\n              />\n            </g>\n\n            {/* The glass itself: the band between the two walls, thickest\n                in the base, tinting what sits behind it. */}\n            <path\n              d={`${OUTER}${INNER}`}\n              fillRule=\"evenodd\"\n              className=\"fill-foreground/[0.08]\"\n            />\n            <path\n              d={OUTER}\n              fill=\"none\"\n              strokeWidth={1.25}\n              strokeLinejoin=\"round\"\n              className=\"stroke-foreground/30\"\n            />\n            <path\n              d={INNER}\n              fill=\"none\"\n              strokeWidth={0.75}\n              className=\"stroke-foreground/10\"\n            />\n            {/* The rolled lip of the neck. */}\n            <rect\n              x={33}\n              y={31}\n              width={34}\n              height={6}\n              rx={2.5}\n              strokeWidth={1.25}\n              className=\"fill-muted stroke-foreground/30\"\n            />\n            {/* Specular light on curved glass is white in any room. */}\n            <path\n              d=\"M14 72V100\"\n              strokeWidth={3}\n              strokeLinecap=\"round\"\n              className=\"stroke-white/70 dark:stroke-white/15\"\n            />\n            <path\n              d=\"M19.5 62C24 58 30 57 34 55\"\n              fill=\"none\"\n              strokeWidth={2}\n              strokeLinecap=\"round\"\n              className=\"stroke-white/70 dark:stroke-white/15\"\n            />\n            <path\n              d=\"M85 76V90\"\n              strokeWidth={1.5}\n              strokeLinecap=\"round\"\n              className=\"stroke-white/60 dark:stroke-white/10\"\n            />\n            <path\n              d=\"M22 112.5H78\"\n              strokeWidth={1}\n              strokeLinecap=\"round\"\n              className=\"stroke-white/60 dark:stroke-white/10\"\n            />\n          </svg>\n          <p\n            aria-hidden\n            className={cn(\n              \"mt-2 text-center text-[13px] leading-tight transition-[color] duration-200 ease-out\",\n              hot ? \"text-destructive\" : \"text-muted-foreground\",\n            )}\n          >\n            <span className=\"block text-2xl font-semibold tracking-tight tabular-nums\">\n              {remaining}\n            </span>\n            {remaining < 0 ? \"over the limit\" : \"left\"}\n          </p>\n          <span id={`${id}-count`} className=\"sr-only\">\n            {remaining < 0\n              ? `${-remaining} characters over the ${limit} character limit`\n              : `${remaining} of ${limit} characters remaining`}\n          </span>\n        </div>\n      </div>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\nexport default function InkWellDemo() {\n  const [text, setText] = useState(\n    \"Dear Ada, the prototype finally runs. \",\n  );\n  return (\n    <InkWell\n      label=\"Postcard\"\n      placeholder=\"Write a short note\"\n      limit={160}\n      value={text}\n      onChange={setText}\n    />\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["objects"],"docs":"From ui lab: https://lab.xevrion.dev/lab/ink-well"}