Components
Textarea
A real <textarea> styled to match Input. The field-sizing: content CSS rule makes it grow with what you type — no JS auto-resize hook required.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/textarea.json2. Use it
import { Textarea } from "@/components/ui/textarea"
<Textarea name="bio" placeholder="Tell us about yourself…" rows={4} />Or copy the source manually
/** @jsxImportSource hono/jsx */
import { cn, type ClassValue } from "@/registry/lib/cn"
// Native <textarea> with shadcn polish. Source of truth:
// repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/textarea.tsx
//
// Auto-resize is delivered by CSS `field-sizing: content` (the textarea grows
// with its value). No JS hooks required. See:
// repos/mdn/files/en-us/web/css/field-sizing/
const base =
"flex field-sizing-content min-h-16 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. live-validation).
"[&.htmx-request]:opacity-70"
export function textareaClasses(opts?: { class?: ClassValue }): string {
return cn(base, opts?.class)
}
type TextareaProps = {
class?: ClassValue
id?: string
name?: string
value?: string
defaultValue?: string
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
// Sizing
rows?: number
cols?: number
// Validation
minLength?: number
maxLength?: number
// Mobile UX
autocomplete?: string
autofocus?: boolean
spellcheck?: boolean
// Mobile keyboard hints. inputmode picks the OS keyboard layout (e.g.
// numeric/decimal for a multi-line numeric entry); enterkeyhint labels the
// soft-keyboard Enter key — relevant when Enter inserts a newline vs submits.
// Both are enumerated global attributes valid on <textarea>. See
// repos/mdn/files/en-us/web/html/reference/global_attributes/inputmode/index.md
// repos/mdn/files/en-us/web/html/reference/global_attributes/enterkeyhint/index.md
inputmode?: "none" | "text" | "decimal" | "numeric" | "tel" | "search" | "email" | "url"
enterkeyhint?: "enter" | "done" | "go" | "next" | "previous" | "search" | "send"
// Submits the text directionality (ltr/rtl) as a separate form field.
// Same as <input>: critical for multilingual content where the server
// needs to preserve the writer's direction. See
// repos/mdn/files/en-us/web/html/reference/elements/textarea/index.md
dirname?: string
// Mobile keyboard capitalisation hint (most useful as "off" for code,
// JSON, or tag input where auto-caps is wrong).
autocapitalize?: "off" | "none" | "on" | "sentences" | "words" | "characters"
// Safari/WebKit extension. Disable when editing code, JSON, hashtags, etc.
autocorrect?: "on" | "off"
// Wrapping
wrap?: "hard" | "soft" | "off"
// ARIA
ariaLabel?: string
ariaLabelledby?: string
ariaDescribedby?: string
ariaInvalid?: boolean | "grammar" | "spelling"
ariaRequired?: boolean
// Id of a visible error-message element; meaningful only alongside
// ariaInvalid="true". See
// repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-errormessage/index.md
ariaErrormessage?: string
// 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/src/content/reference/01-attributes/.
"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 Textarea(props: TextareaProps) {
const {
class: className,
value,
defaultValue,
ariaLabel,
ariaLabelledby,
ariaDescribedby,
ariaInvalid,
ariaRequired,
ariaErrormessage,
...rest
} = props
return (
<textarea
class={textareaClasses({ 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)}
aria-errormessage={ariaErrormessage}
data-slot="textarea"
{...rest}
>{value ?? defaultValue}</textarea>
)
}
1. Save the file
Copy textarea.html into templates/components/.
2. Use it
{% from "components/textarea.html" import textarea %}
{{ textarea(name="bio", placeholder="Tell us about yourself…", rows=4) }}View source
{# Textarea macro — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/textarea.tsx for Python/Flask/FastAPI/Django/Jinja2.
Usage:
{% from "components/textarea.html" import textarea %}
{{ textarea(name="bio", placeholder="Tell us about yourself…", rows=4) }}
field-sizing:content makes the textarea grow with its content; no JS hook
required. See repos/mdn/files/en-us/web/css/field-sizing/. #}
{% macro textarea(
id=none,
name=none,
value=none,
placeholder=none,
required=false,
disabled=false,
readonly=false,
rows=none,
cols=none,
minlength=none,
maxlength=none,
autocomplete=none,
autocapitalize=none,
autocorrect=none,
autofocus=false,
spellcheck=none,
inputmode=none,
enterkeyhint=none,
dirname=none,
wrap=none,
form=none,
aria_label=none,
aria_labelledby=none,
aria_describedby=none,
aria_invalid=none,
aria_required=none,
aria_errormessage=none,
extra_class="",
**attrs
) %}
{%- set base -%}
flex field-sizing-content min-h-16 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 -%}
<textarea class="{{ base }} {{ 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 inputmode %} inputmode="{{ inputmode }}"{% endif %}
{%- if enterkeyhint %} enterkeyhint="{{ enterkeyhint }}"{% 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 %}
{%- if aria_errormessage %} aria-errormessage="{{ aria_errormessage }}"{% endif %}
data-slot="textarea"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{% if value is not none %}{{ value }}{% endif %}</textarea>
{% endmacro %}
1. Save the file
Add textarea.tmpl alongside button.tmpl.
2. Use it
tpl.ExecuteTemplate(w, "textarea", map[string]any{
"Name": "bio",
"Placeholder": "Tell us about yourself…",
"Rows": 4,
})View source
{{/*
Textarea template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/textarea.tsx.
Usage:
type TextareaArgs struct {
ID, Name, Value, Placeholder string
Required, Disabled, Readonly, Autofocus bool
Rows, Cols int
MinLength, MaxLength int
Autocomplete, Wrap string
Spellcheck *bool // tri-state
InputMode string // none | text | decimal | numeric | tel | search | email | url
EnterKeyHint string // enter | done | go | next | previous | search | send
Form, AriaLabel, AriaLabelledby, AriaDescribedby string
AriaInvalid, AriaRequired string
AriaErrormessage string // id of a visible error message; use with AriaInvalid
Attrs map[string]string
}
tpl.ExecuteTemplate(w, "textarea", TextareaArgs{
Name: "bio", Placeholder: "Tell us about yourself…", Rows: 4,
})
*/}}
{{define "textarea"}}
{{- $base := "flex field-sizing-content min-h-16 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" -}}
<textarea class="{{$base}}"
{{- 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 .InputMode}} inputmode="{{.InputMode}}"{{end}}
{{- if .EnterKeyHint}} enterkeyhint="{{.EnterKeyHint}}"{{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}}
{{- if .AriaErrormessage}} aria-errormessage="{{.AriaErrormessage}}"{{end}}
data-slot="textarea"
{{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>{{.Value}}</textarea>
{{end}}
1. Save the file
Drop textarea.ex into lib/my_app_web/components/.
2. Use it
<.textarea name="bio" placeholder="Tell us about yourself…" rows="4" />View source
defmodule ShadcnHtmx.Components.Textarea do
@moduledoc """
Textarea — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/textarea.tsx. Uses CSS `field-sizing: content` so the
element grows with its value — no JS auto-resize hook required.
## Examples
<.textarea name="bio" placeholder="Tell us about yourself…" rows="4" />
<.textarea name="comment"
hx-post="/comments/draft" hx-trigger="input changed delay:500ms" />
"""
use Phoenix.Component
@base "flex field-sizing-content min-h-16 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 :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
inputmode enterkeyhint
aria-label aria-labelledby aria-describedby aria-invalid aria-required aria-errormessage)
def textarea(assigns) do
assigns = assign(assigns, :base_class, @base)
~H"""
<textarea
class={[@base_class, @class]}
data-slot="textarea"
{@rest}
>{@value}</textarea>
"""
end
end
1. Save the file
Tailwind v4 is enough; field-sizing-content is a single utility.
2. Use it
<textarea name="bio" placeholder="Tell us about yourself…" rows="4"
class="flex field-sizing-content min-h-16 w-full rounded-md border
border-input bg-transparent px-3 py-2 text-base shadow-xs …"></textarea>View source
<!--
shadcn-htmx — raw HTML textarea snippets.
Mirrors registry/ui/textarea.tsx. The `field-sizing: content` rule makes
the element grow with its value — no JS hook required.
BASE:
flex field-sizing-content min-h-16 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
-->
<!-- Basic -->
<textarea name="bio" placeholder="Tell us about yourself…" rows="4" data-slot="textarea"
class="flex field-sizing-content min-h-16 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"></textarea>
<!-- Disabled -->
<textarea disabled value="Locked content." data-slot="textarea"
class="flex field-sizing-content min-h-16 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">Locked content.</textarea>
<!-- Invalid + describedby -->
<div>
<textarea name="comment" aria-invalid="true" aria-describedby="comment-error"
placeholder="Comment…" data-slot="textarea"
class="flex field-sizing-content min-h-16 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"></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 type" data-slot="textarea"
hx-post="/drafts/123" hx-trigger="input changed delay:600ms" hx-swap="none"
class="flex field-sizing-content min-h-16 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 [&.htmx-request]:opacity-70"></textarea>
<!-- Mobile keyboard hints — inputmode picks the OS keyboard, enterkeyhint
labels the soft-keyboard Enter key (here "send" for a chat composer).
See repos/mdn/files/en-us/web/html/reference/global_attributes/enterkeyhint/. -->
<textarea name="message" placeholder="Type a message…" rows="2" data-slot="textarea"
inputmode="text" enterkeyhint="send"
class="flex field-sizing-content min-h-16 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"></textarea>
<!-- Invalid + aria-errormessage — point an invalid field at its visible error.
aria-errormessage is meaningful only with aria-invalid="true". See
repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-errormessage/. -->
<div>
<textarea name="feedback" aria-invalid="true" aria-errormessage="feedback-error"
placeholder="Your feedback…" data-slot="textarea"
class="flex field-sizing-content min-h-16 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"></textarea>
<p id="feedback-error" class="mt-1 text-sm text-destructive">Feedback is required.</p>
</div>
Examples
Auto-resize as you type
The textarea starts at min-h-16 (≈4rem). field-sizing: content grows it line by line; no JS observer required.
CSS field-sizing: content is a recent platform addition that resizes form controls to their contents. It works on every modern browser. Use min-h-* and optionally max-h-* to set bounds — past the maximum the textarea starts to scroll as usual.
<Label htmlFor="bio">Bio</Label>
<Textarea id="bio" name="bio"
placeholder="Tell us about yourself. The field grows as you type…" />{{ label("Bio", for_="bio") }}
{{ textarea(id="bio", name="bio",
placeholder="Tell us about yourself. The field grows as you type…") }}{{template "label" (dict "For" "bio" "Text" "Bio")}}
{{template "textarea" (dict
"ID" "bio" "Name" "bio"
"Placeholder" "Tell us about yourself. The field grows as you type…"
)}}<.label for="bio">Bio</.label>
<.textarea id="bio" name="bio"
placeholder="Tell us about yourself. The field grows as you type…" /><div class="grid w-full max-w-md gap-2">
<label for="ex-bio" 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">Bio</label>
<textarea class="flex field-sizing-content min-h-16 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" data-slot="textarea" id="ex-bio" name="bio" placeholder="Tell us about yourself. The field grows as you type…">
</textarea>
</div>Further reading
Invalid + error message
Same pattern as Input — aria-invalid styles the field, aria-describedby connects to the error text.
The aria-invalid / aria-describedby pairing is non-negotiable for non-native validations. When the server (via htmx) replies with an error, swap the whole textarea — flipping the attribute and inserting the message in one shot.
Comment can't be empty.
<Label htmlFor="comment">Comment</Label>
<Textarea id="comment" name="comment" ariaInvalid
ariaDescribedby="comment-error" />
<p id="comment-error" class="text-sm text-destructive">
Comment can't be empty.
</p>{{ label("Comment", for_="comment") }}
{{ textarea(id="comment", name="comment",
aria_invalid=true, aria_describedby="comment-error") }}
<p id="comment-error" class="text-sm text-destructive">
Comment can't be empty.
</p>{{template "textarea" (dict
"ID" "comment" "Name" "comment"
"AriaInvalid" "true" "AriaDescribedby" "comment-error"
)}}
<p id="comment-error" class="text-sm text-destructive">…</p><.label for="comment">Comment</.label>
<.textarea id="comment" name="comment"
aria-invalid="true" aria-describedby="comment-error" />
<p id="comment-error" class="text-sm text-destructive">…</p><div class="grid w-full max-w-md gap-2">
<label for="ex-comment" 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">Comment</label>
<textarea class="flex field-sizing-content min-h-16 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" aria-describedby="ex-comment-error" aria-invalid="true" data-slot="textarea" id="ex-comment" name="comment" placeholder="Add a comment…">
</textarea>
<p id="ex-comment-error" class="text-sm text-destructive">Comment can't be empty.</p>
</div>Further reading
Disabled vs. readonly
Same contract as Input: disabled removes from form submission; readonly stays focusable + selectable.
Reach for readonly when you want the user to read the text (and copy from it) but not edit. Use disabled when the field shouldn't submit at all (e.g. it doesn't apply yet for this user).
<Textarea disabled value="Cannot focus or edit" />
<Textarea readonly value="Selectable but not editable." />{{ textarea(disabled=true, value="Cannot focus or edit") }}
{{ textarea(readonly=true, value="Selectable but not editable.") }}{{template "textarea" (dict "Disabled" true "Value" "Cannot focus or edit")}}
{{template "textarea" (dict "Readonly" true "Value" "Selectable but not editable.")}}<.textarea disabled value="Cannot focus or edit" />
<.textarea readonly value="Selectable but not editable." /><div class="grid w-full max-w-md gap-3">
<div class="space-y-1">
<label for="ex-ta-disabled" 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 text-xs" data-slot="label">Disabled</label>
<textarea class="flex field-sizing-content min-h-16 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" data-slot="textarea" id="ex-ta-disabled" disabled="">Cannot focus or edit</textarea>
</div>
<div class="space-y-1">
<label for="ex-ta-readonly" 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 text-xs" data-slot="label">Readonly</label>
<textarea class="flex field-sizing-content min-h-16 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" data-slot="textarea" id="ex-ta-readonly" readonly="">
The full release notes for this version are pinned here. You can select and copy this text, but not edit it.
</textarea>
</div>
</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.
"Save as you type" feels magic when it's reliable. input changed delay:600ms fires only after the user pauses, which avoids hammering your server on every keystroke. While the request is in flight htmx adds .htmx-request — our base styling dims the field at 70% so the user gets a subtle hint that a save is happening.
The field briefly dims while the server records each pause.
<Textarea name="draft" placeholder="Start writing…"
hx-post="/api/drafts" hx-trigger="input changed delay:600ms"
hx-swap="none" />{{ textarea(name="draft", placeholder="Start writing…",
hx_post="/api/drafts",
hx_trigger="input changed delay:600ms",
hx_swap="none") }}{{template "textarea" (dict
"Name" "draft" "Placeholder" "Start writing…"
"Attrs" (dict
"hx-post" "/api/drafts"
"hx-trigger" "input changed delay:600ms"
"hx-swap" "none"
)
)}}<.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 field-sizing-content min-h-16 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" data-slot="textarea" id="ex-draft" name="draft" placeholder="Start writing… we'll save as you pause" hx-post="/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
<Textarea>
| Prop | Type | Default | Description |
|---|---|---|---|
inputmode | "none" | "text" | "decimal" | "numeric" | "tel" | "search" | "email" | "url" | — | Hints which virtual keyboard the OS should show for this multi-line field (e.g. numeric/decimal). Enumerated global attribute, valid on textarea. |
enterkeyhint | "enter" | "done" | "go" | "next" | "previous" | "search" | "send" | — | Labels the soft-keyboard Enter key (e.g. send for a chat composer). Relevant when deciding whether Enter inserts a newline or submits. |
ariaErrormessage | string | — | Id of a visible error-message element describing the validation error. Meaningful only alongside ariaInvalid set to true. |
rows | number | — | Initial visible row count (native default 2). |
cols | number | — | Visible width in characters. |
minlength / maxlength | number | — | Length bounds. |
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 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 |