Component
Textarea Auto Resize
Textarea that grows and shrinks with its content, capped at a configurable maximum height.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/textareas/textarea-auto-resize/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Textareas/textarea-auto-resize React/Components/Textareas/textarea-auto-resize import type { ChangeEvent, ReactNode, TextareaHTMLAttributes } from "react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
const TEXTAREA_BASE =
"w-full min-h-[80px] resize-none overflow-y-auto rounded-[var(--ds-radius-sm)] border bg-[var(--ds-color-input)] px-3 py-2 text-sm leading-5 text-[var(--ds-color-foreground)] shadow-none transition-colors duration-150 ease-out placeholder:text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-input-hover,var(--ds-color-input))] focus:bg-[var(--ds-color-input-focus,var(--ds-color-input))] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:bg-[var(--ds-color-muted)] disabled:text-[var(--ds-color-muted-foreground)] disabled:opacity-60 read-only:bg-[var(--ds-color-surface-subtle)] read-only:text-[var(--ds-color-muted-foreground)] motion-reduce:transition-none";
const TEXTAREA_BORDER =
"border-[var(--ds-color-border)] focus:border-[var(--ds-color-border-strong)]";
export interface TextareaAutoResizeProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
/** Visible label; omit and pass `aria-label` for a bare control. */
label?: ReactNode;
/** Maximum height in px before the field scrolls instead of growing. */
maxHeight?: number;
}
/**
* Auto-resizing textarea. The field grows with its content and shrinks
* when content is removed, capped at `maxHeight` (default 320px) where it
* scrolls instead of growing further. Resizing is managed programmatically
* from the real value — controlled (`value`/`onChange`) and uncontrolled
* (`defaultValue`) both work, and the initial content is measured on
* mount. Height changes are instant (no animation), so behavior is
* identical under prefers-reduced-motion. Manual resize is disabled
* (`resize-none`) because the component owns the height; without the
* effect running it still renders as a normal scrollable `rows`-sized
* textarea.
*/
export function TextareaAutoResize({
label,
id,
className,
rows = 3,
maxHeight = 320,
value,
defaultValue = "",
onChange,
...props
}: TextareaAutoResizeProps) {
const generatedId = useId();
const textareaId = id ?? `textarea-${generatedId}`;
const ref = useRef<HTMLTextAreaElement>(null);
const [internalValue, setInternalValue] = useState(String(defaultValue ?? ""));
const currentValue = value === undefined ? internalValue : String(value ?? "");
const adjust = useCallback(() => {
const el = ref.current;
if (!el) return;
// Reset to natural height first so removed lines collapse the field,
// then clamp to the cap. Two writes + one read per change, only when
// the value, cap, or available width actually changes.
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`;
}, [maxHeight]);
// Measure on mount (initial value) and after every value change.
useEffect(() => {
adjust();
}, [adjust, currentValue]);
// Re-measure on viewport resize (width changes reflow wrapped lines) and
// once the web font settles (a late font load changes the wrapping, so the
// first measurement can otherwise hold a stale height). Both are single,
// cleaned-up subscriptions.
useEffect(() => {
window.addEventListener("resize", adjust);
document.fonts.ready.then(adjust).catch(() => {});
return () => window.removeEventListener("resize", adjust);
}, [adjust]);
function handleChange(event: ChangeEvent<HTMLTextAreaElement>) {
if (value === undefined) setInternalValue(event.target.value);
onChange?.(event);
}
const control = (
<textarea
ref={ref}
id={textareaId}
rows={rows}
value={currentValue}
onChange={handleChange}
className={cx(TEXTAREA_BASE, TEXTAREA_BORDER, className)}
{...props}
/>
);
if (label === undefined || label === null) {
return control;
}
return (
<div className="w-full">
<label
htmlFor={textareaId}
className="mb-2 block text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]"
>
{label}
</label>
{control}
</div>
);
}
export default TextareaAutoResize; /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import { useCallback, useEffect, useId, useRef, useState } from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const TEXTAREA_BASE = "w-full min-h-[80px] resize-none overflow-y-auto rounded-[var(--ds-radius-sm)] border bg-[var(--ds-color-input)] px-3 py-2 text-sm leading-5 text-[var(--ds-color-foreground)] shadow-none transition-colors duration-150 ease-out placeholder:text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-input-hover,var(--ds-color-input))] focus:bg-[var(--ds-color-input-focus,var(--ds-color-input))] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:bg-[var(--ds-color-muted)] disabled:text-[var(--ds-color-muted-foreground)] disabled:opacity-60 read-only:bg-[var(--ds-color-surface-subtle)] read-only:text-[var(--ds-color-muted-foreground)] motion-reduce:transition-none";
const TEXTAREA_BORDER = "border-[var(--ds-color-border)] focus:border-[var(--ds-color-border-strong)]";
export function TextareaAutoResize({
label,
id,
className,
rows = 3,
maxHeight = 320,
value,
defaultValue = "",
onChange,
...props
}) {
const generatedId = useId();
const textareaId = id ?? `textarea-${generatedId}`;
const ref = useRef(null);
const [internalValue, setInternalValue] = useState(String(defaultValue ?? ""));
const currentValue = value === undefined ? internalValue : String(value ?? "");
const adjust = useCallback(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`;
}, [maxHeight]);
useEffect(() => {
adjust();
}, [adjust, currentValue]);
useEffect(() => {
window.addEventListener("resize", adjust);
document.fonts.ready.then(adjust).catch(() => {
});
return () => window.removeEventListener("resize", adjust);
}, [adjust]);
function handleChange(event) {
if (value === undefined) setInternalValue(event.target.value);
onChange?.(event);
}
const control = <textarea
ref={ref}
id={textareaId}
rows={rows}
value={currentValue}
onChange={handleChange}
className={cx(TEXTAREA_BASE, TEXTAREA_BORDER, className)}
{...props}
/>;
if (label === undefined || label === null) {
return control;
}
return <div className="w-full">
<label
htmlFor={textareaId}
className="mb-2 block text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]"
>
{label}
</label>
{control}
</div>;
}
export default TextareaAutoResize; # Textarea Auto Resize
Textarea that grows and shrinks with its content, capped at a configurable maximum height.
## Usage
```tsx
<TextareaAutoResize label="Commit message" maxHeight={240} rows={2} />
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
<TextareaAutoResize label="Commit message" maxHeight={240} rows={2} />
```
## Props
| Name | Type | Default | Description |
|---|---|---:|---|
| `label` | `ReactNode` | — | Visible label (omit and pass `aria-label` for a bare control). |
| `value` / `defaultValue` | `string` | — | Controlled / uncontrolled value. |
| `onChange` | `(event) => void` | — | Native change callback. |
| `rows` | `number` | `3` | Visible rows — the natural height floor (with `min-h-[80px]`). |
| `placeholder` | `string` | — | Muted placeholder (never critical information). |
| `disabled` | `boolean` | — | Native disabled — not focusable, not submitted. |
| `readOnly` | `boolean` | — | Native read-only — focusable, selectable, submitted. |
| `required` / `name` / `id` | `boolean` / `string` | — | Native form semantics (`id` also the label `htmlFor`). |
| `minLength` / `maxLength` | `number` | — | Native length constraints. |
| `className` | `string` | — | Extra Tailwind classes merged onto the control. |
| other native props / `aria-*` | — | — | Passed through to the `<textarea>`. |
| `maxHeight` | `number` | `320` | Height cap in px — the field scrolls past it instead of growing. |
## States
Native textarea whose height tracks its content: it grows as lines are added, shrinks when they are removed, and stops at `maxHeight` (default 320px) where it scrolls. Measurement runs from the real value — initial content, uncontrolled typing, and controlled `value` updates all trigger it — plus once on viewport resize (wrapped lines reflow). Manual resize is disabled (`resize-none`) because the component owns the height; height changes are instant, so nothing animates and reduced-motion users see identical behavior. Without effects running it still renders as a normal `rows`-sized textarea.
## Accessibility
Same native semantics as the reference textarea — real focus, keyboard, selection, and form behavior. No live regions are needed because the resize is a visual nicety, not a state change. Visible `focus-visible` ring; `resize-none` is safe here because the field grows on its own.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-input)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This textarea uses the semantic color, radius, spacing, and motion tokens.
## Notes
Use for inputs where the content length varies wildly and scrolling a fixed box hides context: commit messages, review comments, support replies. Keep `maxHeight` sane so a paste of 500 lines cannot push the rest of the form off-screen. 109 lines UTF-8 · LF · Spaces: 2
Continue browsing