FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Textarea

Textarea

pnpm dlx shadcn@latest add …/r/textarea.json

An accessible React textarea for Next.js that grows smoothly with its content, with min and max rows, styled like Input with Tailwind CSS.

import { Textarea } from "@/components/ui/textarea"

export function MessageField() {
  return (
    <>
      <label htmlFor="message">Message</label>
      <Textarea id="message" minRows={2} maxRows={6} />
    </>
  )
}
default.tsx
import { Textarea } from "@/components/ui/textarea";
import { useId } from "react";

export default function TextareaDefault() {
  const id = useId();
  return (
    <div className="grid w-full max-w-md gap-2">
      <label htmlFor={id} className="text-sm font-medium">
        Message
      </label>
      <Textarea id={id} placeholder="Write a few lines…" minRows={4} maxRows={10} />
    </div>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add motion
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Copy the source into your project.

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

    Update the import paths to match your project.

Examples

Character counter

maxLength stops the text at 160 characters. Each digit of the count swaps through the blur on its own, rising as you type and falling as you delete.

Shown on your public profile.

59/16059 of 160 characters

counter.tsx
"use client";

import { Textarea } from "@/components/ui/textarea";
import { duration, ease, spring, swap } from "@/lib/motion";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId, useState } from "react";

const LIMIT = 160;

/*
 * A character count under the field. Each digit swaps through the blur on its own (Copy Button's swap),
 * rising as the count goes up and falling as it goes down, so typing rolls only the digits that change.
 * A digit that appears or goes (9 → 10) comes and goes the same way. Screen readers get the count as text,
 * linked to the field with aria-describedby.
 */

/** The swap, turned to the count's direction: 1 rises for a higher count, -1 falls for a lower one */
const roll = {
  enter: (direction: number) => ({ ...swap.initial, y: swap.initial.y * direction }),
  shown: swap.animate,
  leave: (direction: number) => ({ ...swap.exit, y: swap.exit.y * direction }),
};
/** With reduced motion, opacity only */
const fade = { enter: { opacity: 0 }, shown: { opacity: 1 }, leave: { opacity: 0 } };

function Count({ value }: { value: number }) {
  const reduce = useReducedMotion() ?? false;
  // Which way the count last moved, updated while rendering (React's pattern for following a prop)
  const [last, setLast] = useState(value);
  const [direction, setDirection] = useState(1);
  if (value !== last) {
    setLast(value);
    setDirection(value > last ? 1 : -1);
  }
  const digits = String(value).split("");
  const variants = reduce ? fade : roll;
  const transition = reduce
    ? { duration: duration.fast }
    : { duration: duration.base, ease: ease.out, y: spring.snappy };
  const swapping = {
    custom: direction,
    variants,
    initial: "enter",
    animate: "shown",
    exit: "leave",
    transition,
  };

  return (
    <span aria-hidden className="inline-flex">
      <AnimatePresence initial={false} custom={direction}>
        {digits.map((digit, index) => {
          // Keyed by place (ones, tens, hundreds), so each digit keeps its slot as the number grows
          const place = digits.length - index;
          return (
            <motion.span key={place} {...swapping} className="inline-grid">
              <AnimatePresence initial={false} custom={direction}>
                {/* Old and new share one cell, so the swap doesn't shift the text */}
                <motion.span key={digit} {...swapping} className="col-start-1 row-start-1">
                  {digit}
                </motion.span>
              </AnimatePresence>
            </motion.span>
          );
        })}
      </AnimatePresence>
    </span>
  );
}

export default function TextareaCounter() {
  const id = useId();
  const [bio, setBio] = useState("Designer and engineer. I build calm, accessible interfaces.");

  return (
    <div className="grid w-full max-w-sm gap-2">
      <label htmlFor={id} className="text-sm font-medium">
        Bio
      </label>
      <Textarea
        id={id}
        value={bio}
        onChange={(event) => setBio(event.target.value)}
        maxLength={LIMIT}
        minRows={2}
        maxRows={5}
        aria-describedby={`${id}-hint ${id}-count`}
      />
      <div className="flex items-start justify-between gap-4 text-sm text-muted-foreground">
        <p id={`${id}-hint`}>Shown on your public profile.</p>
        <p id={`${id}-count`} className="shrink-0 tabular-nums">
          <Count value={bio.length} />
          <span aria-hidden>/{LIMIT}</span>
          <span className="sr-only">
            {bio.length} of {LIMIT} characters
          </span>
        </p>
      </div>
    </div>
  );
}

Disabled

disabled.tsx
import { Textarea } from "@/components/ui/textarea";
import { useId } from "react";

export default function TextareaDisabled() {
  const id = useId();
  return (
    <div className="grid w-full max-w-sm gap-2">
      <label htmlFor={id} className="text-sm font-medium">
        Release notes
      </label>
      <Textarea id={id} minRows={2} defaultValue="Locked while the release is being published." disabled />
    </div>
  );
}

API reference

Also accepts every native textarea prop except rows (use minRows). Grows with its text; with reduced motion it resizes at once. Drag its bottom-right corner to resize it in height: the height you drag to becomes its floor.

PropTypeDefaultDescription
minRows?number3The lines it shows when empty. It never gets shorter.
maxRows?number–The lines it grows to before it scrolls. Unlimited when left out.
aria-invalid?boolean–Marks the value as wrong: the border and focus ring turn destructive.
maxLength?number–The most characters it accepts.
disabled?booleanfalsePrevents editing.

Related components

  • Input

    An accessible React text input with three sizes and clear invalid, disabled and file states, built on a native input.

  • October 2026October 2026
    SunMonTueWedThuFriSat
    Calendar

    An accessible React calendar for Next.js and Tailwind CSS: pick a day or a range, with full keyboard support, disabled days and Intl names. No date library.

  • Checkbox

    An accessible React checkbox for Next.js forms, with a check that draws itself in and an indeterminate state, built on Radix Checkbox.