shshadcn-htmx

Components

Form Field

A field-row wrapper that composes a <label>, a control, a description, and an error — auto-wiring aria-describedby and a native :user-invalid styling hook. No client form runtime: the server owns the truth, the platform owns the wiring.

Installation

One file per stack. Use the shadcn CLI for JSX, or copy the source for your template engine.

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/form-field.json

2. Use it

components/ui/form-field.tsx
import { FormField } from "@/components/ui/form-field"
import { Input } from "@/components/ui/input"

<FormField for="email" label="Email"
           description="We'll never share it."
           error={errors.email}>
  <Input id="email" type="email" name="email" />
</FormField>
Or copy the source manually
components/ui/form-field.tsx
/** @jsxImportSource hono/jsx */
import type { Child } from "hono/jsx"
import { cloneElement, isValidElement } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Form Field — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A field-row wrapper that composes a <label>, a single control slot, an
// optional description, and an optional error message — auto-wiring the
// label's `for`, the control's `aria-describedby` (description + error ids),
// and `aria-invalid`. The visual error state is driven by the native
// `:user-invalid` pseudo-class so the field only "turns red" AFTER the user
// has interacted and a submit was attempted — no JS, no premature errors.
//
// Source of truth (shadcn anatomy — FormField / FormItem / FormLabel /
// FormControl / FormDescription / FormMessage):
//   repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/form.tsx
// Upstream couples those parts to react-hook-form via React context. We have
// no client form runtime, so instead we lift the wiring to the server: one
// component reads `id`/`invalid`/`description`/`error`, derives the ids, and
// clones them onto the control child. Same semantic HTML, zero client state.
//
// Built on web platform primitives:
//   - <fieldset>/<legend> for grouping multiple controls under one caption.
//     repos/mdn/files/en-us/web/html/reference/elements/fieldset/index.md
//     repos/mdn/files/en-us/web/html/reference/elements/legend/index.md
//   - Constraint Validation + :user-invalid for "show error only after the
//     user tried" styling. :invalid fires before interaction (confusing);
//     :user-invalid fires only after a submit attempt + interaction.
//     repos/mdn/files/en-us/web/css/reference/selectors/_colon_user-invalid/index.md
//     repos/web.dev/src/site/content/en/learn/forms/validation/index.md (#javascript, :user-invalid aside)
//   - aria-describedby to connect the control to its description + error.
//     repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-describedby/index.md
//     repos/web.dev/src/site/content/en/learn/forms/accessibility/index.md ("Help users find the error message")
//
// htmx: the field forwards hx-* untouched, so a server can swap the whole
// field via hx-swap="outerHTML" and flip aria-invalid + inject the error in
// one shot. See repos/htmx/www/reference.md.

// Root row. `grid gap-2` mirrors shadcn FormItem. The `[&:has(:user-invalid)]`
// arbitrary selector lets the label adopt the destructive colour the moment
// the platform marks any descendant control :user-invalid — pure CSS.
const fieldBase =
  "grid gap-2 [&:has(:user-invalid)_[data-slot=form-field-label]]:text-destructive"

const labelBase =
  "flex items-center gap-2 text-sm leading-none font-medium select-none " +
  // Author-driven error state (server sets data-invalid when it knows).
  "data-[invalid=true]:text-destructive " +
  // Dim when the control inside is disabled.
  "peer-disabled:cursor-not-allowed peer-disabled:opacity-50"

const descriptionBase = "text-sm text-muted-foreground"

// Error text. role="alert" + aria-live="assertive" so a swapped-in error is
// announced; destructive colour pairs with the label turning red. Hidden when
// empty so it doesn't leave a gap.
const errorBase = "text-sm font-medium text-destructive"

const legendBase = "text-sm leading-none font-medium select-none"

export function formFieldClasses(opts?: { class?: ClassValue }): string {
  return cn(fieldBase, opts?.class)
}

type FormFieldProps = {
  // The single control to wire up (an <Input>, <Textarea>, <Select>, …).
  // We clone it to inject id + aria-describedby + aria-invalid.
  children?: Child
  // Visible label text. Omit to render no label (e.g. control is self-labelled).
  label?: Child
  // The control's id. The label points at it via `for`, and the description /
  // error ids are derived from it (`${id}-description`, `${id}-error`).
  // Authored as the natural HTML attribute name `for`; `htmlFor` is accepted
  // as an alias for ergonomics.
  for?: string
  htmlFor?: string
  // Helper text under the label.
  description?: Child
  // Error message. When set (and `invalid` is not explicitly false) the field
  // is marked aria-invalid and the message is announced.
  error?: Child
  // Force the invalid state. Defaults to `true` when `error` is provided.
  invalid?: boolean
  // Marks the label with a required indicator and is forwarded as data-required.
  required?: boolean
  class?: ClassValue
  labelClass?: ClassValue
  // htmx + data-* + aria-* ride along onto the root.
  [key: string]: unknown
}

