Components

Getting started

Components144

More

A text box that eases to the height of what you write, with a count that rolls and turns amber near the limit.

Used forComments and repliesBio and description fieldsFeedback formsRelease notes and changelogs

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/textarea.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Comments and replies

    A box that starts at three lines and grows as the reply does.

  • Bio and description fields

    A limit with a count that warns before it stops you.

  • Feedback forms

    Long answers that scroll after ten lines instead of taking the page.

  • Release notes and changelogs

    Writing in a card without a fixed height.

What it handles for you

Growing a textarea by resetting its height to auto to measure it flickers and jumps. A hidden mirror measures instead, so the real box only ever eases.

Instead of wiring upautosize librariescharacter counterscrollHeight hacks
  1. Eased growth

    The box eases to each new height in 260ms, between minRows and maxRows.

  2. A count that rolls

    Each digit rolls the way the count moved; amber in the last 10%, red at the limit.

  3. Same field as the rest

    Label, hint, error and shake work exactly as in Input.

  4. No layout reads per keystroke

    One ResizeObserver on the mirror, not scrollHeight on every change.

Overview

Auto-growing textareas usually snap a line taller on every wrap, or reset their height to measure and flicker. This one measures a hidden copy of the text and eases the box to it between minRows and maxRows, then scrolls. With maxLength the count rolls digit by digit and turns amber in the last tenth.

States

rest
Three lines tall by default inside a 14px-radius hairline box.
growing
Each wrap eases the box to the mirror's height (260ms); past maxRows it scrolls.
near limit
With maxLength, the count turns amber at 90% and red at 100%.
error
Red line, the error replaces the hint, one shake.

Usage

usage.tsx
import { Textarea } from "@/components/ui/textarea";<Textarea label="Reply" placeholder="Write a reply" minRows={2} maxRows={8} /><Textarea label="Bio" maxLength={160} />

Works with

Props

PropTypeDescription
minRows / maxRowsnumberdefault 3 / 10Height range in lines; it scrolls past maxRows.
maxLengthnumberLimit; shows a rolling count beside the label.
showCountbooleandefault trueShow the count when maxLength is set.
label / hint / errorReactNodeAs in Input.
onValueChange(value: string) => voidThe new text on every change.
...textarea propsTextareaHTMLAttributesEverything a native textarea takes, including ref.

Accessibility and motion

  • Shares field.tsx with every text control: one surface, one focus ring, one way of showing hints and errors.
  • Reduced motion: the height changes at once.
  • Pass textareaClassName to change type; the mirror takes the same classes.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Number roll, ui-interaction-motion.

Source

