{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "textarea",
  "type": "registry:ui",
  "title": "Textarea",
  "description": "An accessible React textarea for Next.js that grows smoothly with its content, with min and max rows, styled like Input with Tailwind CSS.",
  "author": "FewTone <https://fewtone.com>",
  "dependencies": [
    "motion@^14.0.0"
  ],
  "registryDependencies": [
    "https://ui.fewtone.com/r/tokens.json",
    "https://ui.fewtone.com/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/fewtone/ui/textarea.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { duration, ease } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { type AnimationPlaybackControls, animate, useReducedMotion } from \"motion/react\";\nimport {\n  type ComponentProps,\n  type PointerEvent as ReactPointerEvent,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n} from \"react\";\n\n/*\n * A multi-line text field that grows with its content (roadmap: \"grows smoothly with its content\").\n * - Look: the same as Input (border, rounded-lg, text-sm, and the focus, invalid and disabled states).\n * - Growth (.agents/rules/motion.md, the height pattern): as lines come and go, the height glides to fit on\n *   an eased tween (duration.base, ease.out), never a spring, whose overshoot would bounce the page below.\n *   `minRows` and `maxRows` bound it; past maxRows it scrolls. With reduced motion it resizes at once.\n * - First paint: CSS `field-sizing: content` sizes it to its text before any JavaScript runs, so a value\n *   rendered on the server doesn't jump on hydration. (Browsers without it start at minRows and fit on\n *   hydration.) Once hydrated, JavaScript takes over: it measures scrollHeight and animates the height in\n *   pixels, because CSS can't transition a height that comes from field-sizing.\n * - Layout pixels throughout (scrollHeight, offsetHeight), not on-screen boxes, so a zoomed or scaled\n *   preview doesn't double the glide.\n * - While it grows, the text stays put: the browser scrolls a new line into view inside the shorter box,\n *   so the text would jump up and slide back down as the box opened. The scroll is held at the top instead,\n *   and the box opens onto the new line.\n * - Nothing animates on first paint, on a resize or when webfonts load: those fit at once.\n * - Drag to resize (owner, 2026-10-22: \"like a normal textarea, in our style\"): press the bottom-right corner,\n *   where the cursor turns to a resize arrow. Nothing is drawn there (the owner removed a two-stroke grip the\n *   same day), and it's the textarea's own pointer events, so there's still no wrapper. Dragging sets the height, held between minRows and maxRows by the min and max heights;\n *   it follows the pointer exactly, with no glide. The height you drag to becomes the field's floor: it keeps\n *   it as you type, and still grows past it when the text needs more room.\n */\n\n/** The corner you drag by, a square in the bottom-right, in px: the small icon size (--size-icon-sm) */\nconst GRIP = 14;\n\n/** Whether the pointer is on the corner you drag by. In on-screen pixels, so a zoomed or scaled field still matches. */\nfunction onGrip(node: HTMLTextAreaElement, event: { clientX: number; clientY: number }) {\n  const box = node.getBoundingClientRect();\n  // Not drawn (display: none, or no layout at all)\n  if (box.width === 0) return false;\n  const zone = GRIP * (box.height / (node.offsetHeight || box.height || 1));\n  return event.clientX >= box.right - zone && event.clientY >= box.bottom - zone;\n}\n\n/**\n * The height of a number of lines: the lines (`lh` is the field's own line height), plus the vertical padding\n * (py-2, 1rem in all) and the border (1px each side) from the classes below. CSS, so it holds on first paint.\n */\nconst linesTall = (lines: number) => `calc(${lines}lh + 1rem + 2px)`;\n\n/**\n * The height that fits the text (`fits`, the min height holding it at minRows), and the max height (NaN when\n * there's none)\n */\nfunction measure(node: HTMLTextAreaElement) {\n  const { height, overflowY } = node.style;\n  const { scrollTop } = node;\n  // At 0 the min height holds it at minRows, and scrollHeight is the text plus the padding. Hidden overflow,\n  // so a scrollbar doesn't narrow the text while it's measured.\n  node.style.overflowY = \"hidden\";\n  node.style.height = \"0px\";\n  const text = node.scrollHeight;\n  node.style.height = height;\n  node.style.overflowY = overflowY;\n  node.scrollTop = scrollTop;\n\n  const style = getComputedStyle(node);\n  const border =\n    (Number.parseFloat(style.borderTopWidth) || 0) + (Number.parseFloat(style.borderBottomWidth) || 0);\n  // NaN when there's no max, and nothing is ever more than NaN\n  const max = Number.parseFloat(style.maxHeight);\n  return { fits: text + border, max };\n}\n\nexport type TextareaProps = Omit<ComponentProps<\"textarea\">, \"rows\"> & {\n  /** The lines it shows when it's empty. It never gets shorter. */\n  minRows?: number;\n  /** The lines it grows to before it scrolls. Unlimited when left out. */\n  maxRows?: number;\n};\n\n/**\n * A multi-line text field that grows as you type, from `minRows` up to `maxRows`. Pair it with a visible\n * label (`<label htmlFor>`), and set `aria-invalid` when the value is wrong.\n */\nfunction Textarea({\n  className,\n  style,\n  minRows = 3,\n  maxRows,\n  value,\n  onChange,\n  onPointerDown,\n  onPointerMove,\n  onPointerLeave,\n  ref,\n  ...props\n}: TextareaProps) {\n  const local = useRef<HTMLTextAreaElement | null>(null);\n  const running = useRef<AnimationPlaybackControls | null>(null);\n  // The height it's at or on its way to, so fitting the same text twice doesn't restart the glide\n  const target = useRef<number | null>(null);\n  const fitted = useRef(false);\n  // The height it was dragged to, the floor it keeps from then on (0: never dragged)\n  const dragged = useRef(0);\n  const reduce = useReducedMotion() ?? false;\n\n  const resize = useCallback(\n    (glide: boolean) => {\n      const node = local.current;\n      // Not on the page (a closed tab, display: none): the observer below fits it once it shows\n      if (!node || node.clientWidth === 0) return;\n      const { fits, max } = measure(node);\n      // The text's height, or the height it was dragged to if that's more, up to the max\n      const wanted = Math.max(fits, dragged.current);\n      const height = Number.isNaN(max) ? wanted : Math.min(wanted, max);\n      const next = { height, overflows: fits > height };\n      node.style.overflowY = next.overflows ? \"auto\" : \"hidden\";\n      if (next.height === target.current) return;\n      target.current = next.height;\n      running.current?.stop();\n\n      const from = node.offsetHeight;\n      if (!glide || reduce || from === next.height) {\n        node.style.height = `${next.height}px`;\n        return;\n      }\n      // Growing into room it has: keep the text at the top while the box opens (see the note at the top)\n      const hold = next.height > from && !next.overflows;\n      if (hold) node.scrollTop = 0;\n      running.current = animate(from, next.height, {\n        duration: duration.base,\n        ease: ease.out,\n        onUpdate: (height) => {\n          node.style.height = `${height}px`;\n          if (hold) node.scrollTop = 0;\n        },\n      });\n    },\n    [reduce],\n  );\n\n  // On first paint it fits at once. A value or limits set from outside (controlled, a Clear button) glide.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: a new value or new limits refit it\n  useLayoutEffect(() => {\n    resize(fitted.current);\n    fitted.current = true;\n  }, [value, minRows, maxRows, resize]);\n\n  useEffect(() => {\n    const node = local.current;\n    if (!node) return;\n    // Text wraps differently at a new width, so refit at once; the height changes we make are ignored\n    let width = node.clientWidth;\n    const observer = new ResizeObserver(() => {\n      if (node.clientWidth === width) return;\n      width = node.clientWidth;\n      resize(false);\n    });\n    observer.observe(node);\n    // A webfont that loads late can change how the text wraps\n    let live = true;\n    document.fonts?.ready.then(() => {\n      if (live) resize(false);\n    });\n    // A form reset puts the first value back without an input event\n    const form = node.form;\n    let frame = 0;\n    const onReset = () => {\n      frame = requestAnimationFrame(() => resize(true));\n    };\n    form?.addEventListener(\"reset\", onReset);\n    return () => {\n      live = false;\n      observer.disconnect();\n      form?.removeEventListener(\"reset\", onReset);\n      cancelAnimationFrame(frame);\n    };\n  }, [resize]);\n\n  // Unmounting mid-glide stops it\n  useEffect(() => () => running.current?.stop(), []);\n\n  const setRef = useCallback(\n    (node: HTMLTextAreaElement | null) => {\n      local.current = node;\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) ref.current = node;\n    },\n    [ref],\n  );\n\n  // Drag the corner: the height follows the pointer exactly, from the height it had when you pressed\n  const startDrag = (event: ReactPointerEvent<HTMLTextAreaElement>) => {\n    const node = event.currentTarget;\n    running.current?.stop();\n    // No caret and no text selection from the press\n    event.preventDefault();\n    const scale = node.offsetHeight / (node.getBoundingClientRect().height || node.offsetHeight || 1);\n    const startY = event.clientY;\n    const startHeight = node.offsetHeight;\n    node.setPointerCapture?.(event.pointerId);\n    node.dataset.resizing = \"\";\n\n    const move = (moved: PointerEvent) => {\n      node.style.height = `${startHeight + (moved.clientY - startY) * scale}px`;\n      // The min and max heights hold it between minRows and maxRows: what it ends up at is the new floor\n      const height = node.offsetHeight;\n      dragged.current = height;\n      target.current = height;\n      node.style.overflowY = measure(node).fits > height ? \"auto\" : \"hidden\";\n    };\n    const end = () => {\n      delete node.dataset.resizing;\n      node.removeEventListener(\"pointermove\", move);\n      node.removeEventListener(\"pointerup\", end);\n      node.removeEventListener(\"pointercancel\", end);\n    };\n    node.addEventListener(\"pointermove\", move);\n    node.addEventListener(\"pointerup\", end);\n    node.addEventListener(\"pointercancel\", end);\n  };\n\n  return (\n    <textarea\n      ref={setRef}\n      data-slot=\"textarea\"\n      // Only for browsers without field-sizing, before hydration; the min height does the same job\n      rows={minRows}\n      value={value}\n      onChange={(event) => {\n        onChange?.(event);\n        // After React has settled the value: a controlled field may change or refuse what was typed\n        queueMicrotask(() => resize(true));\n      }}\n      onPointerDown={(event) => {\n        onPointerDown?.(event);\n        if (event.defaultPrevented || event.button !== 0 || !onGrip(event.currentTarget, event)) return;\n        startDrag(event);\n      }}\n      // The resize cursor over the corner (data-grip), as on a native textarea's corner\n      onPointerMove={(event) => {\n        onPointerMove?.(event);\n        event.currentTarget.toggleAttribute(\"data-grip\", onGrip(event.currentTarget, event));\n      }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event);\n        event.currentTarget.removeAttribute(\"data-grip\");\n      }}\n      style={{\n        minHeight: linesTall(minRows),\n        maxHeight: maxRows === undefined ? undefined : linesTall(Math.max(maxRows, minRows)),\n        ...style,\n      }}\n      className={cn(\n        \"block w-full min-w-0 resize-none field-sizing-content rounded-lg border border-input bg-transparent px-3 py-2 text-sm text-foreground shadow-xs\",\n        // The resize cursor over the corner and while dragging\n        \"data-grip:cursor-ns-resize data-resizing:cursor-ns-resize data-resizing:select-none\",\n        \"transition duration-(--duration-fast) ease-out\",\n        \"placeholder:text-muted-foreground\",\n        \"outline-ring focus-visible:border-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)\",\n        \"aria-invalid:border-destructive aria-invalid:outline-destructive\",\n        \"disabled:cursor-not-allowed disabled:opacity-(--opacity-disabled)\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Textarea };\n"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Usage and examples: https://ui.fewtone.com/components/textarea"
}