export function FormField(props: FormFieldProps) {
  const {
    children,
    label,
    for: forProp,
    htmlFor: htmlForProp,
    description,
    error,
    invalid,
    required,
    class: className,
    labelClass,
    ...rest
  } = props

  // Accept the natural HTML attribute `for` (how the docs/routes author it),
  // falling back to the `htmlFor` alias. Without this the id would leak onto
  // the root div via {...rest} and the label/aria wiring would never derive.
  const htmlFor = forProp ?? htmlForProp

  const isInvalid = invalid ?? (error != null && error !== false)
  const descriptionId = htmlFor && description != null ? `${htmlFor}-description` : undefined
  const errorId = htmlFor && isInvalid && error != null ? `${htmlFor}-error` : undefined
  // aria-describedby: description first, then error (announced after the name).
  const describedby = [descriptionId, errorId].filter(Boolean).join(" ") || undefined

  // Clone the control child to inject the wiring. Mirrors the asChild pattern
  // in registry/ui/button.tsx (hono/jsx cloneElement + isValidElement).
  let control: Child = children
  if (isValidElement(children)) {
    const child = children as any
    control = cloneElement(child, {
      id: child?.props?.id ?? htmlFor,
      "aria-describedby": cn(child?.props?.["aria-describedby"], describedby) || undefined,
      "aria-invalid": isInvalid ? "true" : child?.props?.["aria-invalid"],
      "aria-required": required ? "true" : child?.props?.["aria-required"],
    })
  }

  return (
    <div
      class={formFieldClasses({ class: className })}
      data-slot="form-field"
      data-invalid={isInvalid ? "true" : undefined}
      {...rest}
    >
      {label != null && (
        <label
          for={htmlFor}
          class={cn(labelBase, labelClass)}
          data-slot="form-field-label"
          data-invalid={isInvalid ? "true" : undefined}
          data-required={required ? "true" : undefined}
        >
          {label}
          {required && (
            <span class="text-destructive" aria-hidden="true">
              *
            </span>
          )}
        </label>
      )}
      {control}
      {description != null && (
        <p id={descriptionId} class={descriptionBase} data-slot="form-field-description">
          {description}
        </p>
      )}
      {isInvalid && error != null && (
        <p
          id={errorId}
          role="alert"
          aria-live="assertive"
          class={errorBase}
          data-slot="form-field-error"
        >
          {error}
        </p>
      )}
    </div>
  )
}

type FormFieldsetProps = {
  children?: Child
  // The <legend> caption for the group.
  legend?: Child
  description?: Child
  error?: Child
  invalid?: boolean
  // Disables every control inside the group natively (fieldset[disabled]).
  disabled?: boolean
  required?: boolean
  // id of the description/error so callers can point group controls at it.
  id?: string
  class?: ClassValue
  legendClass?: ClassValue
  [key: string]: unknown
}

// Fieldset variant — for grouping multiple controls (radios, checkboxes,
// related inputs) under one caption. The <legend> names the group for AT, and
// `disabled` on the <fieldset> disables every descendant control in one go.
//   repos/mdn/files/en-us/web/html/reference/elements/fieldset/index.md
export function FormFieldset(props: FormFieldsetProps) {
  const {
    children,
    legend,
    description,
    error,
    invalid,
    disabled,
    required,
    id,
    class: className,
    legendClass,
    ...rest
  } = props

  const isInvalid = invalid ?? (error != null && error !== false)
  const descriptionId = id && description != null ? `${id}-description` : undefined
  const errorId = id && isInvalid && error != null ? `${id}-error` : undefined
  const describedby = [descriptionId, errorId].filter(Boolean).join(" ") || undefined

  return (
    <fieldset
      class={cn(fieldBase, "min-w-0 border-0 p-0", className)}
      data-slot="form-field"
      data-invalid={isInvalid ? "true" : undefined}
      disabled={disabled}
      aria-describedby={describedby}
      aria-invalid={isInvalid ? "true" : undefined}
      aria-required={required ? "true" : undefined}
      {...rest}
    >
      {legend != null && (
        <legend
          class={cn(legendBase, "float-none mb-1 data-[invalid=true]:text-destructive", legendClass)}
          data-slot="form-field-legend"
          data-invalid={isInvalid ? "true" : undefined}
          data-required={required ? "true" : undefined}
        >
          {legend}
          {required && (
            <span class="text-destructive" aria-hidden="true">
              {" "}
              *
            </span>
          )}
        </legend>
      )}
      {children}
      {description != null && (
        <p id={descriptionId} class={descriptionBase} data-slot="form-field-description">
          {description}
        </p>
      )}
      {isInvalid && error != null && (
        <p
          id={errorId}
          role="alert"
          aria-live="assertive"
          class={errorBase}
          data-slot="form-field-error"
        >
          {error}
        </p>
      )}
    </fieldset>
  )
}

1. Save the file

Copy form-field.html into templates/components/.

2. Use it

templates/components/form-field.html
{% from "components/form-field.html" import form_field %}
{% from "components/input.html" import input %}