components/textarea.tsx
"use client";import * as React from "react";import { Field, fieldSurface, useFieldIds, useShake } from "./field";import { NumberRoll } from "./number-roll";import { EASE_OUT, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * TEXTAREA: grows with what you write * *   grow     a hidden copy of the text is measured and the box *            eases to its height, between minRows and maxRows, *            then scrolls; it never snaps a line taller *   count    with maxLength, the count rolls as you type and turns *            amber in the last tenth, red at the limit *   error    same as every field: red line, hint gives way, shake * ───────────────────────────────────────────────────────── */export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {  label?: React.ReactNode;  hint?: React.ReactNode;  error?: React.ReactNode;  minRows?: number;  maxRows?: number;  /** Show "12 / 280" beside the label when maxLength is set. */  showCount?: boolean;  onValueChange?: (value: string) => void;  ref?: React.Ref<HTMLTextAreaElement>;  className?: string;  textareaClassName?: string;}const LINE = 21; // 13.5px text at leading 1.55const PAD = 18; // 9px top and bottomexport function Textarea({  label,  hint,  error,  minRows = 3,  maxRows = 10,  showCount = true,  onValueChange,  ref,  id,  className = "",  textareaClassName = "",  value,  defaultValue,  onChange,  maxLength,  disabled,  required,  ...props}: TextareaProps) {  const reduced = useInteractionReducedMotion();  const ids = useFieldIds(id);  const [text, setText] = React.useState(String(defaultValue ?? ""));  const current = value !== undefined ? String(value) : text;  const mirror = React.useRef<HTMLDivElement>(null);  const [height, setHeight] = React.useState(minRows * LINE + PAD);  const shakeRef = useShake<HTMLDivElement>(!!error);  React.useLayoutEffect(() => {    const el = mirror.current;    if (!el) return;    const measure = () => {      const next = Math.min(maxRows * LINE + PAD, Math.max(minRows * LINE + PAD, el.offsetHeight));      setHeight((h) => (h === next ? h : next));    };    measure();    const ro = new ResizeObserver(measure);    ro.observe(el);    return () => ro.disconnect();  }, [current, minRows, maxRows]);  const count = current.length;  const near = maxLength ? count >= maxLength * 0.9 : false;  const full = maxLength ? count >= maxLength : false;  const describedBy = [hint && !error ? ids.hint : null, error ? ids.error : null].filter(Boolean).join(" ") || undefined;  return (    <Field      id={ids.id}      label={label}      hint={hint}      error={error}      required={required}      className={className}      aside={        maxLength && showCount ? (          <span className={full ? "text-red-600 dark:text-red-400" : near ? "text-amber-600 dark:text-amber-400" : ""} style={{ transition: "color 200ms" }}>            <NumberRoll value={count} /> / {maxLength}          </span>        ) : undefined      }    >      <div ref={shakeRef} className={`${fieldSurface({ invalid: !!error, disabled, round: false })} items-stretch`}>        {/* The measuring copy: same type and padding, never seen. */}        <div          ref={mirror}          aria-hidden="true"          className={`pointer-events-none invisible absolute inset-x-0 top-0 whitespace-pre-wrap break-words px-4 py-[9px] text-[13.5px] leading-[1.55] ${textareaClassName}`}        >          {current + "​"}        </div>        <textarea          ref={ref}          id={ids.id}          value={value}          defaultValue={defaultValue}          maxLength={maxLength}          disabled={disabled}          required={required}          aria-invalid={error ? true : undefined}          aria-describedby={describedBy}          onChange={(e) => {            if (value === undefined) setText(e.target.value);            onChange?.(e);            onValueChange?.(e.target.value);          }}          style={{ height, transition: reduced ? "none" : `height 260ms ${EASE_OUT}` }}          className={`block w-full resize-none bg-transparent px-4 py-[9px] text-[13.5px] leading-[1.55] outline-none placeholder:text-muted-foreground/70 ${textareaClassName}`}          {...props}        />      </div>    </Field>  );}export default Textarea;
components/field.tsx
"use client";import * as React from "react";import { EASE_OUT, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * FIELD: the column every text control sits in * *   label    above, with an optional aside on the right (a count) *   hint     under the control while all is well *   error    takes the hint's place: the hint folds away, the *            error rises in out of a blur, the control shakes once * * Shared by input, textarea, password, search and select so the * whole family has one surface, one focus ring and one error. * ───────────────────────────────────────────────────────── */export interface FieldProps {  id: string;  label?: React.ReactNode;  /** Right side of the label row, e.g. a character count. */  aside?: React.ReactNode;  hint?: React.ReactNode;  error?: React.ReactNode;  required?: boolean;  className?: string;  children: React.ReactNode;}export type FieldSize = "sm" | "md";/** The surface of a text control: a hairline that darkens on hover and turns to the ring on focus. */export function fieldSurface({ invalid, disabled, round = true }: { invalid?: boolean; disabled?: boolean; round?: boolean }) {  const ring = invalid    ? "shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)] focus-within:shadow-[inset_0_0_0_1px_var(--color-red-500),0_0_0_3px_color-mix(in_oklab,var(--color-red-500)_14%,transparent)]"    : "shadow-[inset_0_0_0_1px_var(--border)] [&:not(:focus-within)]:hover:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_24%,var(--border))] focus-within:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_16%,transparent)]";  return `relative flex w-full items-center bg-background text-foreground transition-[box-shadow,background-color,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${round ? "rounded-full" : "rounded-[14px]"} ${ring} ${    disabled ? "pointer-events-none opacity-50" : ""  }`;}/** ids for the control's label, hint and error. */export function useFieldIds(id?: string) {  const auto = React.useId();  const base = id ?? auto;  return { id: base, hint: `${base}-hint`, error: `${base}-error` };}/** One short shake whenever `on` turns true (an error arrives). */export function useShake<T extends HTMLElement>(on: boolean) {  const ref = React.useRef<T>(null);  const reduced = useInteractionReducedMotion();  React.useEffect(() => {    if (!on || reduced) return;    ref.current?.animate(      [{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }],      { duration: 380, easing: "ease-out" },    );  }, [on, reduced]);  return ref;}/** Sets a native input's value so React's onChange still fires (clear buttons, pickers). */export function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;  Object.getOwnPropertyDescriptor(proto, "value")?.set?.call(el, value);  el.dispatchEvent(new Event("input", { bubbles: true }));}/** A line under the control that folds open and closed; keeps its last words while closing. */function Line({ show, children, id, tone, reduced }: { show: boolean; children: React.ReactNode; id: string; tone: string; reduced: boolean }) {  const [kept, setKept] = React.useState(children);  if (show && children !== kept) setKept(children);  return (    <div      className="grid"      style={{ gridTemplateRows: show ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-rows 320ms ${EASE_OUT}` }}    >      <div className="min-h-0 overflow-hidden">        <p          id={id}          className={`pt-1.5 pl-3 text-[12.5px] leading-snug ${tone}`}          style={{            opacity: show ? 1 : 0,            filter: show ? "blur(0px)" : "blur(4px)",            transform: show ? "none" : "translateY(-4px)",            transition: reduced ? "none" : show ? `opacity 280ms ${EASE_OUT} 60ms, filter 280ms ${EASE_OUT} 60ms, transform 380ms ${EASE_OUT}` : `opacity 140ms ${EASE_OUT}, filter 140ms ${EASE_OUT}, transform 200ms ${EASE_OUT}`,          }}        >          {show ? children : kept}        </p>      </div>    </div>  );}export function Field({ id, label, aside, hint, error, required, className = "", children }: FieldProps) {  const reduced = useInteractionReducedMotion();  const ids = { hint: `${id}-hint`, error: `${id}-error` };  return (    <div className={`flex w-full flex-col ${className}`}>      {(label || aside) && (        <div className="flex items-baseline justify-between gap-3 px-3 pb-1.5">          {label && (            <label htmlFor={id} className="text-[13px] font-medium text-foreground">              {label}              {required && (                <span aria-hidden="true" className="ml-0.5 text-muted-foreground">                  *                </span>              )}            </label>          )}          {aside && <span className="text-[12px] tabular-nums text-muted-foreground">{aside}</span>}        </div>      )}      {children}      <Line show={!error && !!hint} id={ids.hint} tone="text-muted-foreground" reduced={reduced}>        {hint}      </Line>      <Line show={!!error} id={ids.error} tone="text-red-600 dark:text-red-400" reduced={reduced}>        {error}      </Line>    </div>  );}export default Field;

Registry item: /r/textarea.json