{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"textarea","type":"registry:component","title":"Textarea","description":"Multi-line companion to Input with the same label, error shake and message API plus a description line, auto-growing between minRows and maxRows through a measured height transition before it scrolls behind a thin themed scrollbar, and an optional maxLength counter that shifts to warning then destructive near the limit and shakes when a keystroke or paste is refused.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/textarea.tsx","type":"registry:component","target":"@components/motion/textarea.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/textarea\n\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type TextareaHTMLAttributes,\n} from \"react\";\nimport { EASE_OUT_CSS, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type TextareaClassNames = {\n  root?: string;\n  label?: string;\n  description?: string;\n  field?: string;\n  textarea?: string;\n  counter?: string;\n  errorMessage?: string;\n};\n\nexport interface TextareaProps\n  extends Omit<\n    TextareaHTMLAttributes<HTMLTextAreaElement>,\n    \"value\" | \"defaultValue\" | \"onChange\" | \"rows\"\n  > {\n  label?: string;\n  /** Helper text under the label, linked through aria-describedby. */\n  description?: string;\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  /** Truthy error triggers a shake, red border and (if a string) a message. */\n  error?: string | boolean;\n  /** Rows shown when empty; the field never shrinks below this. */\n  minRows?: number;\n  /** Rows the field grows to before it starts scrolling. */\n  maxRows?: number;\n  /** Shows the character counter. Defaults to true when `maxLength` is set. */\n  showCount?: boolean;\n  /** Fraction of `maxLength` at which the counter turns to the warning color. */\n  warnAt?: number;\n  className?: string;\n  classNames?: TextareaClassNames;\n}\n\nconst SHAKE = { x: [0, -6, 6, -4, 4, -2, 0] };\n\nexport const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(\n  function Textarea(\n    {\n      label,\n      description,\n      value: valueProp,\n      defaultValue,\n      onChange,\n      onFocus,\n      onBlur,\n      error,\n      minRows = 3,\n      maxRows = 8,\n      maxLength,\n      showCount,\n      warnAt = 0.9,\n      placeholder,\n      className,\n      classNames,\n      disabled,\n      id: idProp,\n      ...rest\n    },\n    forwardedRef,\n  ) {\n    const reactId = useId();\n    const id = idProp ?? reactId;\n    const reduce = useReducedMotion();\n\n    const controlled = valueProp !== undefined;\n    const [internal, setInternal] = useState(defaultValue ?? \"\");\n    const value = controlled ? (valueProp ?? \"\") : internal;\n\n    const [focused, setFocused] = useState(false);\n    const [height, setHeight] = useState<number | null>(null);\n    const [scrolls, setScrolls] = useState(false);\n\n    const fieldRef = useRef<HTMLDivElement>(null);\n    const counterRef = useRef<HTMLSpanElement>(null);\n    const mirrorRef = useRef<HTMLTextAreaElement>(null);\n    const innerRef = useRef<HTMLTextAreaElement | null>(null);\n\n    const setRefs = useCallback(\n      (node: HTMLTextAreaElement | null) => {\n        innerRef.current = node;\n        if (typeof forwardedRef === \"function\") forwardedRef(node);\n        else if (forwardedRef) forwardedRef.current = node;\n      },\n      [forwardedRef],\n    );\n\n    const hasError = Boolean(error);\n    const errorMessage = typeof error === \"string\" ? error : null;\n    const countVisible = showCount ?? maxLength !== undefined;\n    const count = value.length;\n    const ratio = maxLength ? count / maxLength : 0;\n    const atLimit = maxLength !== undefined && count >= maxLength;\n    const nearLimit = maxLength !== undefined && ratio >= warnAt && !atLimit;\n\n    // Measure the content with an invisible twin so the real field can\n    // transition between pixel heights (resetting it to `auto` would kill the\n    // transition). Height is this component's purpose, so it animates layout.\n    const measure = useCallback(() => {\n      const mirror = mirrorRef.current;\n      if (!mirror) return;\n      const style = getComputedStyle(mirror);\n      const lineHeight = Number.parseFloat(style.lineHeight) || 24;\n      const padY =\n        Number.parseFloat(style.paddingTop) +\n        Number.parseFloat(style.paddingBottom);\n      const min = lineHeight * minRows + padY;\n      const max = lineHeight * Math.max(minRows, maxRows) + padY;\n      const content = mirror.scrollHeight;\n      setHeight(Math.min(max, Math.max(min, content)));\n      setScrolls(content > max + 1);\n    }, [minRows, maxRows]);\n\n    useLayoutEffect(() => {\n      if (mirrorRef.current) mirrorRef.current.value = value || \" \";\n      measure();\n    }, [value, measure]);\n\n    // Width changes rewrap the text, so re-measure on resize.\n    useEffect(() => {\n      const field = fieldRef.current;\n      if (!field || typeof ResizeObserver === \"undefined\") return;\n      const observer = new ResizeObserver(() => measure());\n      observer.observe(field);\n      return () => observer.disconnect();\n    }, [measure]);\n\n    // Shake the field when an error appears.\n    useEffect(() => {\n      if (!fieldRef.current || reduce || !hasError) return;\n      animate(fieldRef.current, SHAKE, { duration: 0.45 });\n    }, [hasError, reduce]);\n\n    const nudgeCounter = useCallback(() => {\n      if (!counterRef.current || reduce) return;\n      animate(\n        counterRef.current,\n        { x: [0, -4, 4, -3, 3, 0] },\n        { duration: 0.35 },\n      );\n    }, [reduce]);\n\n    // Typing or pasting past `maxLength` is blocked natively; catch the\n    // attempt first so the counter can shake. Native listener because React's\n    // onBeforeInput misses paste and line breaks.\n    useEffect(() => {\n      const el = innerRef.current;\n      if (!el || maxLength === undefined) return;\n      const onBeforeInput = (event: InputEvent) => {\n        if (!event.inputType.startsWith(\"insert\")) return;\n        const data =\n          event.data ??\n          event.dataTransfer?.getData(\"text/plain\") ??\n          (event.inputType === \"insertLineBreak\" ? \"\\n\" : \"\");\n        const selected = el.selectionEnd - el.selectionStart;\n        if (el.value.length - selected + data.length > maxLength) {\n          nudgeCounter();\n        }\n      };\n      el.addEventListener(\"beforeinput\", onBeforeInput);\n      return () => el.removeEventListener(\"beforeinput\", onBeforeInput);\n    }, [maxLength, nudgeCounter]);\n\n    const handleChange = (next: string) => {\n      if (!controlled) setInternal(next);\n      onChange?.(next);\n    };\n\n    const describedBy =\n      [\n        description ? `${id}-description` : null,\n        countVisible ? `${id}-count` : null,\n        errorMessage ? `${id}-error` : null,\n      ]\n        .filter(Boolean)\n        .join(\" \") || undefined;\n\n    const sharedText =\n      \"block w-full resize-none bg-transparent px-3.5 py-2.5 text-base leading-6\";\n\n    return (\n      <div\n        className={cn(\"flex flex-col gap-1.5\", className, classNames?.root)}\n      >\n        {label || description ? (\n          <div className=\"flex flex-col gap-0.5 px-1\">\n            {label ? (\n              <label\n                htmlFor={id}\n                className={cn(\n                  \"text-sm font-medium text-foreground\",\n                  classNames?.label,\n                )}\n              >\n                {label}\n              </label>\n            ) : null}\n            {description ? (\n              <p\n                id={`${id}-description`}\n                className={cn(\n                  \"text-xs text-muted-foreground\",\n                  classNames?.description,\n                )}\n              >\n                {description}\n              </p>\n            ) : null}\n          </div>\n        ) : null}\n\n        <div\n          ref={fieldRef}\n          data-state={hasError ? \"error\" : focused ? \"focused\" : \"idle\"}\n          className={cn(\n            \"relative overflow-hidden rounded-2xl border transition-colors duration-200\",\n            \"border-border\",\n            focused && !hasError && \"border-foreground/40 ring-2 ring-ring/40\",\n            hasError && \"border-destructive ring-2 ring-destructive/25\",\n            disabled && \"opacity-60\",\n            classNames?.field,\n          )}\n        >\n          <textarea\n            ref={mirrorRef}\n            aria-hidden=\"true\"\n            tabIndex={-1}\n            readOnly\n            rows={1}\n            className={cn(\n              sharedText,\n              \"pointer-events-none invisible absolute inset-x-0 top-0 h-0 overflow-hidden\",\n              classNames?.textarea,\n            )}\n          />\n          <textarea\n            ref={setRefs}\n            id={id}\n            rows={minRows}\n            value={value}\n            disabled={disabled}\n            maxLength={maxLength}\n            placeholder={placeholder}\n            aria-invalid={hasError || undefined}\n            aria-describedby={describedBy}\n            {...rest}\n            onChange={(e) => handleChange(e.target.value)}\n            onFocus={(event) => {\n              setFocused(true);\n              onFocus?.(event);\n            }}\n            onBlur={(event) => {\n              setFocused(false);\n              onBlur?.(event);\n            }}\n            style={{\n              height: height ?? undefined,\n              transition: reduce ? undefined : `height 160ms ${EASE_OUT_CSS}`,\n              ...rest.style,\n            }}\n            className={cn(\n              sharedText,\n              \"text-foreground caret-foreground outline-none\",\n              \"placeholder:text-muted-foreground/60\",\n              scrolls\n                ? \"overflow-y-auto [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\"\n                : \"overflow-y-hidden\",\n              disabled && \"cursor-not-allowed\",\n              classNames?.textarea,\n            )}\n          />\n\n          {countVisible ? (\n            <div className=\"flex justify-end px-3.5 pb-2\">\n              <span\n                ref={counterRef}\n                id={`${id}-count`}\n                className={cn(\n                  \"inline-block text-xs tabular-nums text-muted-foreground transition-colors duration-200\",\n                  nearLimit && \"text-warning\",\n                  atLimit && \"text-destructive\",\n                  classNames?.counter,\n                )}\n              >\n                <motion.span\n                  key={atLimit ? \"limit\" : \"count\"}\n                  className=\"inline-block\"\n                  initial={reduce || !atLimit ? false : { scale: 1.2 }}\n                  animate={{ scale: 1 }}\n                  transition={SPRING_PRESS}\n                >\n                  {maxLength !== undefined ? `${count}/${maxLength}` : count}\n                </motion.span>\n                <span className=\"sr-only\">\n                  {maxLength !== undefined\n                    ? ` characters used, ${maxLength - count} remaining`\n                    : \" characters\"}\n                </span>\n              </span>\n            </div>\n          ) : null}\n        </div>\n\n        <AnimatePresence initial={false}>\n          {errorMessage ? (\n            <motion.p\n              id={`${id}-error`}\n              role=\"alert\"\n              initial={\n                reduce\n                  ? { opacity: 0 }\n                  : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n              }\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={\n                reduce\n                  ? { opacity: 0 }\n                  : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n              }\n              transition={{ duration: 0.2 }}\n              className={cn(\n                \"px-1 text-xs text-destructive\",\n                classNames?.errorMessage,\n              )}\n            >\n              {errorMessage}\n            </motion.p>\n          ) : null}\n        </AnimatePresence>\n      </div>\n    );\n  },\n);\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}