{% call form_field(for_="email", label="Email",
                   description="We'll never share it.",
                   error=errors.email) %}
  {{ input(id="email", name="email", type="email",
           aria_describedby="email-description email-error",
           aria_invalid=(errors.email is not none)) }}
{% endcall %}
View source
templates/components/form-field.html
{# Form Field macros — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/form-field.tsx for Python/Flask/FastAPI/Django/Jinja2.

   A field row composes a <label>, a control slot, an optional description and
   an optional error. The label's `for`, the control's aria-describedby and
   aria-invalid are wired from the id you pass. Error styling rides on the
   native :user-invalid pseudo-class (no JS, no premature errors).

   Usage (block form — you place the control yourself so its id matches):

       {% from "components/form-field.html" import form_field, form_fieldset %}
       {% from "components/input.html" import input %}

       {% call form_field(for_="email", label="Email",
                          description="We'll never share it.",
                          error=errors.email, required=true) %}
         {{ input(id="email", name="email", type="email",
                  aria_describedby="email-description email-error",
                  aria_invalid=(errors.email is not none)) }}
       {% endcall %}

   Group form (multiple controls under one <legend>):

       {% call form_fieldset(id="plan", legend="Plan", error=errors.plan) %}
         …radios / checkboxes…
       {% endcall %}

   Built on <fieldset>/<legend>, Constraint Validation + :user-invalid, and
   aria-describedby. See:
     repos/mdn/files/en-us/web/html/reference/elements/fieldset/index.md
     repos/mdn/files/en-us/web/css/reference/selectors/_colon_user-invalid/index.md
     repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-describedby/index.md
   htmx attrs pass through via **attrs (underscores become dashes). #}

{% macro form_field(
    for_=none,
    label=none,
    description=none,
    error=none,
    invalid=none,
    required=false,
    extra_class="",
    label_class="",
    **attrs
) %}
{%- set is_invalid = invalid if invalid is not none else (error is not none) -%}
{%- set base -%}
grid gap-2 [&:has(:user-invalid)_[data-slot=form-field-label]]:text-destructive
{%- endset -%}
<div class="{{ base }} {{ extra_class }}"
     data-slot="form-field"
     {%- if is_invalid %} data-invalid="true"{% endif %}
     {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>
  {%- if label is not none %}
  <label class="flex items-center gap-2 text-sm leading-none font-medium select-none data-[invalid=true]:text-destructive peer-disabled:cursor-not-allowed peer-disabled:opacity-50 {{ label_class }}"
         {%- if for_ %} for="{{ for_ }}"{% endif %}
         data-slot="form-field-label"
         {%- if is_invalid %} data-invalid="true"{% endif %}
         {%- if required %} data-required="true"{% endif %}
  >{{ label }}{% if required %}<span class="text-destructive" aria-hidden="true">*</span>{% endif %}</label>
  {%- endif %}
  {{ caller() }}
  {%- if description is not none %}
  <p {% if for_ %}id="{{ for_ }}-description" {% endif %}class="text-sm text-muted-foreground" data-slot="form-field-description">{{ description }}</p>
  {%- endif %}
  {%- if is_invalid and error is not none %}
  <p {% if for_ %}id="{{ for_ }}-error" {% endif %}role="alert" aria-live="assertive" class="text-sm font-medium text-destructive" data-slot="form-field-error">{{ error }}</p>
  {%- endif %}
</div>
{% endmacro %}

{% macro form_fieldset(
    id=none,
    legend=none,
    description=none,
    error=none,
    invalid=none,
    disabled=false,
    required=false,
    extra_class="",
    legend_class="",
    **attrs
) %}
{%- set is_invalid = invalid if invalid is not none else (error is not none) -%}
{%- set described = [] -%}
{%- if id and description is not none %}{% set _ = described.append(id ~ "-description") %}{% endif -%}
{%- if id and is_invalid and error is not none %}{% set _ = described.append(id ~ "-error") %}{% endif -%}
{%- set base -%}
grid gap-2 [&:has(:user-invalid)_[data-slot=form-field-label]]:text-destructive min-w-0 border-0 p-0
{%- endset -%}
<fieldset class="{{ base }} {{ extra_class }}"
          data-slot="form-field"
          {%- if is_invalid %} data-invalid="true"{% endif %}
          {%- if disabled %} disabled{% endif %}
          {%- if described %} aria-describedby="{{ described|join(' ') }}"{% endif %}
          {%- if is_invalid %} aria-invalid="true"{% endif %}
          {%- if required %} aria-required="true"{% endif %}
          {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>
  {%- if legend is not none %}
  <legend class="text-sm leading-none font-medium select-none float-none mb-1 data-[invalid=true]:text-destructive {{ legend_class }}"
          data-slot="form-field-legend"
          {%- if is_invalid %} data-invalid="true"{% endif %}
          {%- if required %} data-required="true"{% endif %}
  >{{ legend }}{% if required %}<span class="text-destructive" aria-hidden="true"> *</span>{% endif %}</legend>
  {%- endif %}
  {{ caller() }}
  {%- if description is not none %}
  <p {% if id %}id="{{ id }}-description" {% endif %}class="text-sm text-muted-foreground" data-slot="form-field-description">{{ description }}</p>
  {%- endif %}
  {%- if is_invalid and error is not none %}
  <p {% if id %}id="{{ id }}-error" {% endif %}role="alert" aria-live="assertive" class="text-sm font-medium text-destructive" data-slot="form-field-error">{{ error }}</p>
  {%- endif %}
</fieldset>
{% endmacro %}

1. Save the file

Add form-field.tmpl alongside your templates.

2. Use it

components/form-field.tmpl
tpl.ExecuteTemplate(w, "form-field", FormFieldArgs{
    For:         "email",
    Label:       "Email",
    Description: "We'll never share it.",
    Error:       errs["email"],
    Control: template.HTML(`<input id="email" name="email" type="email"
        aria-describedby="email-description email-error">`),
})
View source
components/form-field.tmpl
{{/*
  Form Field templates — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/form-field.tsx for Go projects using html/template.

  A field row composes a <label>, a control slot, an optional description, and
  an optional error. The label's `for`, and the control's aria-describedby /
  aria-invalid, are wired from the id you pass. Error styling rides on the
  native :user-invalid pseudo-class. Because html/template has no caller-block
  syntax, hand the control HTML in via .Control (template.HTML).

  Usage in your handler:

      type FormFieldArgs struct {
          For         string        // id of the control inside .Control
          Label       string
          Description string
          Error       string        // non-empty => invalid
          Invalid     bool          // force invalid even without a message
          Required    bool
          Control     template.HTML // the <input>/<select>/… markup
      }

      tpl.ExecuteTemplate(w, "form-field", FormFieldArgs{
          For: "email", Label: "Email",
          Description: "We'll never share it.",
          Error: errs["email"],
          Control: template.HTML(`<input id="email" name="email" type="email"
              aria-describedby="email-description email-error">`),
      })

  Built on <fieldset>/<legend>, Constraint Validation + :user-invalid, and
  aria-describedby. See:
    repos/mdn/files/en-us/web/html/reference/elements/fieldset/index.md
    repos/mdn/files/en-us/web/css/reference/selectors/_colon_user-invalid/index.md
    repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-describedby/index.md
*/}}

{{define "form-field"}}
{{- $invalid := or .Invalid (ne (or .Error "") "") -}}
{{- $base := "grid gap-2 [&:has(:user-invalid)_[data-slot=form-field-label]]:text-destructive" -}}
<div class="{{$base}}" data-slot="form-field"{{if $invalid}} data-invalid="true"{{end}}>
  {{- if .Label}}
  <label class="flex items-center gap-2 text-sm leading-none font-medium select-none data-[invalid=true]:text-destructive peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
         {{- if .For}} for="{{.For}}"{{end}} data-slot="form-field-label"{{if $invalid}} data-invalid="true"{{end}}{{if .Required}} data-required="true"{{end}}>{{.Label}}{{if .Required}}<span class="text-destructive" aria-hidden="true">*</span>{{end}}</label>
  {{- end}}
  {{- if .Control}}{{htmlSafe .Control}}{{end}}
  {{- if .Description}}
  <p {{if .For}}id="{{.For}}-description" {{end}}class="text-sm text-muted-foreground" data-slot="form-field-description">{{.Description}}</p>
  {{- end}}
  {{- if and $invalid (ne (or .Error "") "")}}
  <p {{if .For}}id="{{.For}}-error" {{end}}role="alert" aria-live="assertive" class="text-sm font-medium text-destructive" data-slot="form-field-error">{{.Error}}</p>
  {{- end}}
</div>
{{end}}

{{/*
  Group variant — multiple controls under one <legend>. `Disabled` disables
  every descendant control natively. Point group controls at
  "<ID>-description <ID>-error" via their own aria-describedby.

      tpl.ExecuteTemplate(w, "form-fieldset", FormFieldsetArgs{
          ID: "plan", Legend: "Plan", Error: errs["plan"],
          Controls: template.HTML(`…radios…`),
      })
*/}}
{{define "form-fieldset"}}
{{- $invalid := or .Invalid (ne (or .Error "") "") -}}
{{- $base := "grid gap-2 [&:has(:user-invalid)_[data-slot=form-field-label]]:text-destructive min-w-0 border-0 p-0" -}}
{{- $hasDesc := ne (or .Description "") "" -}}
{{- $hasErr := and $invalid (ne (or .Error "") "") -}}
<fieldset class="{{$base}}" data-slot="form-field"{{if $invalid}} data-invalid="true"{{end}}{{if .Disabled}} disabled{{end}}
  {{- if .ID}}{{if $hasDesc}} aria-describedby="{{.ID}}-description{{if $hasErr}} {{.ID}}-error{{end}}"{{else if $hasErr}} aria-describedby="{{.ID}}-error"{{end}}{{end}}
  {{- if $invalid}} aria-invalid="true"{{end}}{{if .Required}} aria-required="true"{{end}}>
  {{- if .Legend}}
  <legend class="text-sm leading-none font-medium select-none float-none mb-1 data-[invalid=true]:text-destructive" data-slot="form-field-legend"{{if $invalid}} data-invalid="true"{{end}}{{if .Required}} data-required="true"{{end}}>{{.Legend}}{{if .Required}}<span class="text-destructive" aria-hidden="true"> *</span>{{end}}</legend>
  {{- end}}
  {{- if .Controls}}{{htmlSafe .Controls}}{{end}}
  {{- if .Description}}
  <p {{if .ID}}id="{{.ID}}-description" {{end}}class="text-sm text-muted-foreground" data-slot="form-field-description">{{.Description}}</p>
  {{- end}}
  {{- if $hasErr}}
  <p {{if .ID}}id="{{.ID}}-error" {{end}}role="alert" aria-live="assertive" class="text-sm font-medium text-destructive" data-slot="form-field-error">{{.Error}}</p>
  {{- end}}
</fieldset>
{{end}}

1. Save the file

Drop form_field.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/form_field.ex
<.form_field for="email" label="Email"
             description="We'll never share it."
             error={@errors[:email]}>
  <.input id="email" name="email" type="email"
          aria-describedby="email-description email-error"
          aria-invalid={@errors[:email] && "true"} />
</.form_field>
View source
lib/my_app_web/components/form_field.ex
defmodule ShadcnHtmx.Components.FormField do
  @moduledoc """
  Form Field — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/form-field.tsx. A field row that composes a `<label>`,
  a control slot, an optional description, and an optional error. The label's
  `for`, plus the control's `aria-describedby` / `aria-invalid`, are wired from
  the `for`/`id` you pass. Error styling rides on the native `:user-invalid`
  pseudo-class — the field only turns red after the user interacts and a submit
  is attempted, so no premature errors and no JS.

  ## Examples

      <.form_field for="email" label="Email"
                   description="We'll never share it."
                   error={@errors[:email]} required>
        <.input id="email" name="email" type="email"
                aria-describedby="email-description email-error"
                aria-invalid={@errors[:email] && "true"} />
      </.form_field>

      <.form_fieldset id="plan" legend="Plan" error={@errors[:plan]}>
        <%!-- radios / checkboxes --%>
      </.form_fieldset>

  Built on `<fieldset>`/`<legend>`, Constraint Validation + `:user-invalid`,
  and `aria-describedby`. See:
    repos/mdn/files/en-us/web/html/reference/elements/fieldset/index.md
    repos/mdn/files/en-us/web/css/reference/selectors/_colon_user-invalid/index.md
    repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-describedby/index.md
  """

  use Phoenix.Component

  @field_base "grid gap-2 [&:has(:user-invalid)_[data-slot=form-field-label]]:text-destructive"
  @label_base "flex items-center gap-2 text-sm leading-none font-medium select-none " <>
                "data-[invalid=true]:text-destructive " <>
                "peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
  @legend_base "text-sm leading-none font-medium select-none float-none mb-1 data-[invalid=true]:text-destructive"

  attr :for, :string, default: nil, doc: "id of the control; the label points at it"
  attr :label, :string, default: nil
  attr :description, :string, default: nil
  attr :error, :string, default: nil, doc: "non-nil => invalid"
  attr :invalid, :boolean, default: nil, doc: "force invalid without a message"
  attr :required, :boolean, default: false
  attr :class, :string, default: nil
  attr :label_class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def form_field(assigns) do
    assigns =
      assigns
      |> assign(:is_invalid, if(assigns.invalid != nil, do: assigns.invalid, else: assigns.error != nil))
      |> assign(:field_base, @field_base)
      |> assign(:label_base, @label_base)

    ~H"""
    <div
      class={[@field_base, @class]}
      data-slot="form-field"
      data-invalid={@is_invalid && "true"}
      {@rest}
    >
      <label
        :if={@label}
        for={@for}
        class={[@label_base, @label_class]}
        data-slot="form-field-label"
        data-invalid={@is_invalid && "true"}
        data-required={@required && "true"}
      >
        {@label}<span :if={@required} class="text-destructive" aria-hidden="true">*</span>
      </label>
      {render_slot(@inner_block)}
      <p
        :if={@description}
        id={@for && "#{@for}-description"}
        class="text-sm text-muted-foreground"
        data-slot="form-field-description"
      >
        {@description}
      </p>
      <p
        :if={@is_invalid && @error}
        id={@for && "#{@for}-error"}
        role="alert"
        aria-live="assertive"
        class="text-sm font-medium text-destructive"
        data-slot="form-field-error"
      >
        {@error}
      </p>
    </div>
    """
  end

  attr :id, :string, default: nil
  attr :legend, :string, default: nil
  attr :description, :string, default: nil
  attr :error, :string, default: nil
  attr :invalid, :boolean, default: nil
  attr :disabled, :boolean, default: false
  attr :required, :boolean, default: false
  attr :class, :string, default: nil
  attr :legend_class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def form_fieldset(assigns) do
    is_invalid = if assigns.invalid != nil, do: assigns.invalid, else: assigns.error != nil

    described =
      [
        assigns.id && assigns.description && "#{assigns.id}-description",
        assigns.id && is_invalid && assigns.error && "#{assigns.id}-error"
      ]
      |> Enum.filter(& &1)

    assigns =
      assigns
      |> assign(:is_invalid, is_invalid)
      |> assign(:describedby, if(described == [], do: nil, else: Enum.join(described, " ")))
      |> assign(:field_base, @field_base)
      |> assign(:legend_base, @legend_base)

    ~H"""
    <fieldset
      class={[@field_base, "min-w-0 border-0 p-0", @class]}
      data-slot="form-field"
      data-invalid={@is_invalid && "true"}
      disabled={@disabled}
      aria-describedby={@describedby}
      aria-invalid={@is_invalid && "true"}
      aria-required={@required && "true"}
      {@rest}
    >
      <legend
        :if={@legend}
        class={[@legend_base, @legend_class]}
        data-slot="form-field-legend"
        data-invalid={@is_invalid && "true"}
        data-required={@required && "true"}
      >
        {@legend}<span :if={@required} class="text-destructive" aria-hidden="true"> *</span>
      </legend>
      {render_slot(@inner_block)}
      <p
        :if={@description}
        id={@id && "#{@id}-description"}
        class="text-sm text-muted-foreground"
        data-slot="form-field-description"
      >
        {@description}
      </p>
      <p
        :if={@is_invalid && @error}
        id={@id && "#{@id}-error"}
        role="alert"
        aria-live="assertive"
        class="text-sm font-medium text-destructive"
        data-slot="form-field-error"
      >
        {@error}
      </p>
    </fieldset>
    """
  end
end

1. Save the file

Paste the markup; relies only on theme tokens.

2. Use it

snippets/form-field.html
<div data-slot="form-field"
     class="grid gap-2 [&:has(:user-invalid)_[data-slot=form-field-label]]:text-destructive">
  <label for="email" data-slot="form-field-label" class="…">Email</label>
  <input id="email" name="email" type="email"
         aria-describedby="email-description" class="…" />
  <p id="email-description" class="text-sm text-muted-foreground">We'll never share it.</p>
</div>
View source
snippets/form-field.html
<!--
  shadcn-htmx — raw Form Field snippets.

  Mirrors registry/ui/form-field.tsx. Drop these onto any page that loads
  Tailwind CSS v4 and the shadcn theme variables (foreground, muted-foreground,
  input, ring, destructive). The label's `for`, the control's
  aria-describedby, and aria-invalid are wired by hand here — keep the ids in
  sync. Error styling rides on the native :user-invalid pseudo so the field
  only turns red AFTER the user interacts + attempts submit. No JS required.

  Built on <fieldset>/<legend>, Constraint Validation + :user-invalid, and
  aria-describedby. See:
    repos/mdn/files/en-us/web/html/reference/elements/fieldset/index.md
    repos/mdn/files/en-us/web/css/reference/selectors/_colon_user-invalid/index.md
    repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-describedby/index.md

  The :user-invalid hook (drives the label red, pure CSS):
    [&:has(:user-invalid)_[data-slot=form-field-label]]:text-destructive
-->

<!-- ─── Single field — label + input + description ─────────────────────── -->
<div data-slot="form-field"
     class="grid gap-2 [&:has(:user-invalid)_[data-slot=form-field-label]]:text-destructive">
  <label for="ff-email" data-slot="form-field-label"
         class="flex items-center gap-2 text-sm leading-none font-medium select-none data-[invalid=true]:text-destructive peer-disabled:cursor-not-allowed peer-disabled:opacity-50">
    Email
  </label>
  <input id="ff-email" name="email" type="email" required autocomplete="email"
         placeholder="[email protected]" data-slot="input"
         aria-describedby="ff-email-description"
         class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 user-invalid:border-destructive">
  <p id="ff-email-description" class="text-sm text-muted-foreground" data-slot="form-field-description">
    We'll never share it.
  </p>
</div>

<!-- ─── Invalid field — server-known error (data-invalid + role=alert) ──── -->
<div data-slot="form-field" data-invalid="true"
     class="grid gap-2 [&:has(:user-invalid)_[data-slot=form-field-label]]:text-destructive">
  <label for="ff-name" data-slot="form-field-label" data-invalid="true" data-required="true"
         class="flex items-center gap-2 text-sm leading-none font-medium select-none data-[invalid=true]:text-destructive peer-disabled:cursor-not-allowed peer-disabled:opacity-50">
    Full name<span class="text-destructive" aria-hidden="true">*</span>
  </label>
  <input id="ff-name" name="name" type="text" value="" aria-invalid="true" aria-required="true"
         aria-describedby="ff-name-error" data-slot="input"
         class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground md:text-sm dark:bg-input/30 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">
  <p id="ff-name-error" role="alert" aria-live="assertive" class="text-sm font-medium text-destructive" data-slot="form-field-error">
    Name is required.
  </p>
</div>

<!-- ─── Fieldset group — multiple controls under one legend ────────────── -->
<fieldset data-slot="form-field"
          class="grid gap-2 [&:has(:user-invalid)_[data-slot=form-field-label]]:text-destructive min-w-0 border-0 p-0">
  <legend data-slot="form-field-legend"
          class="text-sm leading-none font-medium select-none float-none mb-1 data-[invalid=true]:text-destructive">
    Notification method
  </legend>
  <label class="flex items-center gap-2 text-sm font-medium">
    <input type="radio" name="notify" value="email" class="size-4" required> Email
  </label>
  <label class="flex items-center gap-2 text-sm font-medium">
    <input type="radio" name="notify" value="sms" class="size-4"> SMS
  </label>
  <p class="text-sm text-muted-foreground" data-slot="form-field-description">
    Choose how we reach you.
  </p>
</fieldset>

<!-- ─── htmx — validate on blur, swap the whole field ──────────────────── -->
<!-- The server returns the same field markup, flipping aria-invalid + the
     error <p> in one outerHTML swap. -->
<div id="ff-htmx" data-slot="form-field"
     class="grid gap-2 [&:has(:user-invalid)_[data-slot=form-field-label]]:text-destructive">
  <label for="ff-htmx-email" data-slot="form-field-label"
         class="flex items-center gap-2 text-sm leading-none font-medium select-none data-[invalid=true]:text-destructive">
    Email (checked on blur)
  </label>
  <input id="ff-htmx-email" name="email" type="email" placeholder="[email protected]" data-slot="input"
         hx-post="/api/validate-email" hx-trigger="blur" hx-target="#ff-htmx" hx-swap="outerHTML"
         class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&.htmx-request]:opacity-70">
</div>

Examples

Label + description

The row wires the label's `for` to the control id and the control's aria-describedby to the description — automatically.

Pass the control as the child and an for id. The field links the <label> to the control and points aria-describedby at the description, so a screen reader reads the label then the helper text. Clicking the label focuses the input — native <label for> behaviour, no JS.

We'll only use it to send receipts.

<FormField for="email" label="Email"
           description="We'll only use it to send receipts.">
  <Input id="email" type="email" name="email"
         placeholder="[email protected]" />
</FormField>
{% call form_field(for_="email", label="Email",
                   description="We'll only use it to send receipts.") %}
  {{ input(id="email", name="email", type="email",
           placeholder="[email protected]",
           aria_describedby="email-description") }}
{% endcall %}
{{template "form-field" (dict
  "For" "email" "Label" "Email"
  "Description" "We'll only use it to send receipts."
  "Control" (htmlSafe `<input id="email" name="email" type="email"
      aria-describedby="email-description" class="…">`)
)}}
<.form_field for="email" label="Email"
             description="We'll only use it to send receipts.">
  <.input id="email" name="email" type="email"
          placeholder="[email protected]"
          aria-describedby="email-description" />
</.form_field>
<div class="w-full max-w-md">
  <div class="grid gap-2 [&amp;:has(:user-invalid)_[data-slot=form-field-label]]:text-destructive" data-slot="form-field">
    <label for="ff-basic-email" class="flex items-center gap-2 text-sm leading-none font-medium select-none data-[invalid=true]:text-destructive peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="form-field-label">Email</label>
    <input type="email" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 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 [&amp;.htmx-request]:opacity-70" aria-describedby="ff-basic-email-description" data-slot="input" id="ff-basic-email" name="email" placeholder="[email protected]" autocomplete="email"/>
    <p id="ff-basic-email-description" class="text-sm text-muted-foreground" data-slot="form-field-description">We&#39;ll only use it to send receipts.</p>
  </div>
</div>

Error + :user-invalid

When `error` is set the field flips aria-invalid, announces the message, and the label turns red. :user-invalid keeps client errors from showing too early.

Two complementary mechanisms. Server-known errors: pass error and the field sets aria-invalid, renders a role="alert" message, and wires it into aria-describedby. Client constraints: the root carries a :has(:user-invalid) hook, so a native constraint failure (e.g. a bad email) turns the label red only after the user has interacted and tried to submit — never before.

<FormField for="name" label="Full name" required
           error={errors.name /* "Name is required." */}>
  <Input id="name" type="text" name="name" required />
</FormField>
{% call form_field(for_="name", label="Full name",
                   required=true, error=errors.name) %}
  {{ input(id="name", name="name", type="text", required=true,
           aria_describedby="name-error",
           aria_invalid=(errors.name is not none)) }}
{% endcall %}
{{template "form-field" (dict
  "For" "name" "Label" "Full name" "Required" true
  "Error" .Errors.Name
  "Control" (htmlSafe `<input id="name" name="name" type="text" required
      aria-describedby="name-error" aria-invalid="true" class="…">`)
)}}
<.form_field for="name" label="Full name" required
             error={@errors[:name]}>
  <.input id="name" name="name" type="text" required
          aria-describedby="name-error"
          aria-invalid={@errors[:name] && "true"} />
</.form_field>
<div class="w-full max-w-md">
  <div class="grid gap-2 [&amp;:has(:user-invalid)_[data-slot=form-field-label]]:text-destructive" data-slot="form-field" data-invalid="true">
    <label for="ff-invalid-name" class="flex items-center gap-2 text-sm leading-none font-medium select-none data-[invalid=true]:text-destructive peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="form-field-label" data-invalid="true" data-required="true">
      Full name
      <span class="text-destructive" aria-hidden="true">*</span>
    </label>
    <input type="text" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 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 [&amp;.htmx-request]:opacity-70" aria-describedby="ff-invalid-name-error" aria-invalid="true" aria-required="true" data-slot="input" id="ff-invalid-name" name="name" value=""/>
    <p id="ff-invalid-name-error" role="alert" aria-live="assertive" class="text-sm font-medium text-destructive" data-slot="form-field-error">Name is required.</p>
  </div>
</div>

Fieldset group

Group related controls (radios, checkboxes) under one <legend>. Disable them all at once with the fieldset's disabled attribute.

When the field is a set of controls, the right element is <fieldset> with a <legend>: the legend becomes the group's accessible name, announced before each option. A single disabled attribute on the fieldset disables every descendant control — no per-input bookkeeping.

Plan

You can change this anytime.

<FormFieldset id="plan" legend="Plan"
               description="You can change this anytime.">
  <RadioGroup name="plan" defaultValue="pro">
    <label><RadioGroupItem value="hobby" name="plan" /> Hobby</label>
    <label><RadioGroupItem value="pro" name="plan" defaultChecked /> Pro</label>
  </RadioGroup>
</FormFieldset>
{% call form_fieldset(id="plan", legend="Plan",
                      description="You can change this anytime.") %}
  <label><input type="radio" name="plan" value="hobby"> Hobby</label>
  <label><input type="radio" name="plan" value="pro" checked> Pro</label>
{% endcall %}
{{template "form-fieldset" (dict
  "ID" "plan" "Legend" "Plan"
  "Description" "You can change this anytime."
  "Controls" (htmlSafe `<label><input type="radio" name="plan" value="hobby"> Hobby</label>…`)
)}}
<.form_fieldset id="plan" legend="Plan"
                description="You can change this anytime.">
  <label><input type="radio" name="plan" value="hobby" /> Hobby</label>
  <label><input type="radio" name="plan" value="pro" checked /> Pro</label>
</.form_fieldset>
<div class="w-full max-w-md">
  <fieldset class="grid gap-2 [&amp;:has(:user-invalid)_[data-slot=form-field-label]]:text-destructive min-w-0 border-0 p-0" data-slot="form-field" aria-describedby="ff-plan-description">
    <legend class="text-sm leading-none font-medium select-none float-none mb-1 data-[invalid=true]:text-destructive" data-slot="form-field-legend">Plan</legend>
    <div role="radiogroup" data-slot="radio-group" data-name="ff-plan-choice" data-default-value="pro" class="grid gap-3 data-[orientation=horizontal]:grid-flow-col data-[orientation=horizontal]:auto-cols-max">
      <label class="flex items-center gap-2 text-sm font-medium">
        <span class="relative inline-flex size-4 shrink-0 align-middle">
          <input type="radio" class="peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="radio-group-item" value="hobby" id="ff-plan-hobby" name="ff-plan-choice"/>
          <span class="pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block" data-slot="radio-group-indicator" aria-hidden="true">
          </span>
        </span>
        Hobby
      </label>
      <label class="flex items-center gap-2 text-sm font-medium">
        <span class="relative inline-flex size-4 shrink-0 align-middle">
          <input type="radio" class="peer aspect-square size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30" data-slot="radio-group-item" value="pro" id="ff-plan-pro" name="ff-plan-choice" defaultChecked="true"/>
          <span class="pointer-events-none absolute inset-0 m-auto size-2 hidden rounded-full bg-primary peer-checked:block" data-slot="radio-group-indicator" aria-hidden="true">
          </span>
        </span>
        Pro
      </label>
    </div>
    <p id="ff-plan-description" class="text-sm text-muted-foreground" data-slot="form-field-description">You can change this anytime.</p>
  </fieldset>
</div>

htmx — validate on blur

On blur the server checks the value and returns the whole field, flipping error + aria-invalid in a single outerHTML swap.

Server validation belongs on the server. The field becomes a swap target: hx-trigger="blur" fires when the user leaves the input, hx-swap="outerHTML" replaces the entire field — so the server returns the same <FormField> with the error message and aria-invalid set, all wired up.

<FormField id="email-field" for="email" label="Email">
  <Input id="email" type="email" name="email"
         hx-post="/api/validate-email" hx-trigger="blur"
         hx-target="#email-field" hx-swap="outerHTML" />
</FormField>
{% call form_field(id="email-field", for_="email", label="Email") %}
  {{ input(id="email", name="email", type="email",
           hx_post="/api/validate-email", hx_trigger="blur",
           hx_target="#email-field", hx_swap="outerHTML") }}
{% endcall %}
{{template "form-field" (dict
  "For" "email" "Label" "Email"
  "Control" (htmlSafe `<input id="email" name="email" type="email"
      hx-post="/api/validate-email" hx-trigger="blur"
      hx-target="#email-field" hx-swap="outerHTML" class="…">`)
)}}
<.form_field id="email-field" for="email" label="Email">
  <.input id="email" name="email" type="email"
          hx-post="/api/validate-email" hx-trigger="blur"
          hx-target="#email-field" hx-swap="outerHTML" />
</.form_field>
<div class="w-full max-w-md">
  <div class="grid gap-2 [&amp;:has(:user-invalid)_[data-slot=form-field-label]]:text-destructive" data-slot="form-field" id="ff-htmx-field">
    <label for="ff-htmx-email" class="flex items-center gap-2 text-sm leading-none font-medium select-none data-[invalid=true]:text-destructive peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="form-field-label">Email (validated on blur)</label>
    <input type="email" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 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 [&amp;.htmx-request]:opacity-70" data-slot="input" id="ff-htmx-email" name="email" placeholder="[email protected]" hx-post="/docs/form-field/validate-email" hx-trigger="blur" hx-target="#ff-htmx-field" hx-swap="outerHTML"/>
  </div>
</div>

API Reference

<FormField>

PropTypeDefaultDescription
childrenChild
The single control to wire up (an <Input>, <Textarea>, <Select>, …). It is cloned to inject id, aria-describedby, and aria-invalid.
labelChild
Visible label text. Rendered in a <label for> linked to the control. Omit to render no label (e.g. a self-labelled control).MDN<label>
forstring
The control's id. The label points at it, and the description/error ids are derived as {id}-description and {id}-error.
descriptionChild
Helper text under the label. Its id is folded into the control's aria-describedby so screen readers announce it after the name.MDNaria-describedby
errorChild
Error message. When set, the field marks aria-invalid, renders a role=alert message, and wires it into aria-describedby. Defaults invalid to true.WCAG3.3.1 Error Identification
invalidboolean
Force the invalid state. Defaults to true when error is provided.MDNaria-invalid
requiredbooleanfalse
Adds a * indicator to the label and forwards aria-required onto the control.
labelClassstring
Extra Tailwind classes appended to the label element.
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference