Components
Autosize Textarea
A real <textarea> that grows and shrinks to fit its content between min/max bounds — delivered by the single CSS line field-sizing: content instead of the classic scrollHeight JavaScript hack. Where unsupported it degrades to a plain fixed field. Zero JS.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/autosize-textarea.json2. Use it
import { AutosizeTextarea } from "@/components/ui/autosize-textarea"
<AutosizeTextarea name="reply" placeholder="Write a reply…" />Or copy the source manually
/** @jsxImportSource hono/jsx */
import { cn, type ClassValue } from "@/registry/lib/cn"
// Autosize Textarea — a <textarea> that grows and shrinks to fit its content
// between min/max bounds, delivered by ONE CSS declaration instead of the
// classic scrollHeight JS hack. Source of truth for the platform behaviour:
// repos/mdn/files/en-us/web/css/reference/properties/field-sizing/index.md
// - "field-sizing: content overrides the default preferred sizing of form
// elements … configure text inputs to shrinkwrap their content and grow
// as more text is entered."
// - "<textarea> … If unable to grow due to a width constraint, they grow in
// height to display additional rows … When a height constraint is then
// reached, they show a scrollbar."
// - "rows/cols have no effect on <textarea> with field-sizing: content set."
// - "using min-height and max-height alongside field-sizing: content is
// quite effective … allow the control to grow and shrink … and prevent
// the control from becoming too large or too small."
// repos/mdn/files/en-us/web/html/reference/elements/textarea/index.md
// (native element, dirname, wrap, readonly/disabled semantics)
//
// Tailwind v4 ships the utility natively — see
// repos/tailwindcss/packages/tailwindcss/src/utilities.ts
// staticUtility('field-sizing-content', [['field-sizing','content']])
// staticUtility('field-sizing-fixed', [['field-sizing','fixed']])
//
// htmx attrs (hx-post / hx-trigger="input changed delay:…") verified against
// repos/htmx/www/reference.md (forwarded untouched via {...rest}).
//
// Style analogue: registry/ui/textarea.tsx (shares the base field styling).
//
// DEGRADATION: where field-sizing is unsupported, the rule is simply ignored
// and the element renders as an ordinary fixed-height textarea sized by
// min-height (and rows, which the browser then honours). No JS, no polyfill —
// progressive enhancement, not emulation. Pass autosize={false} to opt out
// explicitly (field-sizing-fixed), turning it into a plain bounded textarea.
// Bounds are expressed as utilities so a single CSS line drives the resize.
// Defaults: grow from ~2 lines up to ~10 lines, then scroll.
const base =
"flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none " +
"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-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " +
"md:text-sm dark:bg-input/30 " +
// htmx-request: dim while a request triggered by/targeting this textarea is
// in flight (e.g. autosave / live-validation).
"[&.htmx-request]:opacity-70"
// Sizing keyword maps. content => grows with text; fixed => classic textarea
// (resizable handle only). Kept as Record<Union,string> per house style.
const sizing: Record<"content" | "fixed", string> = {
content: "field-sizing-content resize-none",
fixed: "field-sizing-fixed resize-y",
}
export function autosizeTextareaClasses(opts?: {
autosize?: boolean
minHeight?: ClassValue
maxHeight?: ClassValue
class?: ClassValue
}): string {
const auto = opts?.autosize !== false
return cn(
base,
auto ? sizing.content : sizing.fixed,
opts?.minHeight ?? "min-h-16",
// Past the max, field-sizing: content yields a scrollbar (per MDN).
opts?.maxHeight ?? "max-h-80",
"overflow-auto",
opts?.class,
)
}
type AutosizeTextareaProps = {
class?: ClassValue
id?: string
name?: string
value?: string
defaultValue?: string
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
// Autosize behaviour. true (default) => field-sizing: content; false =>
// field-sizing: fixed, a plain bounded textarea with a drag handle.
autosize?: boolean
// Lower / upper growth bounds, as Tailwind height utilities. These are the
// RIGHT levers for field-sizing per MDN — not width/height, which would
// reimpose a fixed size and defeat the feature.
minHeight?: ClassValue
maxHeight?: ClassValue
// rows/cols are honoured ONLY as the no-support fallback size — they have no
// effect once field-sizing: content applies (MDN). Useful for graceful
// degradation in older engines.
rows?: number
cols?: number
// Validation
minLength?: number
maxLength?: number
// Mobile UX
autocomplete?: string
autofocus?: boolean
spellcheck?: boolean
autocapitalize?: "off" | "none" | "on" | "sentences" | "words" | "characters"
autocorrect?: "on" | "off"
// Submits the text directionality (ltr/rtl) as a separate form field.
// See repos/mdn/files/en-us/web/html/reference/elements/textarea/index.md
dirname?: string
// Wrapping
wrap?: "hard" | "soft" | "off"
// ARIA
ariaLabel?: string
ariaLabelledby?: string
ariaDescribedby?: string
ariaInvalid?: boolean | "grammar" | "spelling"
ariaRequired?: boolean
// Form metadata
form?: string
// htmx attributes — fire on blur or hx-trigger="input changed delay:300ms"
// for live validation / autosave patterns. See repos/htmx/www/reference.md.
"hx-get"?: string
"hx-post"?: string
"hx-put"?: string
"hx-patch"?: string
"hx-target"?: string
"hx-swap"?: string
"hx-trigger"?: string
"hx-indicator"?: string
"hx-vals"?: string
"hx-include"?: string
}
export function AutosizeTextarea(props: AutosizeTextareaProps) {
const {
class: className,
autosize,
minHeight,
maxHeight,
value,
defaultValue,
ariaLabel,
ariaLabelledby,
ariaDescribedby,
ariaInvalid,
ariaRequired,
...rest
} = props
return (
<textarea
class={autosizeTextareaClasses({ autosize, minHeight, maxHeight, class: className })}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
aria-invalid={ariaInvalid === undefined ? undefined : String(ariaInvalid)}
aria-required={ariaRequired === undefined ? undefined : String(ariaRequired)}
data-slot="autosize-textarea"
data-autosize={autosize === false ? "false" : "true"}
{...rest}
>{value ?? defaultValue}</textarea>
)
}
1. Save the file
Copy autosize-textarea.html into templates/components/.
2. Use it
{% from "components/autosize-textarea.html" import autosize_textarea %}
{{ autosize_textarea(name="reply", placeholder="Write a reply…") }}View source
{# Autosize Textarea macro — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/autosize-textarea.tsx for Python/Flask/FastAPI/Django/Jinja2.
A <textarea> that grows/shrinks to fit its content between min/max bounds via
the single CSS rule `field-sizing: content` — no scrollHeight JS hack. See
repos/mdn/files/en-us/web/css/reference/properties/field-sizing/index.md.
Tailwind utility: field-sizing-content (repos/tailwindcss/.../utilities.ts).
Usage:
{% from "components/autosize-textarea.html" import autosize_textarea %}
{{ autosize_textarea(name="reply", placeholder="Write a reply…") }}
Pass autosize=false for a plain bounded textarea (field-sizing: fixed).
minheight/maxheight are Tailwind height utilities (default min-h-16 / max-h-80). #}
{% macro autosize_textarea(
id=none,
name=none,
value=none,
placeholder=none,
required=false,
disabled=false,
readonly=false,
autosize=true,
minheight="min-h-16",
maxheight="max-h-80",
rows=none,
cols=none,
minlength=none,
maxlength=none,
autocomplete=none,
autocapitalize=none,
autocorrect=none,
autofocus=false,
spellcheck=none,
dirname=none,
wrap=none,
form=none,
aria_label=none,
aria_labelledby=none,
aria_describedby=none,
aria_invalid=none,
aria_required=none,
extra_class="",
**attrs
) %}
{%- set base -%}
flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none 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-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 md:text-sm dark:bg-input/30 [&.htmx-request]:opacity-70
{%- endset -%}
{%- set sizing = "field-sizing-content resize-none" if autosize else "field-sizing-fixed resize-y" -%}
<textarea class="{{ base }} {{ sizing }} {{ minheight }} {{ maxheight }} overflow-auto {{ extra_class }}"
{%- if id %} id="{{ id }}"{% endif %}
{%- if name %} name="{{ name }}"{% endif %}
{%- if placeholder %} placeholder="{{ placeholder }}"{% endif %}
{%- if required %} required{% endif %}
{%- if disabled %} disabled{% endif %}
{%- if readonly %} readonly{% endif %}
{%- if rows is not none %} rows="{{ rows }}"{% endif %}
{%- if cols is not none %} cols="{{ cols }}"{% endif %}
{%- if minlength is not none %} minlength="{{ minlength }}"{% endif %}
{%- if maxlength is not none %} maxlength="{{ maxlength }}"{% endif %}
{%- if autocomplete %} autocomplete="{{ autocomplete }}"{% endif %}
{%- if autocapitalize %} autocapitalize="{{ autocapitalize }}"{% endif %}
{%- if autocorrect %} autocorrect="{{ autocorrect }}"{% endif %}
{%- if autofocus %} autofocus{% endif %}
{%- if spellcheck is not none %} spellcheck="{{ 'true' if spellcheck else 'false' }}"{% endif %}
{%- if dirname %} dirname="{{ dirname }}"{% endif %}
{%- if wrap %} wrap="{{ wrap }}"{% endif %}
{%- if form %} form="{{ form }}"{% endif %}
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
{%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
{%- if aria_invalid is not none %} aria-invalid="{{ aria_invalid|string|lower }}"{% endif %}
{%- if aria_required is not none %} aria-required="{{ aria_required|string|lower }}"{% endif %}
data-slot="autosize-textarea"
data-autosize="{{ 'false' if not autosize else 'true' }}"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{% if value is not none %}{{ value }}{% endif %}</textarea>
{% endmacro %}
1. Save the file
Add autosize-textarea.tmpl alongside your templates.
2. Use it
tpl.ExecuteTemplate(w, "autosize-textarea", map[string]any{
"Name": "reply",
"Placeholder": "Write a reply…",
})View source
{{/*
Autosize Textarea template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/autosize-textarea.tsx.
A <textarea> that grows/shrinks to fit its content between min/max bounds via
the single CSS rule `field-sizing: content` — no scrollHeight JS hack. See
repos/mdn/files/en-us/web/css/reference/properties/field-sizing/index.md.
Tailwind utility: field-sizing-content (repos/tailwindcss/.../utilities.ts).
Usage:
type AutosizeTextareaArgs struct {
ID, Name, Value, Placeholder string
Required, Disabled, Readonly, Autofocus bool
Autosize *bool // tri-state; nil => autosize on
MinHeight, MaxHeight string // Tailwind height utils
Rows, Cols int
MinLength, MaxLength int
Autocomplete, Autocapitalize, Autocorrect, Wrap, Dirname string
Spellcheck *bool // tri-state
Form, AriaLabel, AriaLabelledby, AriaDescribedby string
AriaInvalid, AriaRequired string
Attrs map[string]string
}
tpl.ExecuteTemplate(w, "autosize-textarea", AutosizeTextareaArgs{
Name: "reply", Placeholder: "Write a reply…",
})
Pass Autosize=&false for a plain bounded textarea (field-sizing: fixed).
*/}}
{{define "autosize-textarea"}}
{{- $base := "flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none 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-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 md:text-sm dark:bg-input/30 [&.htmx-request]:opacity-70" -}}
{{- $autosize := true -}}{{- if .Autosize}}{{- $autosize = deref .Autosize -}}{{- end -}}
{{- $sizing := "field-sizing-content resize-none" -}}{{- if not $autosize}}{{- $sizing = "field-sizing-fixed resize-y" -}}{{- end -}}
{{- $minH := or .MinHeight "min-h-16" -}}
{{- $maxH := or .MaxHeight "max-h-80" -}}
<textarea class="{{$base}} {{$sizing}} {{$minH}} {{$maxH}} overflow-auto"
{{- if .ID}} id="{{.ID}}"{{end}}
{{- if .Name}} name="{{.Name}}"{{end}}
{{- if .Placeholder}} placeholder="{{.Placeholder}}"{{end}}
{{- if .Required}} required{{end}}
{{- if .Disabled}} disabled{{end}}
{{- if .Readonly}} readonly{{end}}
{{- if .Rows}} rows="{{.Rows}}"{{end}}
{{- if .Cols}} cols="{{.Cols}}"{{end}}
{{- if .MinLength}} minlength="{{.MinLength}}"{{end}}
{{- if .MaxLength}} maxlength="{{.MaxLength}}"{{end}}
{{- if .Autocomplete}} autocomplete="{{.Autocomplete}}"{{end}}
{{- if .Autocapitalize}} autocapitalize="{{.Autocapitalize}}"{{end}}
{{- if .Autocorrect}} autocorrect="{{.Autocorrect}}"{{end}}
{{- if .Autofocus}} autofocus{{end}}
{{- if .Spellcheck}} spellcheck="{{if deref .Spellcheck}}true{{else}}false{{end}}"{{end}}
{{- if .Dirname}} dirname="{{.Dirname}}"{{end}}
{{- if .Wrap}} wrap="{{.Wrap}}"{{end}}
{{- if .Form}} form="{{.Form}}"{{end}}
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
{{- if .AriaDescribedby}} aria-describedby="{{.AriaDescribedby}}"{{end}}
{{- if .AriaInvalid}} aria-invalid="{{.AriaInvalid}}"{{end}}
{{- if .AriaRequired}} aria-required="{{.AriaRequired}}"{{end}}
data-slot="autosize-textarea"
data-autosize="{{if $autosize}}true{{else}}false{{end}}"
{{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>{{.Value}}</textarea>
{{end}}
1. Save the file
Drop autosize_textarea.ex into lib/my_app_web/components/.
2. Use it
<.autosize_textarea name="reply" placeholder="Write a reply…" />View source
defmodule ShadcnHtmx.Components.AutosizeTextarea do
@moduledoc """
Autosize Textarea — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/autosize-textarea.tsx. A `<textarea>` that grows and
shrinks to fit its content between min/max bounds via the single CSS rule
`field-sizing: content` — no scrollHeight JS hack required. See
repos/mdn/files/en-us/web/css/reference/properties/field-sizing/index.md and
the Tailwind utility in repos/tailwindcss/.../utilities.ts (field-sizing-content).
Where `field-sizing` is unsupported the rule is ignored and the element renders
as a plain fixed-height textarea — progressive enhancement, not emulation.
## Examples
<.autosize_textarea name="reply" placeholder="Write a reply…" />
<.autosize_textarea name="comment"
hx-post="/comments/draft" hx-trigger="input changed delay:500ms" />
<.autosize_textarea autosize={false} value="A plain bounded textarea." />
"""
use Phoenix.Component
@base "flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs " <>
"transition-[color,box-shadow] outline-none " <>
"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-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " <>
"md:text-sm dark:bg-input/30 " <>
"[&.htmx-request]:opacity-70"
attr :class, :string, default: nil
attr :value, :string, default: nil
attr :autosize, :boolean, default: true
attr :min_height, :string, default: "min-h-16"
attr :max_height, :string, default: "max-h-80"
attr :rest, :global,
include:
~w(hx-get hx-post hx-put hx-patch hx-target hx-swap hx-trigger hx-indicator hx-vals hx-include
id name placeholder required disabled readonly
rows cols minlength maxlength autocomplete autocapitalize autocorrect autofocus spellcheck dirname wrap form
aria-label aria-labelledby aria-describedby aria-invalid aria-required)
def autosize_textarea(assigns) do
sizing = if assigns.autosize, do: "field-sizing-content resize-none", else: "field-sizing-fixed resize-y"
assigns =
assigns
|> assign(:base_class, @base)
|> assign(:sizing_class, sizing)
~H"""
<textarea
class={[@base_class, @sizing_class, @min_height, @max_height, "overflow-auto", @class]}
data-slot="autosize-textarea"
data-autosize={to_string(@autosize)}
{@rest}
>{@value}</textarea>
"""
end
end
1. Save the file
Paste the markup; relies only on theme tokens. field-sizing-content is one Tailwind v4 utility.
2. Use it
<textarea name="reply" placeholder="Write a reply…" data-slot="autosize-textarea"
class="flex w-full rounded-md border border-input bg-transparent px-3 py-2
text-base … field-sizing-content resize-none min-h-16 max-h-80 overflow-auto"></textarea>View source
<!--
shadcn-htmx — raw HTML autosize-textarea snippets.
Mirrors registry/ui/autosize-textarea.tsx. The `field-sizing: content` rule
(Tailwind utility field-sizing-content) makes the element grow/shrink to fit
its content between the min-h-* / max-h-* bounds — no scrollHeight JS hook.
See repos/mdn/files/en-us/web/css/reference/properties/field-sizing/index.md.
Where field-sizing is unsupported the rule is ignored and the textarea renders
as an ordinary fixed-height field (sized by min-height + rows). Progressive
enhancement, not emulation — relies only on theme tokens.
BASE:
flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-base
shadow-xs transition-[color,box-shadow] outline-none
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-destructive aria-invalid:ring-destructive/20
dark:aria-invalid:ring-destructive/40
md:text-sm dark:bg-input/30
[&.htmx-request]:opacity-70
AUTOSIZE: field-sizing-content resize-none min-h-16 max-h-80 overflow-auto
FIXED: field-sizing-fixed resize-y min-h-16 max-h-80 overflow-auto
-->
<!-- Basic — grows as you type, scrolls past max-h-80 -->
<textarea name="reply" placeholder="Write a reply… the field grows as you type"
data-slot="autosize-textarea" data-autosize="true"
class="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm dark:bg-input/30 field-sizing-content resize-none min-h-16 max-h-80 overflow-auto"></textarea>
<!-- Plain bounded (autosize off) — classic fixed textarea with a drag handle -->
<textarea name="notes" data-slot="autosize-textarea" data-autosize="false"
class="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm dark:bg-input/30 field-sizing-fixed resize-y min-h-16 max-h-80 overflow-auto">A plain bounded textarea.</textarea>
<!-- Disabled -->
<textarea disabled data-slot="autosize-textarea" data-autosize="true"
class="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 field-sizing-content resize-none min-h-16 max-h-80 overflow-auto">Locked content.</textarea>
<!-- Invalid + describedby -->
<div>
<textarea name="comment" aria-invalid="true" aria-describedby="comment-error"
placeholder="Comment…" data-slot="autosize-textarea" data-autosize="true"
class="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 md:text-sm dark:bg-input/30 field-sizing-content resize-none min-h-16 max-h-80 overflow-auto"></textarea>
<p id="comment-error" class="mt-1 text-sm text-destructive">Comment can't be empty.</p>
</div>
<!-- htmx — autosave draft on input pause -->
<textarea name="draft" placeholder="Start writing… we'll save as you pause"
data-slot="autosize-textarea" data-autosize="true"
hx-post="/drafts/123" hx-trigger="input changed delay:600ms" hx-swap="none"
class="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm dark:bg-input/30 field-sizing-content resize-none min-h-16 max-h-80 overflow-auto [&.htmx-request]:opacity-70"></textarea>
Examples
Grow as you type
field-sizing: content shrinkwraps the textarea to its value and grows it line by line — no resize observer, no scrollHeight measuring.
The old way to autosize a textarea was to listen for input events, write the value into a hidden mirror, read its scrollHeight and assign it back as a pixel height on every keystroke. The platform now does this itself: one declaration, field-sizing: content, and the control resizes to its contents. Per MDN, once it is set the rows / cols attributes have no effect — sizing is driven by the text.
<Label htmlFor="reply">Reply</Label>
<AutosizeTextarea id="reply" name="reply"
placeholder="Write a reply. The field grows as you type…" />{{ label("Reply", for_="reply") }}
{{ autosize_textarea(id="reply", name="reply",
placeholder="Write a reply. The field grows as you type…") }}{{template "label" (dict "For" "reply" "Text" "Reply")}}
{{template "autosize-textarea" (dict
"ID" "reply" "Name" "reply"
"Placeholder" "Write a reply. The field grows as you type…"
)}}<.label for="reply">Reply</.label>
<.autosize_textarea id="reply" name="reply"
placeholder="Write a reply. The field grows as you type…" /><div class="grid w-full max-w-md gap-2">
<label for="ex-reply" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Reply</label>
<textarea class="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none 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-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 md:text-sm dark:bg-input/30 [&.htmx-request]:opacity-70 field-sizing-content resize-none min-h-16 max-h-80 overflow-auto" data-slot="autosize-textarea" data-autosize="true" id="ex-reply" name="reply" placeholder="Write a reply. The field grows as you type…">
</textarea>
</div>Further reading
Min / max bounds
Pass minHeight / maxHeight (Tailwind height utilities). The field grows freely between them, then scrolls past the max.
MDN is explicit that you should pair field-sizing: content with min-height / max-height, not a fixed height (a fixed height would reimpose a static size and defeat the feature). Once the value reaches the maximum, the browser shows a scrollbar as usual. Defaults here are min-h-16 to max-h-80.
<AutosizeTextarea name="note"
minHeight="min-h-10" maxHeight="max-h-24" />{{ autosize_textarea(name="note",
minheight="min-h-10", maxheight="max-h-24") }}{{template "autosize-textarea" (dict
"Name" "note" "MinHeight" "min-h-10" "MaxHeight" "max-h-24"
)}}<.autosize_textarea name="note"
min_height="min-h-10" max_height="max-h-24" /><div class="grid w-full max-w-md gap-2">
<label for="ex-bounded" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Short note (caps at ~3 lines)</label>
<textarea class="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none 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-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 md:text-sm dark:bg-input/30 [&.htmx-request]:opacity-70 field-sizing-content resize-none min-h-10 max-h-24 overflow-auto" data-slot="autosize-textarea" data-autosize="true" id="ex-bounded" name="note">
This field starts small, grows a couple of lines, then scrolls once it hits its max height. Try adding several more lines to see the scrollbar appear.
</textarea>
</div>Further reading
Opt out + graceful fallback
autosize={false} renders a plain bounded textarea (field-sizing: fixed) with a drag handle. The same fallback is what older browsers see automatically.
This is progressive enhancement, not emulation. Browsers that don't understand field-sizing simply ignore the rule and render an ordinary fixed-height textarea sized by min-height — no broken layout, no JS shim. Passing autosize={false} makes that the explicit behaviour everywhere via field-sizing: fixed, restoring the native resize handle.
<AutosizeTextarea name="notes" autosize={false}
value="A plain bounded textarea." />{{ autosize_textarea(name="notes", autosize=false,
value="A plain bounded textarea.") }}{{template "autosize-textarea" (dict
"Name" "notes" "Autosize" false "Value" "A plain bounded textarea."
)}}<.autosize_textarea name="notes" autosize={false}
value="A plain bounded textarea." /><div class="grid w-full max-w-md gap-2">
<label for="ex-fixed-ta" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Notes (fixed, drag to resize)</label>
<textarea class="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none 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-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 md:text-sm dark:bg-input/30 [&.htmx-request]:opacity-70 field-sizing-fixed resize-y min-h-16 max-h-80 overflow-auto" data-slot="autosize-textarea" data-autosize="false" id="ex-fixed-ta" name="notes">A plain bounded textarea — field-sizing: fixed. Drag the corner handle.</textarea>
</div>Further reading
htmx — autosave draft on pause
hx-trigger="input changed delay:600ms" fires after the user stops typing. The server stores the draft and returns 204.
Autosize and autosave compose cleanly: the field grows with the draft while input changed delay:600ms fires only after a pause, so you don't hammer the server on every keystroke. While the request is in flight htmx adds .htmx-request — the base style dims the field to 70% as a subtle "saving" hint.
The field briefly dims while the server records each pause.
<AutosizeTextarea name="draft" placeholder="Start writing…"
hx-post="/api/drafts" hx-trigger="input changed delay:600ms"
hx-swap="none" />{{ autosize_textarea(name="draft", placeholder="Start writing…",
hx_post="/api/drafts",
hx_trigger="input changed delay:600ms",
hx_swap="none") }}{{template "autosize-textarea" (dict
"Name" "draft" "Placeholder" "Start writing…"
"Attrs" (dict
"hx-post" "/api/drafts"
"hx-trigger" "input changed delay:600ms"
"hx-swap" "none"
)
)}}<.autosize_textarea name="draft" placeholder="Start writing…"
hx-post={~p"/api/drafts"} hx-trigger="input changed delay:600ms"
hx-swap="none" /><div class="grid w-full max-w-md gap-2">
<label for="ex-draft" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Draft</label>
<textarea class="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none 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-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 md:text-sm dark:bg-input/30 [&.htmx-request]:opacity-70 field-sizing-content resize-none min-h-16 max-h-80 overflow-auto" data-slot="autosize-textarea" data-autosize="true" id="ex-draft" name="draft" placeholder="Start writing… we'll save as you pause" hx-post="/docs/autosize-textarea/save-draft" hx-trigger="input changed delay:600ms" hx-swap="none">
</textarea>
<p class="text-xs text-muted-foreground">The field briefly dims while the server records each pause.</p>
</div>Further reading
API Reference
<AutosizeTextarea>
| Prop | Type | Default | Description |
|---|---|---|---|
autosize | boolean | true | true sets field-sizing: content so the textarea grows/shrinks to fit its value; false sets field-sizing: fixed — a plain bounded textarea with a drag handle.MDNfield-sizing |
minHeight | string | "min-h-16" | Lower growth bound as a Tailwind height utility. Per MDN, pair min/max height with field-sizing rather than a fixed height (a fixed height would reimpose a static size).MDNfield-sizing + min/max height |
maxHeight | string | "max-h-80" | Upper growth bound as a Tailwind height utility. Once the value reaches it the browser shows a scrollbar. |
rows / cols | number | — | Fallback size only. With field-sizing: content active these have no effect (MDN); they size the control where field-sizing is unsupported.MDN<textarea> rows |
minlength / maxlength | number | — | Length bounds. maxlength also stops the field growing once the character limit is reached. |
wrap | "hard"|"soft"|"off" | "soft" | Newline handling on submit. |
spellcheck | boolean | — | Browser spellchecker. |
autocorrect | "on"|"off" | — | WebKit autocorrect (Safari/iOS). |
autocapitalize | "off"|"on"|"sentences"|"words"|"characters" | — | Mobile capitalisation hint. |
dirname | string | — | Submit text direction (ltr/rtl) as a second form field. |
id | string | — | Pairs the input with a <label for>. |
name | string | — | Form field name on submit. |
value | string | — | Initial value. |
placeholder | string | — | Placeholder text when empty. |
required | boolean | false | Native HTML required for form validation. |
disabled | boolean | false | Disable — unfocusable, not submitted. |
readonly | boolean | false | Read-only — focusable + selectable but not editable. |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
ariaDescribedby | string | — | Id of an element describing this control (announced after the name).MDNaria-describedby |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |