shshadcn-htmx

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.json

2. Use it

components/ui/textarea.tsx
import { Textarea } from "@/components/ui/textarea"

<Textarea name="bio" placeholder="Tell us about yourself…" rows={4} />
Or copy the source manually
components/ui/textarea.tsx
/** @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

templates/components/textarea.html
{% from "components/textarea.html" import textarea %}

{{ textarea(name="bio", placeholder="Tell us about yourself…", rows=4) }}
View source
templates/components/textarea.html
{# 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

templates/components/textarea.tmpl
tpl.ExecuteTemplate(w, "textarea", map[string]any{
    "Name": "bio",
    "Placeholder": "Tell us about yourself…",
    "Rows": 4,
})
View source
templates/components/textarea.tmpl
{{/*
  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

lib/my_app_web/components/textarea.ex
<.textarea name="bio" placeholder="Tell us about yourself…" rows="4" />
View source
lib/my_app_web/components/textarea.ex
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

index.html
<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
index.html
<!--
  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 [&amp;.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>

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 [&amp;.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&#39;t be empty.</p>
</div>

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 [&amp;.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 [&amp;.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>

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 [&amp;.htmx-request]:opacity-70" data-slot="textarea" id="ex-draft" name="draft" placeholder="Start writing… we&#39;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>

API Reference

<Textarea>

PropTypeDefaultDescription
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.
ariaErrormessagestring
Id of a visible error-message element describing the validation error. Meaningful only alongside ariaInvalid set to true.
rowsnumber
Initial visible row count (native default 2).
colsnumber
Visible width in characters.
minlength / maxlengthnumber
Length bounds.
wrap"hard"|"soft"|"off""soft"
Newline handling on submit.
spellcheckboolean
Browser spellchecker.
autocorrect"on"|"off"
WebKit autocorrect (Safari/iOS).
autocapitalize"off"|"on"|"sentences"|"words"|"characters"
Mobile capitalisation hint.
dirnamestring
Submit text direction as a second form field.
idstring
Pairs the input with a <label for>.
namestring
Form field name on submit.
valuestring
Initial value.
placeholderstring
Placeholder text when empty.
requiredbooleanfalse
Native HTML required for form validation.
disabledbooleanfalse
Disable — unfocusable, not submitted.
readonlybooleanfalse
Read-only — focusable + selectable but not editable.
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
ariaDescribedbystring
Id of an element describing this control (announced after the name).MDNaria-describedby
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference