Autosize textareas
Five multi-line fields that differ only in height policy: one grows with its content, one grows to a cap, one starts from a generous floor, one flows between both, and one stays fixed. Growth is a single scrollHeight measurement taken on mount and on input, never on every render.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/textarea-autosizeDependencies, the @ai2/tokens theme and the component file are installed together.
Copy the source
components/ui/textarea-autosize.tsx"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
/* Autosize textarea family: 5 multi-line fields that change the height behavior.
The difference is only the height policy: growing with the content, capped,
floored, fluid and fixed. The growth is done with a single scrollHeight
measurement (the height is first set to auto, then written to scrollHeight) and
runs ONLY on mount and on input, not on every render; that way there is no layout
thrash. The cap/floor is given with CSS max-h/min-h and the inline height stays
within them; the floor derives from the size axis, which is why size stays
meaningful in every variant. A height change is a layout measure, not an
animation; that is why motion/react is not needed. Color comes ONLY from tokens.
Each one wraps a real <textarea> and passes through all the native props. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const height: Record<StyledSize, string> = {
sm: "min-h-16 text-sm",
md: "min-h-20 text-sm",
lg: "min-h-24 text-base",
xl: "min-h-28 text-base",
}
/* Min varyantinin comert tabani; size ile birlikte olceklenir. */
const floor: Record<StyledSize, string> = {
sm: "min-h-24",
md: "min-h-32",
lg: "min-h-40",
xl: "min-h-48",
}
const areaBase =
"w-full rounded-lg border border-field-border bg-transparent px-3 py-2 text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-danger aria-invalid:ring-danger/20 dark:aria-invalid:ring-danger/40"
type Props = React.ComponentProps<"textarea"> & { size?: StyledSize }
/* A hook that measures height from the content. The measurement is triggered only on mount and on input, not in the render loop. */
function useAutosize() {
const ref = React.useRef<HTMLTextAreaElement>(null)
const measure = React.useCallback(() => {
const el = ref.current
if (!el) return
el.style.height = "auto"
el.style.height = `${el.scrollHeight}px`
}, [])
React.useEffect(() => {
measure()
}, [measure])
return { ref, measure }
}
/* Shared field: it carries an accessible name instead of a visible label (aria-label), which the consumer can override through props. When autosize is off the measurement is never wired up. */
function AutosizeField({
className,
size = "md",
autosize,
onChange,
...props
}: Props & { autosize: boolean }) {
const { ref, measure } = useAutosize()
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
if (autosize) measure()
onChange?.(e)
}
return (
<textarea
ref={autosize ? ref : undefined}
rows={1}
aria-label="Message"
{...props}
onChange={handleChange}
className={cn(areaBase, height[size], className)}
/>
)
}
/* Grow: it grows as content is added; no handle, no ceiling. */
export function GrowTextarea({ className, size = "md", ...props }: Props) {
return (
<span data-slot="styled-textarea" className="flex w-72 max-w-full flex-col">
<AutosizeField autosize size={size} {...props} className={cn("resize-none overflow-hidden", className)} />
</span>
)
}
/* Max: it grows with the content but stops at a ceiling and lets scrolling take
over. */
export function MaxTextarea({ className, size = "md", ...props }: Props) {
return (
<span data-slot="styled-textarea" className="flex w-72 max-w-full flex-col">
<AutosizeField autosize size={size} {...props} className={cn("max-h-40 resize-none overflow-y-auto", className)} />
</span>
)
}
/* Min: it starts from a generous floor, never goes below it, and grows with the
content. */
export function MinTextarea({ className, size = "md", ...props }: Props) {
return (
<span data-slot="styled-textarea" className="flex w-72 max-w-full flex-col">
<AutosizeField autosize size={size} {...props} className={cn(floor[size], "resize-none overflow-hidden", className)} />
</span>
)
}
/* Fluid: takes the full width, flowing from the size baseline up to a ceiling. */
export function FluidTextarea({ className, size = "md", ...props }: Props) {
return (
<span data-slot="styled-textarea" className="flex w-full max-w-full flex-col">
<AutosizeField autosize size={size} {...props} className={cn("max-h-64 resize-none overflow-y-auto", className)} />
</span>
)
}
/* Fixed: no growth; the height is fixed by size and overflowing content scrolls. */
export function FixedTextarea({ className, size = "md", ...props }: Props) {
return (
<span data-slot="styled-textarea" className="flex w-72 max-w-full flex-col">
<AutosizeField autosize={false} size={size} {...props} className={cn("resize-none overflow-y-auto", className)} />
</span>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Grow
Grows with its content, with no cap and no drag handle.
import { GrowTextarea } from "@/components/ui/textarea-autosize"
<GrowTextarea placeholder="Message" />Max
Grows with its content, then stops at a cap and scrolls.
import { MaxTextarea } from "@/components/ui/textarea-autosize"
<MaxTextarea placeholder="Message" />Min
Starts from a generous floor that scales with size.
import { MinTextarea } from "@/components/ui/textarea-autosize"
<MinTextarea placeholder="Message" />Fluid
Fills its container width and flows between a floor and a cap.
import { FluidTextarea } from "@/components/ui/textarea-autosize"
<FluidTextarea placeholder="Message" />Fixed
Never grows; the height stays at the size and content scrolls.
import { FixedTextarea } from "@/components/ui/textarea-autosize"
<FixedTextarea placeholder="Message" />ai2 Autosize textareas: 5 styled variations on the token system
The ai2 Autosize textareas are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around multi-line fields that differ in height behavior. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: height follows the content directly and is a layout measurement, not an animation. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, nothing changes, because no motion is involved.
What is in the ai2 Autosize textareas?
5 exports in one file: Grow, Max, Min, Fluid and Fixed. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: height follows the content directly and is a layout measurement, not an animation.
- Reduced-motion aware: Under prefers-reduced-motion, nothing changes, because no motion is involved.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Autosize textareas sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.