diff --git a/.changeset/tall-pandas-grow.md b/.changeset/tall-pandas-grow.md
new file mode 100644
index 000000000..af256497f
--- /dev/null
+++ b/.changeset/tall-pandas-grow.md
@@ -0,0 +1,5 @@
+---
+"@cloudflare/kumo": minor
+---
+
+Add auto-resize support to InputArea and Textarea.
diff --git a/packages/kumo-docs-astro/src/components/demos/InputAreaDemo.tsx b/packages/kumo-docs-astro/src/components/demos/InputAreaDemo.tsx
index 8b4afdc6f..b4f969615 100644
--- a/packages/kumo-docs-astro/src/components/demos/InputAreaDemo.tsx
+++ b/packages/kumo-docs-astro/src/components/demos/InputAreaDemo.tsx
@@ -80,6 +80,21 @@ export function InputAreaRowsDemo() {
);
}
+export function InputAreaAutoResizeDemo() {
+ return (
+
+ );
+}
+
export function InputAreaOptionalFieldDemo() {
return (
+### Auto Resize
+
+
+ Use `autoResize` to let the textarea grow vertically as users type or paste
+ multi-line content. `minRows` sets the minimum height, and the optional
+ `maxRows` caps growth — content beyond the cap scrolls.
+
+
+
+
+
### Bare InputArea
diff --git a/packages/kumo/src/components/input/input-area.tsx b/packages/kumo/src/components/input/input-area.tsx
index be0c14a32..1ff1e78b6 100644
--- a/packages/kumo/src/components/input/input-area.tsx
+++ b/packages/kumo/src/components/input/input-area.tsx
@@ -1,9 +1,151 @@
import { inputVariants } from "./input";
import { cn } from "../../utils/cn";
-import { useCallback, type ReactNode } from "react";
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ type ReactNode,
+} from "react";
import * as React from "react";
import { Field as FieldBase } from "@base-ui/react/field";
-import { Field as KumoField, normalizeFieldError, type FieldErrorMatch } from "../field/field";
+import {
+ Field as KumoField,
+ normalizeFieldError,
+ type FieldErrorMatch,
+} from "../field/field";
+
+// useLayoutEffect warns when rendered with react-dom/server (React 18);
+// fall back to useEffect on the server where neither runs anyway.
+const useIsomorphicLayoutEffect =
+ typeof window !== "undefined" ? useLayoutEffect : useEffect;
+
+function parsePx(value: string): number {
+ const parsed = Number.parseFloat(value);
+ return Number.isNaN(parsed) ? 0 : parsed;
+}
+
+/**
+ * Measures the content height of a textarea and applies it as an explicit
+ * height, optionally clamped to `maxRows`. Handles box-sizing/borders and
+ * container width changes (rewrapping).
+ */
+function useTextareaAutoResize({
+ enabled,
+ minRows,
+ maxRows,
+}: {
+ enabled: boolean;
+ minRows: number;
+ maxRows?: number;
+}) {
+ const textareaRef = useRef(null);
+ const enabledRef = useRef(enabled);
+ enabledRef.current = enabled;
+ const minRowsRef = useRef(minRows);
+ minRowsRef.current = minRows;
+ const maxRowsRef = useRef(maxRows);
+ maxRowsRef.current = maxRows;
+
+ const resize = useCallback(() => {
+ const textarea = textareaRef.current;
+ if (!enabledRef.current || !textarea || typeof window === "undefined")
+ return;
+
+ const style = window.getComputedStyle(textarea);
+ const borders =
+ parsePx(style.borderTopWidth) + parsePx(style.borderBottomWidth);
+ const padding =
+ parsePx(style.paddingTop) + parsePx(style.paddingBottom);
+ const isBorderBox = style.boxSizing === "border-box";
+
+ // Collapsing to `auto` lets scrollHeight report the true content height
+ // (needed to shrink); measure-then-write happens within a single layout
+ // pass, before paint.
+ textarea.style.height = "auto";
+ // scrollHeight = content + padding. Convert to the height the current
+ // box-sizing expects: border-box needs borders added, content-box needs
+ // padding removed.
+ let height = isBorderBox
+ ? textarea.scrollHeight + borders
+ : textarea.scrollHeight - padding;
+
+ const currentMinRows = minRowsRef.current;
+ const currentMaxRows = maxRowsRef.current;
+ if (currentMinRows > 0 || (currentMaxRows && currentMaxRows > 0)) {
+ // Computed line-height is normally a px value, but some environments
+ // (e.g. jsdom) can return "normal" or a unitless multiplier like "1.5".
+ const fontSize = parsePx(style.fontSize);
+ const rawLineHeight = style.lineHeight;
+ const lineHeight =
+ rawLineHeight === "normal" || rawLineHeight === ""
+ ? fontSize * 1.2
+ : rawLineHeight.endsWith("px")
+ ? parsePx(rawLineHeight)
+ : parsePx(rawLineHeight) * fontSize;
+ const boxSpacing = isBorderBox ? padding + borders : 0;
+ const minHeight = lineHeight * currentMinRows + boxSpacing;
+ height = Math.max(height, minHeight);
+
+ if (currentMaxRows && currentMaxRows > 0) {
+ const maxHeight = lineHeight * currentMaxRows + boxSpacing;
+ if (height > maxHeight) {
+ height = maxHeight;
+ // Content exceeds the clamp — it must stay scrollable.
+ textarea.style.overflowY = "auto";
+ } else {
+ textarea.style.overflowY = "hidden";
+ }
+ } else {
+ textarea.style.overflowY = "hidden";
+ }
+ } else {
+ textarea.style.overflowY = "hidden";
+ }
+
+ textarea.style.height = `${height}px`;
+ }, []);
+
+ // Re-measure after every commit while enabled: covers controlled value
+ // changes, size/variant/className changes, and anything else that reflows.
+ // Two extra synchronous layouts per render is cheap for a textarea and
+ // immune to stale-dependency bugs.
+ useIsomorphicLayoutEffect(() => {
+ if (enabled) resize();
+ });
+
+ // Setup/teardown per node while enabled. Restores inline styles when
+ // autoResize turns off or on unmount so the manual resize handle behavior
+ // returns untouched.
+ useIsomorphicLayoutEffect(() => {
+ if (!enabled) return;
+ const textarea = textareaRef.current;
+ if (!textarea) return;
+
+ // Width changes rewrap content and change the required height. Only
+ // react to inline-size changes — our own height writes also fire the
+ // observer and would otherwise cause redundant resize passes.
+ let lastWidth = textarea.clientWidth;
+ const observer =
+ typeof ResizeObserver !== "undefined"
+ ? new ResizeObserver(() => {
+ if (textarea.clientWidth !== lastWidth) {
+ lastWidth = textarea.clientWidth;
+ resize();
+ }
+ })
+ : null;
+ observer?.observe(textarea);
+
+ return () => {
+ observer?.disconnect();
+ textarea.style.height = "";
+ textarea.style.overflowY = "";
+ };
+ }, [enabled, resize]);
+
+ return { textareaRef, resize };
+}
export const InputArea = React.forwardRef(
(props, ref) => {
@@ -17,6 +159,10 @@ export const InputArea = React.forwardRef(
labelTooltip,
description,
error,
+ autoResize = false,
+ minRows = 1,
+ maxRows,
+ rows,
...inputProps
} = props;
@@ -35,17 +181,54 @@ export const InputArea = React.forwardRef(
// Extract required from inputProps to pass to Field for label decoration
const { required } = inputProps;
+ const { textareaRef, resize } = useTextareaAutoResize({
+ enabled: autoResize,
+ minRows,
+ maxRows,
+ });
+
+ // Forwarded ref may be a React 19 callback ref returning a cleanup
+ // function — honor it instead of calling ref(null) on detach.
+ const refCleanup = useRef<(() => void) | void>(undefined);
+ const setTextareaRef = useCallback(
+ (node: HTMLTextAreaElement | null) => {
+ textareaRef.current = node;
+
+ if (typeof ref === "function") {
+ if (node) {
+ refCleanup.current = ref(node) as (() => void) | void;
+ } else if (refCleanup.current) {
+ refCleanup.current();
+ refCleanup.current = undefined;
+ } else {
+ ref(null);
+ }
+ } else if (ref) {
+ ref.current = node;
+ }
+ },
+ [ref, textareaRef],
+ );
+
+ // Controlled inputs re-render on every keystroke, so the post-render
+ // layout effect already re-measures; resizing here too would force a
+ // second synchronous layout. Uncontrolled inputs don't re-render, so the
+ // change handler is their only resize trigger.
+ const isControlled = inputProps.value !== undefined;
const handleChange = useCallback(
(event: React.ChangeEvent) => {
onChange?.(event);
onValueChange?.(event.target.value);
+ if (!isControlled) resize();
},
- [onChange, onValueChange],
+ [onChange, onValueChange, resize, isControlled],
);
const textareaClassName = cn(
inputVariants({ size, variant, focusIndicator: true }),
"h-auto py-2", // Input variant always comes with size, but it does not apply for textarea
+ autoResize &&
+ "w-full field-sizing-content resize-none scroll-pb-2 [scrollbar-color:var(--color-kumo-line)_transparent] [scrollbar-width:thin] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:bg-transparent [&::-webkit-scrollbar-track]:my-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-kumo-line [&::-webkit-scrollbar-corner]:bg-transparent",
className,
);
@@ -65,9 +248,10 @@ export const InputArea = React.forwardRef(
render={(controlProps) => (
)}
@@ -79,9 +263,10 @@ export const InputArea = React.forwardRef(
// Render bare textarea without Field wrapper
return (
);
@@ -114,6 +299,12 @@ export type InputAreaProps = {
description?: ReactNode;
/** Error message or validation error object */
error?: string | { message: ReactNode; match: FieldErrorMatch };
+ /** Automatically resize the textarea based on its content. */
+ autoResize?: boolean;
+ /** Minimum number of rows to display when `autoResize` is enabled. @default 1 */
+ minRows?: number;
+ /** Maximum number of rows to grow to when `autoResize` is enabled; content beyond this scrolls. */
+ maxRows?: number;
// Finally, spread the native input props (least important)
} & Omit, "size">;
diff --git a/packages/kumo/src/components/input/input.test.tsx b/packages/kumo/src/components/input/input.test.tsx
index 2503cff71..302a967e7 100644
--- a/packages/kumo/src/components/input/input.test.tsx
+++ b/packages/kumo/src/components/input/input.test.tsx
@@ -1,12 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import { createRef } from "react";
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import {
Input,
inputVariants,
KUMO_INPUT_VARIANTS,
KUMO_INPUT_DEFAULT_VARIANTS,
} from "./input";
+import { InputArea } from "./input-area";
describe("Input", () => {
// Rendering
@@ -148,9 +149,7 @@ describe("Input", () => {
});
it("renders description without label", () => {
- render(
- ,
- );
+ render();
expect(screen.getByText("Enter your work email")).toBeTruthy();
});
@@ -228,3 +227,177 @@ describe("Input", () => {
expect(KUMO_INPUT_DEFAULT_VARIANTS.variant).toBe("default");
});
});
+
+describe("InputArea", () => {
+ it("auto-resizes to its scrollHeight when autoResize is true", () => {
+ const scrollHeight = vi
+ .spyOn(HTMLTextAreaElement.prototype, "scrollHeight", "get")
+ .mockReturnValue(80);
+
+ render(
+ ,
+ );
+
+ const textarea = screen.getByRole("textbox");
+ expect(textarea.style.height).toBe("80px");
+ expect(textarea.className).toContain("resize-none");
+ expect(textarea.className).toContain("field-sizing-content");
+ expect(textarea.style.overflowY).toBe("hidden");
+
+ scrollHeight.mockRestore();
+ });
+
+ it("does not touch inline height when autoResize is false", () => {
+ const { rerender } = render(
+ {}} />,
+ );
+
+ const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
+ // Simulate the user dragging the native resize handle
+ textarea.style.height = "240px";
+
+ rerender(
+ {}} />,
+ );
+ fireEvent.change(textarea, { target: { value: "one two three" } });
+
+ expect(textarea.style.height).toBe("240px");
+ expect(textarea.className).not.toContain("field-sizing-content");
+ expect(textarea.className).not.toContain("[scrollbar-width:thin]");
+ });
+
+ it("clamps to maxRows and becomes scrollable past the clamp", () => {
+ const scrollHeight = vi
+ .spyOn(HTMLTextAreaElement.prototype, "scrollHeight", "get")
+ .mockReturnValue(200);
+
+ render(
+ ,
+ );
+
+ const textarea = screen.getByRole("textbox");
+ expect(textarea.style.height).toBe("100px");
+ expect(textarea.style.overflowY).toBe("auto");
+
+ scrollHeight.mockRestore();
+ });
+
+ it("clamps to minRows when content is shorter", () => {
+ const scrollHeight = vi
+ .spyOn(HTMLTextAreaElement.prototype, "scrollHeight", "get")
+ .mockReturnValue(20);
+
+ render(
+ ,
+ );
+
+ const textarea = screen.getByRole("textbox");
+ expect(textarea.getAttribute("rows")).toBe("3");
+ expect(textarea.style.height).toBe("60px");
+ expect(textarea.style.overflowY).toBe("hidden");
+
+ scrollHeight.mockRestore();
+ });
+
+ it("treats a unitless line-height as a font-size multiplier for maxRows", () => {
+ const scrollHeight = vi
+ .spyOn(HTMLTextAreaElement.prototype, "scrollHeight", "get")
+ .mockReturnValue(500);
+
+ render(
+ ,
+ );
+
+ // 1.5 * 16px * 4 rows = 96px, not 1.5px * 4 rows
+ const textarea = screen.getByRole("textbox");
+ expect(textarea.style.height).toBe("96px");
+ expect(textarea.style.overflowY).toBe("auto");
+
+ scrollHeight.mockRestore();
+ });
+
+ it("clears inline styles on unmount", () => {
+ const scrollHeight = vi
+ .spyOn(HTMLTextAreaElement.prototype, "scrollHeight", "get")
+ .mockReturnValue(80);
+
+ const { unmount } = render(
+ ,
+ );
+ const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
+ expect(textarea.style.height).toBe("80px");
+
+ unmount();
+ expect(textarea.style.height).toBe("");
+ expect(textarea.style.overflowY).toBe("");
+
+ scrollHeight.mockRestore();
+ });
+
+ it("restores inline styles when autoResize is turned off", () => {
+ const scrollHeight = vi
+ .spyOn(HTMLTextAreaElement.prototype, "scrollHeight", "get")
+ .mockReturnValue(80);
+
+ const { rerender } = render(
+ ,
+ );
+ const textarea = screen.getByRole("textbox");
+ expect(textarea.style.height).toBe("80px");
+
+ rerender();
+ expect(textarea.style.height).toBe("");
+ expect(textarea.style.overflowY).toBe("");
+
+ scrollHeight.mockRestore();
+ });
+
+ it("auto-resizes on change and preserves value callbacks", () => {
+ const scrollHeight = vi
+ .spyOn(HTMLTextAreaElement.prototype, "scrollHeight", "get")
+ .mockReturnValue(96);
+ const onChange = vi.fn();
+ const onValueChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ const textarea = screen.getByRole("textbox");
+ fireEvent.change(textarea, { target: { value: "Longer value" } });
+
+ expect(textarea.style.height).toBe("96px");
+ expect(onChange).toHaveBeenCalledOnce();
+ expect(onValueChange).toHaveBeenCalledWith("Longer value");
+
+ scrollHeight.mockRestore();
+ });
+
+ it("forwards ref to the underlying textarea with autoResize", () => {
+ const ref = createRef();
+ render();
+ expect(ref.current).toBeInstanceOf(HTMLTextAreaElement);
+ });
+});