Multi-line text that grows smoothly with what you write between a minimum and maximum number of rows, with counters, validation and a submit shortcut.
Grows with your text, then scrolls.
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
export function TextareaDemo() {
return (
<Field className="w-full max-w-sm">
<FieldLabel>Feedback</FieldLabel>
<Textarea placeholder="What could we do better? Keep typing — the box grows with you." />
<FieldDescription>Grows with your text, then scrolls.</FieldDescription>
</Field>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/textarea.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react class-variance-authority cn - 3
Copy and paste the following code into your project.
components/ui/textarea.tsx "use client" import * as React from "react" import { Input as InputPrimitive } from "@base-ui/react/input" import { cn } from "cn" import { useAutosize } from "@/hooks/use-autosize" import { useComposedRef } from "@/hooks/use-composed-ref" import { useInvalidShake } from "@/hooks/use-invalid-shake" import { inputVariants } from "@/components/ui/input" function clampRows(value: number | undefined, fallback: number) { const rows = Math.floor(Number(value)) return Number.isFinite(rows) ? Math.min(100, Math.max(1, rows)) : fallback } type TextareaProps = Omit<React.ComponentProps<"textarea">, "rows"> & { autoResize?: boolean minRows?: number maxRows?: number submitOnShortcut?: boolean shake?: boolean } function Textarea({ className, autoResize = true, minRows = 3, maxRows = 10, submitOnShortcut = false, shake = true, onKeyDown, ref, ...props }: TextareaProps) { const [textareaRef, setRef] = useComposedRef<HTMLTextAreaElement>(ref) const min = clampRows(minRows, 3) const max = Math.max(min, clampRows(maxRows, 10)) React.useLayoutEffect(() => { const textarea = textareaRef.current if (!textarea) { return } textarea.style.setProperty("--textarea-min-rows", String(min)) textarea.style.setProperty("--textarea-max-rows", String(max)) }, [textareaRef, min, max]) useAutosize(textareaRef, autoResize, props.value) useInvalidShake(textareaRef, shake) return ( <InputPrimitive data-slot="textarea" data-autoresize={autoResize ? "" : undefined} render={<textarea ref={setRef} rows={min} />} className={cn( inputVariants({ size: null }), "block min-h-[calc(var(--textarea-min-rows,3)*1lh+1rem)] px-3 py-2 leading-5 wrap-break-word not-data-autoresize:resize-y data-autoresize:max-h-[calc(var(--textarea-max-rows,10)*1lh+1rem)] data-autoresize:resize-none data-autoresize:overflow-y-hidden", className )} onKeyDown={(event) => { const keyEvent = event as unknown as React.KeyboardEvent<HTMLTextAreaElement> onKeyDown?.(keyEvent) if ( submitOnShortcut && !keyEvent.defaultPrevented && keyEvent.key === "Enter" && (keyEvent.metaKey || keyEvent.ctrlKey) && !keyEvent.nativeEvent.isComposing ) { const form = keyEvent.currentTarget.form if (form) { keyEvent.preventDefault() form.requestSubmit() } } }} {...(props as InputPrimitive.Props)} /> ) } export { Textarea } export type { TextareaProps }components/ui/input.tsx "use client" import * as React from "react" import { Input as InputPrimitive } from "@base-ui/react/input" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { useAutosize } from "@/hooks/use-autosize" import { useComposedRef } from "@/hooks/use-composed-ref" import { useInvalidShake } from "@/hooks/use-invalid-shake" import { useMergedRef } from "@/hooks/use-merged-ref" import { prefersReducedMotion } from "@/lib/motion" import { NumberFlow } from "@/components/ui/number-flow" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } const inputVariants = cva( "w-full min-w-0 rounded-md bg-transparent text-sm text-foreground inset-ring-(length:--hairline) inset-ring-input transition-[color,background-color,box-shadow] duration-150 ease-out-cubic outline-none selection:bg-primary selection:text-primary-foreground file:me-3 file:inline-flex file:h-full file:items-center file:border-0 file:bg-transparent file:p-0 file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground placeholder:transition-opacity placeholder:duration-150 user-invalid:inset-ring-destructive focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden focus-visible:placeholder:opacity-70 user-invalid:focus-visible:ring-destructive/70 user-invalid:focus-visible:inset-ring-destructive disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:inset-ring-destructive aria-invalid:focus-visible:ring-destructive/70 aria-invalid:focus-visible:inset-ring-destructive data-invalid:inset-ring-destructive data-invalid:focus-visible:ring-destructive/70 data-invalid:focus-visible:inset-ring-destructive data-shake:motion-safe:animate-button-shake motion-reduce:transition-none dark:bg-input/30 dark:scheme-dark dark:user-invalid:focus-visible:ring-destructive/70 dark:aria-invalid:focus-visible:ring-destructive/70 dark:data-invalid:focus-visible:ring-destructive/70 forced-colors:border pointer-coarse:text-[max(16px,1rem)] [&::-webkit-search-cancel-button]:cursor-pointer [&::-webkit-search-cancel-button]:opacity-50 [&::-webkit-search-cancel-button]:transition-opacity [&::-webkit-search-cancel-button:hover]:opacity-100 [&[readonly]]:bg-muted/40 dark:[&[readonly]]:bg-input/20 [@media(hover:hover)]:hover:not-focus-visible:not-disabled:not-[[readonly]]:not-aria-invalid:not-data-invalid:not-user-invalid:inset-ring-ring/70", { variants: { size: { sm: "h-8 px-2.5 file:pe-2.5", default: "h-9 px-3", lg: "h-10 px-3.5", }, }, defaultVariants: { size: "default", }, } ) type InputControl = HTMLInputElement | HTMLTextAreaElement type InputProps = Omit<InputPrimitive.Props, "size"> & VariantProps<typeof inputVariants> & { htmlSize?: number shake?: boolean } function Input({ className, size = "default", htmlSize, shake = true, ref, ...props }: InputProps) { const resolvedSize = size ?? "default" const [inputRef, setRef] = useComposedRef<HTMLInputElement>(ref) useInvalidShake(inputRef, shake) return ( <InputPrimitive ref={setRef} data-slot="input" data-size={resolvedSize} size={htmlSize} className={mergeClassName( inputVariants({ size: resolvedSize }), className )} {...props} /> ) } function defaultAnnouncement(remaining: number) { return remaining === 0 ? "Character limit reached" : `${remaining} ${remaining === 1 ? "character" : "characters"} left` } type InputCountProps = Omit<React.ComponentProps<"span">, "children"> & { length: number maxLength?: number | null controlRef?: React.RefObject<InputControl | null> threshold?: number announcement?: (remaining: number) => string } function InputCount({ className, length, maxLength = null, controlRef, threshold, announcement = defaultAnnouncement, ref, ...props }: InputCountProps) { const max = maxLength !== null && maxLength > 0 ? maxLength : null const remaining = max === null ? null : Math.max(0, max - length) const near = max === null ? Infinity : Math.max(1, threshold ?? Math.ceil(Math.min(max * 0.1, 20))) const state = remaining === null ? undefined : remaining === 0 ? "limit" : remaining <= near ? "near" : undefined const [message, setMessage] = React.useState("") const [lastState, setLastState] = React.useState(state) if (state !== lastState) { setLastState(state) setMessage(state && remaining !== null ? announcement(remaining) : "") } const [countRef, setCountRef] = useComposedRef<HTMLSpanElement>(ref) React.useEffect(() => { const element = controlRef?.current const count = countRef.current if (!element || !count || max === null) { return } let timer: ReturnType<typeof setTimeout> | undefined const onBeforeInput = (event: Event) => { const input = event as InputEvent if ( !input.inputType?.startsWith("insert") || element.value.length < max || element.selectionStart !== element.selectionEnd || prefersReducedMotion() ) { return } count.removeAttribute("data-bump") void count.offsetWidth count.setAttribute("data-bump", "") clearTimeout(timer) timer = setTimeout(() => count.removeAttribute("data-bump"), 400) } element.addEventListener("beforeinput", onBeforeInput) return () => { element.removeEventListener("beforeinput", onBeforeInput) clearTimeout(timer) } }, [controlRef, max]) return ( <span ref={setCountRef} data-slot="input-count" data-state={state} className={cn( "inline-flex shrink-0 items-baseline text-xs text-muted-foreground tabular-nums transition-colors duration-200 ease-out-cubic data-bump:animate-button-shake data-[state=limit]:text-destructive data-[state=near]:text-foreground", className )} {...props} > <span aria-hidden="true" dir="ltr" className="inline-flex items-baseline"> <NumberFlow value={length} /> {max !== null && <span>/{max}</span>} </span> <span role="status" className="sr-only"> {message} </span> </span> ) } export { Input, InputCount, useAutosize, inputVariants, useComposedRef, useInvalidShake, useMergedRef, } export type { InputControl, InputCountProps, InputProps }components/ui/number-flow.tsx "use client" import * as React from "react" import { cn } from "cn" import { easeSpring } from "@/lib/motion" type NumberFlowTrend = "auto" | "up" | "down" | "shortest" type NumberFlowChar = { key: string section: number order: number value: string digit: number | null } type NumberFlowItem = NumberFlowChar & { exiting: boolean exitId: number } type NumberFlowProps = Omit< React.ComponentProps<"span">, "children" | "prefix" > & { value: number locales?: Intl.LocalesArgument format?: Intl.NumberFormatOptions prefix?: string suffix?: string trend?: NumberFlowTrend duration?: number easing?: string animated?: boolean onAnimationsStart?: () => void onAnimationsFinish?: () => void } const rows = 30 const restingClasses = "data-[digit='0']:translate-y-[-33.3333%] data-[digit='1']:translate-y-[-36.6667%] data-[digit='2']:translate-y-[-40%] data-[digit='3']:translate-y-[-43.3333%] data-[digit='4']:translate-y-[-46.6667%] data-[digit='5']:translate-y-[-50%] data-[digit='6']:translate-y-[-53.3333%] data-[digit='7']:translate-y-[-56.6667%] data-[digit='8']:translate-y-[-60%] data-[digit='9']:translate-y-[-63.3333%]" function getSpinDelta( from: number, to: number, trend: Exclude<NumberFlowTrend, "auto"> ) { let delta = to - from if (trend === "up") { if (delta < 0) { delta += 10 } } else if (trend === "down") { if (delta > 0) { delta -= 10 } } else if (delta > 5) { delta -= 10 } else if (delta < -4) { delta += 10 } return delta } function digitGlyphs( formatter: Intl.NumberFormat, locales: Intl.LocalesArgument ) { const { numberingSystem } = formatter.resolvedOptions() const plain = new Intl.NumberFormat(locales, { numberingSystem, useGrouping: false, }) return Array.from({ length: 10 }, (_, digit) => plain.format(digit)) } function formatChars( value: number, locales: Intl.LocalesArgument, format: Intl.NumberFormatOptions | undefined, prefix: string | undefined, suffix: string | undefined ) { const formatter = new Intl.NumberFormat(locales, format) const glyphs = digitGlyphs(formatter, locales) const parts = formatter.formatToParts(value) const integerCount = parts .filter((part) => part.type === "integer") .reduce((count, part) => count + Array.from(part.value).length, 0) const chars: NumberFlowChar[] = [] const occurrences = new Map<string, number>() let integerIndex = 0 let fractionIndex = 0 let seenNumber = false function symbol(section: number, type: string, text: string) { const name = `${section}-${type}` const occurrence = occurrences.get(name) ?? 0 occurrences.set(name, occurrence + 1) chars.push({ key: `s${name}-${occurrence}`, section, order: occurrence, value: text, digit: null, }) } if (prefix) { chars.push({ key: "prefix", section: 0, order: 0, value: prefix, digit: null, }) } for (const part of parts) { if (part.type === "integer") { seenNumber = true for (const glyph of Array.from(part.value)) { const place = integerCount - 1 - integerIndex const digit = glyphs.indexOf(glyph) chars.push({ key: `i${place}`, section: 2, order: -place, value: glyph, digit: digit === -1 ? null : digit, }) integerIndex += 1 } } else if (part.type === "group") { const place = integerCount - 1 - integerIndex chars.push({ key: `g${place}`, section: 2, order: -place - 0.5, value: part.value, digit: null, }) } else if (part.type === "decimal") { seenNumber = true chars.push({ key: "decimal", section: 3, order: 0, value: part.value, digit: null, }) } else if (part.type === "fraction") { for (const glyph of Array.from(part.value)) { const digit = glyphs.indexOf(glyph) chars.push({ key: `f${fractionIndex}`, section: 4, order: fractionIndex, value: glyph, digit: digit === -1 ? null : digit, }) fractionIndex += 1 } } else { symbol(seenNumber ? 5 : 1, part.type, part.value) } } if (suffix) { chars.push({ key: "suffix", section: 6, order: 0, value: suffix, digit: null, }) } return chars } function signature(chars: NumberFlowChar[]) { return chars.map((char) => `${char.key}:${char.value}`).join("|") } function mergeItems(previous: NumberFlowItem[], next: NumberFlowChar[]) { const keys = new Set(next.map((char) => char.key)) const kept = previous .filter((item) => !keys.has(item.key)) .map((item) => item.exiting ? item : { ...item, exiting: true, exitId: item.exitId + 1 } ) const fresh = next.map((char) => { const before = previous.find((item) => item.key === char.key) return { ...char, exiting: false, exitId: before?.exitId ?? 0 } }) return [...kept, ...fresh].sort( (a, b) => a.section - b.section || a.order - b.order ) } function subscribeReducedMotion(callback: () => void) { if (typeof window.matchMedia !== "function") { return () => {} } const query = window.matchMedia("(prefers-reduced-motion: reduce)") query.addEventListener("change", callback) return () => query.removeEventListener("change", callback) } function getReducedMotion() { return ( typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } function canAnimate() { return ( typeof Element !== "undefined" && typeof Element.prototype.animate === "function" ) } function liveIndex(column: HTMLElement) { const height = column.offsetHeight if (!height) { return null } const translate = getComputedStyle(column).translate const y = translate === "none" ? "0" : (translate.split(" ")[1] ?? "0") const amount = parseFloat(y) || 0 return y.endsWith("%") ? (-amount / 100) * rows : (-amount / height) * rows } function NumberFlow({ value, locales = "en-US", format, prefix, suffix, trend = "auto", duration = 600, easing = easeSpring, animated = true, onAnimationsStart, onAnimationsFinish, className, ...props }: NumberFlowProps) { const chars = formatChars(value, locales, format, prefix, suffix) const key = signature(chars) const reducedMotion = React.useSyncExternalStore( subscribeReducedMotion, getReducedMotion, () => false ) const animate = animated && !reducedMotion && canAnimate() const [state, setState] = React.useState(() => ({ key, items: chars.map((char) => ({ ...char, exiting: false, exitId: 0 })), })) if (state.key !== key) { setState({ key, items: animate ? mergeItems(state.items, chars) : chars.map((char) => ({ ...char, exiting: false, exitId: 0 })), }) } const rootRef = React.useRef<HTMLSpanElement>(null) const previousRef = React.useRef<Map< string, { digit: number | null; exiting: boolean; exitId: number } > | null>(null) const valueRef = React.useRef(value) const batchRef = React.useRef(0) const callbacksRef = React.useRef({ onAnimationsStart, onAnimationsFinish }) const finishExitRef = React.useRef((itemKey: string, exitId: number) => { setState((current) => ({ ...current, items: current.items.filter( (item) => !(item.key === itemKey && item.exiting && item.exitId === exitId) ), })) }) React.useLayoutEffect(() => { callbacksRef.current = { onAnimationsStart, onAnimationsFinish } }) React.useLayoutEffect(() => { const root = rootRef.current const previous = previousRef.current const current = new Map( state.items.map((item) => [ item.key, { digit: item.digit, exiting: item.exiting, exitId: item.exitId }, ]) ) const previousValue = valueRef.current previousRef.current = current valueRef.current = value if (!root || !previous || !animate) { return } const direction = trend !== "auto" ? trend : value > previousValue ? "up" : value < previousValue ? "down" : "shortest" const timing = { duration, easing } const started: Animation[] = [] const cells = root.querySelectorAll<HTMLElement>( ":scope > [data-number-flow-key]" ) for (const cell of cells) { const itemKey = cell.dataset.numberFlowKey! const item = current.get(itemKey) const before = previous.get(itemKey) if (!item || typeof cell.animate !== "function") { continue } const column = cell.querySelector<HTMLElement>( "[data-slot=number-flow-column]" ) const entering = !item.exiting && (!before || before.exiting) const leaving = item.exiting && (!before || !before.exiting) if (entering || leaving) { const running = cell.getAnimations() const fromWidth = running.length ? cell.getBoundingClientRect().width : entering ? 0 : cell.getBoundingClientRect().width const fromOpacity = running.length ? Number(getComputedStyle(cell).opacity) : entering ? 0 : 1 running.forEach((animation) => animation.cancel()) const toWidth = entering ? cell.getBoundingClientRect().width : 0 const animation = cell.animate( [ { width: `${fromWidth}px`, opacity: fromOpacity }, { width: `${toWidth}px`, opacity: entering ? 1 : 0 }, ], { ...timing, fill: leaving ? "forwards" : "none" } ) started.push(animation) if (leaving) { const exitId = item.exitId animation.finished.then( () => finishExitRef.current(itemKey, exitId), () => {} ) } } if (!column || item.digit === null || item.exiting) { continue } const target = item.digit const running = column.getAnimations() const live = running.length ? liveIndex(column) : null running.forEach((animation) => animation.cancel()) const from = live !== null ? live - 10 : entering ? 0 : before && before.digit !== null ? before.digit : target if (live === null && from === target && !entering) { continue } const delta = getSpinDelta( from, target, entering && live === null ? "up" : direction ) if (delta === 0) { continue } started.push( column.animate( [ { translate: `0 ${(-(target + 10 - delta) / rows) * 100}%` }, { translate: `0 ${(-(target + 10) / rows) * 100}%` }, ], timing ) ) } if (!started.length) { return } batchRef.current += 1 const batch = batchRef.current callbacksRef.current.onAnimationsStart?.() Promise.all(started.map((animation) => animation.finished)).then( () => { if (batch === batchRef.current) { callbacksRef.current.onAnimationsFinish?.() } }, () => {} ) }, [state.items, animate, duration, easing, trend, value]) return ( <span ref={rootRef} dir="ltr" className={cn("inline-block max-w-full tabular-nums", className)} {...props} data-slot="number-flow" > <span data-slot="number-flow-text" className="sr-only"> {state.items .filter((item) => !item.exiting) .map((item) => item.value) .join("")} </span> {state.items.map((item) => item.digit === null ? ( <span key={item.key} data-number-flow-key={item.key} data-slot="number-flow-symbol" aria-hidden className="inline-block max-w-full overflow-clip wrap-anywhere whitespace-pre-wrap" > {item.value} </span> ) : ( <span key={item.key} data-number-flow-key={item.key} data-slot="number-flow-digit" aria-hidden className="relative -my-[0.2em] inline-block overflow-clip [mask-image:linear-gradient(to_bottom,transparent,black_0.2em,black_calc(100%-0.2em),transparent)] py-[0.2em]" > <span className="opacity-0">{item.value}</span> <span aria-hidden data-slot="number-flow-column" data-digit={item.digit} className={cn( "pointer-events-none absolute inset-x-0 top-0 flex flex-col items-center select-none", restingClasses )} > {Array.from({ length: rows }, (_, row) => ( <span key={row} className="block py-[0.2em]"> {glyphFor(item, row)} </span> ))} </span> </span> ) )} </span> ) } function glyphFor(item: NumberFlowItem, row: number) { return String.fromCodePoint( item.value.codePointAt(0)! - (item.digit ?? 0) + (row % 10) ) } export { NumberFlow, getSpinDelta, formatChars, type NumberFlowProps, type NumberFlowTrend, }lib/motion.ts import * as React from "react" const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)" const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)" const easeSpring = "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)" const duration = { press: 100, release: 200, hover: 150, enter: 200, exit: 150, morph: 300, } as const function prefersReducedMotion() { return ( typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } type SizeAxis = "width" | "height" type SizeMorphOptions = { axis: SizeAxis enabled?: boolean duration?: number easing?: string } function readSize(element: HTMLElement, axis: SizeAxis) { const value = parseFloat(getComputedStyle(element)[axis]) if (Number.isFinite(value)) { return value } const rect = element.getBoundingClientRect() return axis === "width" ? rect.width : rect.height } function attachSizeMorph( element: HTMLElement, axis: SizeAxis, time: number, easing: string ) { if ( typeof MutationObserver === "undefined" || typeof element.animate !== "function" ) { return undefined } let settled = readSize(element, axis) let animation: Animation | null = null const morph = () => { const from = animation ? readSize(element, axis) : settled animation?.cancel() animation = null const to = readSize(element, axis) settled = to if ( Math.abs(from - to) < 0.5 || prefersReducedMotion() || !element.isConnected || element.getClientRects().length === 0 ) { element.removeAttribute("data-morphing") return } element.setAttribute("data-morphing", "") const running = element.animate( [{ [axis]: `${from}px` }, { [axis]: `${to}px` }], { duration: time, easing } ) animation = running running.onfinish = () => { if (animation === running) { animation = null element.removeAttribute("data-morphing") settled = readSize(element, axis) } } } const mutations = new MutationObserver(morph) mutations.observe(element, { childList: true, subtree: true, characterData: true, }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (!animation) { settled = readSize(element, axis) } }) resize?.observe(element) return () => { mutations.disconnect() resize?.disconnect() animation?.cancel() element.removeAttribute("data-morphing") } } function useSizeMorph<T extends HTMLElement>({ axis, enabled = true, duration: time = duration.morph, easing = easeOut, }: SizeMorphOptions): React.RefCallback<T> { return React.useCallback( (element: T | null) => { if (!element || !enabled) { return undefined } return attachSizeMorph(element, axis, time, easing) }, [axis, enabled, time, easing] ) } function useSlidingHighlight( barRef: React.RefObject<HTMLElement | null>, highlightRef: React.RefObject<HTMLElement | null>, selector: string, attribute = "data-popup-open" ) { React.useLayoutEffect(() => { const bar = barRef.current const highlight = highlightRef.current if (!bar || !highlight) { return } let current: HTMLElement | null = null const place = (trigger: HTMLElement, instant: boolean) => { if (instant || prefersReducedMotion()) { highlight.setAttribute("data-instant", "") } else { highlight.removeAttribute("data-instant") } const frame = bar.getBoundingClientRect() const box = trigger.getBoundingClientRect() const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1 highlight.style.left = "0px" highlight.style.width = `${box.width / scale}px` highlight.style.height = `${box.height / scale}px` highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)` } const sync = () => { const trigger = bar.querySelector<HTMLElement>(selector) if (trigger === current) { if (trigger) { place(trigger, true) } return } const appearing = current === null current = trigger if (!trigger) { highlight.removeAttribute("data-visible") return } place(trigger, appearing) if (appearing) { void highlight.offsetWidth } highlight.setAttribute("data-visible", "") } sync() const mutations = new MutationObserver(sync) mutations.observe(bar, { subtree: true, childList: true, attributes: true, attributeFilter: [attribute], }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (current) { place(current, true) } }) resize?.observe(bar) const onScroll = () => { if (current) { place(current, true) } } bar.addEventListener("scroll", onScroll, { capture: true, passive: true }) return () => { mutations.disconnect() resize?.disconnect() bar.removeEventListener("scroll", onScroll, { capture: true }) } }, [barRef, highlightRef, selector, attribute]) } export { duration, easeInOut, easeOut, easeSpring, prefersReducedMotion, useSizeMorph, useSlidingHighlight, } export type { SizeMorphOptions } - 4
Update the import paths to match your project setup.
import { Textarea } from "@/components/ui/textarea"<Textarea placeholder="Write something…" />The box grows with your text, easing to each new line instead of jumping, from minRows up to maxRows, then scrolls. Typing is never delayed by the animation.
Send with a shortcut#
submitOnShortcut submits the form with ⌘ + Enter or Ctrl + Enter. Enter on its own still starts a new line.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Kbd, KbdGroup } from "@/components/ui/kbd"
import { Textarea } from "@/components/ui/textarea"
export function TextareaSubmit() {
const [comments, setComments] = React.useState<string[]>([])
return (
<form
className="flex w-full max-w-sm flex-col gap-3"
onSubmit={(event) => {
event.preventDefault()
const form = event.currentTarget
const text = String(new FormData(form).get("comment") ?? "").trim()
if (text) {
setComments((list) => [...list, text])
form.reset()
}
}}
>
{comments.length > 0 ? (
<ul className="flex flex-col gap-1 text-sm">
{comments.map((comment, index) => (
<li
key={index}
className="rounded-md bg-muted px-3 py-2 whitespace-pre-wrap"
>
{comment}
</li>
))}
</ul>
) : null}
<Textarea
name="comment"
aria-label="Comment"
placeholder="Write a comment…"
minRows={2}
submitOnShortcut
/>
<div className="flex items-center justify-between gap-3">
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<KbdGroup>
<Kbd keys="Mod+Enter" />
</KbdGroup>
to send
</span>
<Button type="submit" size="sm">
Comment
</Button>
</div>
</form>
)
}Character count#
With maxLength inside a Field, FieldCounter counts as you type and nudges when you hit the limit.
import { Field, FieldCounter, FieldLabel } from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
export function TextareaCounter() {
return (
<Field className="w-full max-w-sm">
<div className="flex items-baseline justify-between gap-3">
<FieldLabel>Bio</FieldLabel>
<FieldCounter />
</div>
<Textarea name="bio" maxLength={160} minRows={2} />
</Field>
)
}Rows#
minRows and maxRows set where growing starts and stops.
import { Textarea } from "@/components/ui/textarea"
export function TextareaRows() {
return (
<div className="grid w-full max-w-md gap-4 sm:grid-cols-2">
<Textarea
aria-label="One to four rows"
minRows={1}
maxRows={4}
placeholder="1–4 rows"
/>
<Textarea
aria-label="Five to fifteen rows"
minRows={5}
maxRows={15}
placeholder="5–15 rows"
/>
</div>
)
}Fixed size#
autoResize={false} keeps a fixed height with a vertical resize handle.
import { Textarea } from "@/components/ui/textarea"
export function TextareaFixed() {
return (
<Textarea
aria-label="Notes"
autoResize={false}
minRows={4}
placeholder="Fixed height. Drag the corner to resize."
className="max-w-sm"
/>
)
}States#
Disabled, read-only and invalid. Invalid textareas shake when a form submit fails.
import { Textarea } from "@/components/ui/textarea"
export function TextareaStates() {
return (
<div className="grid w-full max-w-md gap-4 sm:grid-cols-3">
<Textarea
aria-label="Disabled"
disabled
placeholder="Disabled"
minRows={2}
/>
<Textarea
aria-label="Read-only"
readOnly
defaultValue="Read-only text you can select but not change."
minRows={2}
/>
<Textarea
aria-label="Invalid"
aria-invalid
placeholder="Invalid"
minRows={2}
/>
</div>
)
}Right to left#
Text, placeholder and caret follow the reading direction.
import { Textarea } from "@/components/ui/textarea"
export function TextareaRtl() {
return (
<div dir="rtl" className="w-full max-w-sm">
<Textarea aria-label="رسالتك" placeholder="اكتب رسالتك هنا…" />
</div>
)
}| Key | Action |
|---|---|
| Enter | Starts a new line. |
| ⌘Enter | Submits the form, with submitOnShortcut. Ctrl + Enter on Windows and Linux. |
- Label it with
FieldLabeloraria-label. - On touch screens the text is at least 16px, so iOS doesn't zoom in when it's focused.
- The shortcut waits while an input method is composing, so confirming a Japanese or Chinese word never sends the message.
- With reduced motion, the box resizes instantly.
| Prop | Type | Default |
|---|---|---|
autoResizeGrow with the text instead of a fixed height. | boolean | true |
minRows | number | 3 |
maxRows | number | 10 |
submitOnShortcutSubmit the parent form with ⌘/Ctrl + Enter. | boolean | false |
shakeShake when a form submit finds it invalid. | boolean | true |
| Attribute | Description |
|---|---|
data-slot="textarea" | The textarea. |
data-autoresize | Present when it grows with its text. |
--textarea-min-rows / --textarea-max-rows | Set from minRows and maxRows. |