shshadcn-htmx

Components

Input

A real <input> with shadcn polish. All native constraint validation, mobile keyboard hints, and autofill keep working — we only restyle. htmx attributes ride along, so live search and live validation are a few attributes away.

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

2. Use it

app/some-page.tsx
import { Input } from "@/components/ui/input"

<Input type="email" name="email" placeholder="[email protected]" required />
Or copy the source manually
components/ui/input.tsx
/** @jsxImportSource hono/jsx */
import { cn, type ClassValue } from "@/registry/lib/cn"

// Native <input> with shadcn styling. Source of truth:
//   repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/input.tsx
//
// We render a real <input> so every native behaviour is preserved:
// constraint validation (required, pattern, min/max), client-side autofill,
// browser autocomplete, and the input-mode keyboards on mobile.

export type InputType =
  | "text"
  | "password"
  | "email"
  | "number"
  | "search"
  | "tel"
  | "url"
  | "date"
  | "time"
  | "datetime-local"
  | "month"
  | "week"
  | "color"
  | "file"
  | "hidden"

const base =
  "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 " +
  // htmx-request: dim while a request triggered by/targeting this input is
  // in flight (e.g. live-validation patterns).
  "[&.htmx-request]:opacity-70"

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

type InputProps = {
  type?: InputType
  class?: ClassValue
  id?: string
  name?: string
  value?: string | number
  defaultValue?: string | number
  placeholder?: string
  required?: boolean
  disabled?: boolean
  readonly?: boolean

  // String/numeric constraints. The browser enforces these natively when the
  // input is inside a <form> that submits.
  minLength?: number
  maxLength?: number
  min?: number | string
  max?: number | string
  step?: number | string
  pattern?: string

  // Mobile UX: hint to the OS keyboard layout (numeric, decimal, email, …)
  // and the Enter key label (done, search, send, …).
  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
  // named by this attribute's value. Critical for multilingual forms — the
  // server can preserve the writer's intent even if it doesn't speak the
  // language. Valid for text/search/url/tel/email.
  // See repos/mdn/files/en-us/web/html/reference/elements/input/index.md:357
  dirname?: string

  // type="file" only — request the OS camera with a specific facing mode.
  capture?: "user" | "environment" | boolean

  // Mobile keyboard capitalisation. iOS/Safari honour this aggressively;
  // most useful as "off" for email/password/url where auto-caps is wrong.
  autocapitalize?: "off" | "none" | "on" | "sentences" | "words" | "characters"

  // Spellcheck hint (enumerated global attribute). Set "false" for fields
  // holding PII, codes, usernames, or tokens — spellcheck content may be
  // sent to a third party ("spell-jacking").
  // See repos/mdn/files/en-us/web/html/reference/global_attributes/spellcheck/index.md
  spellcheck?: boolean

  // Autocorrect hint (enumerated global attribute, on/off). Disable for
  // names, usernames, addresses, or coupon/API codes where OS autocorrect
  // is harmful. password/email/url are always "off" per spec.
  // See repos/mdn/files/en-us/web/html/reference/global_attributes/autocorrect/index.md
  autocorrect?: "on" | "off"

  // Visible width in characters for text/email/password/tel/url. Mostly
  // superseded by CSS but useful for graceful fallback rendering.
  size?: number

  // Autofill / completion.
  autocomplete?: string
  autofocus?: boolean
  list?: string

  // For type="file".
  accept?: string
  multiple?: boolean

  // ARIA
  ariaLabel?: string
  ariaLabelledby?: string
  ariaDescribedby?: string
  ariaInvalid?: boolean | "grammar" | "spelling"
  ariaRequired?: boolean

  // Form metadata
  form?: string

  // htmx v4 attributes (subset). htmx fires hx-* on the trigger event, default
  // for an input is "change" (or "input" for type=search). Use hx-trigger to
  // override, e.g. hx-trigger="input changed delay:300ms".
  // 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
  "hx-disable"?: string
}

export function Input(props: InputProps) {
  const {
    type = "text",
    class: className,
    ariaLabel,
    ariaLabelledby,
    ariaDescribedby,
    ariaInvalid,
    ariaRequired,
    ...rest
  } = props

  return (
    <input
      type={type}
      class={inputClasses({ class: className })}
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      aria-describedby={ariaDescribedby}
      aria-invalid={ariaInvalid === undefined ? undefined : String(ariaInvalid)}
      aria-required={ariaRequired === undefined ? undefined : String(ariaRequired)}
      data-slot="input"
      {...rest}
    />
  )
}

1. Save the macro

Copy input.html into templates/components/.

2. Use it

templates/page.html
{% from "components/input.html" import input %}

{{ input(type="email", name="email", placeholder="[email protected]", required=true) }}
Source — input.html
templates/components/input.html
{# Input macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/input.tsx for Python/Flask/FastAPI/Django/Jinja2.

   Usage:
       {% from "components/input.html" import input %}
       {{ input(name="email", type="email", placeholder="[email protected]") }}
       {{ input(name="q", type="search",
                hx_get="/search", hx_target="#results",
                hx_trigger="input changed delay:300ms") }}

   All hx-* attributes are passed through via **attrs (underscores become
   dashes, so `hx_get="/search"` emits `hx-get="/search"`).
   See repos/htmx/www/src/content/reference/01-attributes/. #}

{% macro input(
    type="text",
    id=none,
    name=none,
    value=none,
    placeholder=none,
    required=false,
    disabled=false,
    readonly=false,
    minlength=none,
    maxlength=none,
    min=none,
    max=none,
    step=none,
    pattern=none,
    inputmode=none,
    enterkeyhint=none,
    autocomplete=none,
    autocapitalize=none,
    spellcheck=none,
    autocorrect=none,
    autofocus=false,
    list=none,
    accept=none,
    capture=none,
    multiple=false,
    size=none,
    dirname=none,
    form=none,
    aria_label=none,
    aria_labelledby=none,
    aria_describedby=none,
    aria_invalid=none,
    aria_required=none,
    extra_class="",
    **attrs
) %}
{%- set base -%}
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 [&.htmx-request]:opacity-70
{%- endset -%}
<input type="{{ type }}"
       class="{{ base }} {{ extra_class }}"
       {%- if id %} id="{{ id }}"{% endif %}
       {%- if name %} name="{{ name }}"{% endif %}
       {%- if value is not none %} value="{{ value }}"{% endif %}
       {%- if placeholder %} placeholder="{{ placeholder }}"{% endif %}
       {%- if required %} required{% endif %}
       {%- if disabled %} disabled{% endif %}
       {%- if readonly %} readonly{% endif %}
       {%- if minlength is not none %} minlength="{{ minlength }}"{% endif %}
       {%- if maxlength is not none %} maxlength="{{ maxlength }}"{% endif %}
       {%- if min is not none %} min="{{ min }}"{% endif %}
       {%- if max is not none %} max="{{ max }}"{% endif %}
       {%- if step is not none %} step="{{ step }}"{% endif %}
       {%- if pattern %} pattern="{{ pattern }}"{% endif %}
       {%- if inputmode %} inputmode="{{ inputmode }}"{% endif %}
       {%- if enterkeyhint %} enterkeyhint="{{ enterkeyhint }}"{% endif %}
       {%- if autocomplete %} autocomplete="{{ autocomplete }}"{% endif %}
       {%- if autocapitalize %} autocapitalize="{{ autocapitalize }}"{% endif %}
       {# spellcheck/autocorrect: enumerated global attrs see repos/mdn/files/en-us/web/html/reference/global_attributes/spellcheck/index.md #}
       {%- if spellcheck is not none %} spellcheck="{{ 'true' if spellcheck else 'false' }}"{% endif %}
       {%- if autocorrect %} autocorrect="{{ autocorrect }}"{% endif %}
       {%- if autofocus %} autofocus{% endif %}
       {%- if list %} list="{{ list }}"{% endif %}
       {%- if accept %} accept="{{ accept }}"{% endif %}
       {%- if capture is not none %} capture{% if capture is string %}="{{ capture }}"{% endif %}{% endif %}
       {%- if multiple %} multiple{% endif %}
       {%- if size is not none %} size="{{ size }}"{% endif %}
       {%- if dirname %} dirname="{{ dirname }}"{% endif %}
       {%- if form %} form="{{ form }}"{% endif %}
       {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
       {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
       {%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
       {%- if aria_invalid is not none %} aria-invalid="{{ aria_invalid|string|lower }}"{% endif %}
       {%- if aria_required is not none %} aria-required="{{ aria_required|string|lower }}"{% endif %}
       data-slot="input"
       {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>
{% endmacro %}

1. Save the template

Copy input.tmpl into your templates/ tree.

2. Use it

handler.go
tpl.ExecuteTemplate(w, "input", map[string]any{
    "Type": "email",
    "Name": "email",
    "Placeholder": "[email protected]",
    "Required": true,
})
Source — input.tmpl
templates/components/input.tmpl
{{/*
  Input template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/input.tsx for Go projects using html/template.

  Usage in your code:

      type InputArgs struct {
          Type        string // text | password | email | number | search | tel | url | date | …
          ID          string
          Name        string
          Value       string
          Placeholder string
          Required    bool
          Disabled    bool
          Readonly    bool

          // Validation
          MinLength int
          MaxLength int
          Min       string
          Max       string
          Step      string
          Pattern   string

          // Mobile UX
          InputMode      string // none | text | decimal | numeric | tel | search | email | url
          EnterKeyHint   string // enter | done | go | next | previous | search | send
          Autocapitalize string // off | none | on | sentences | words | characters
          Spellcheck     *bool  // tri-state spellcheck hint (enumerated global attr)
          Autocorrect    string // "" | on | off  (enumerated global attr)

          // Autofill / completion
          Autocomplete string
          Autofocus    bool
          List         string // datalist id

          // type="file"
          Accept   string
          Multiple bool
          Capture  string // "" | "user" | "environment" | "true"

          // Display / form behaviour
          Size    string // visible width in characters
          Dirname string // submits text directionality as a separate field

          // Form metadata
          Form string

          // ARIA
          AriaLabel       string
          AriaLabelledby  string
          AriaDescribedby string
          AriaInvalid     string // "true" | "false" | "grammar" | "spelling"
          AriaRequired    string // "true" | "false"

          // Everything else (hx-get, hx-target, hx-trigger, …)
          Attrs map[string]string
      }

      tpl.ExecuteTemplate(w, "input", InputArgs{
          Type: "email", Name: "email",
          Placeholder: "[email protected]",
          Required: true,
      })

  See repos/mdn/files/en-us/web/html/reference/elements/input/ for native
  attribute semantics.
*/}}

{{define "input"}}
{{- $type := or .Type "text" -}}
{{- $base := "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 [&.htmx-request]:opacity-70" -}}
<input type="{{$type}}"
       class="{{$base}}"
       {{- if .ID}} id="{{.ID}}"{{end}}
       {{- if .Name}} name="{{.Name}}"{{end}}
       {{- if .Value}} value="{{.Value}}"{{end}}
       {{- if .Placeholder}} placeholder="{{.Placeholder}}"{{end}}
       {{- if .Required}} required{{end}}
       {{- if .Disabled}} disabled{{end}}
       {{- if .Readonly}} readonly{{end}}
       {{- if .MinLength}} minlength="{{.MinLength}}"{{end}}
       {{- if .MaxLength}} maxlength="{{.MaxLength}}"{{end}}
       {{- if .Min}} min="{{.Min}}"{{end}}
       {{- if .Max}} max="{{.Max}}"{{end}}
       {{- if .Step}} step="{{.Step}}"{{end}}
       {{- if .Pattern}} pattern="{{.Pattern}}"{{end}}
       {{- if .InputMode}} inputmode="{{.InputMode}}"{{end}}
       {{- if .EnterKeyHint}} enterkeyhint="{{.EnterKeyHint}}"{{end}}
       {{- if .Autocomplete}} autocomplete="{{.Autocomplete}}"{{end}}
       {{- if .Autocapitalize}} autocapitalize="{{.Autocapitalize}}"{{end}}
       {{- /* spellcheck/autocorrect: enumerated global attrs repos/mdn/files/en-us/web/html/reference/global_attributes/spellcheck/index.md */ -}}
       {{- if .Spellcheck}} spellcheck="{{if deref .Spellcheck}}true{{else}}false{{end}}"{{end}}
       {{- if .Autocorrect}} autocorrect="{{.Autocorrect}}"{{end}}
       {{- if .Autofocus}} autofocus{{end}}
       {{- if .List}} list="{{.List}}"{{end}}
       {{- if .Accept}} accept="{{.Accept}}"{{end}}
       {{- if .Capture}} capture{{if ne .Capture "true"}}="{{.Capture}}"{{end}}{{end}}
       {{- if .Multiple}} multiple{{end}}
       {{- if .Size}} size="{{.Size}}"{{end}}
       {{- if .Dirname}} dirname="{{.Dirname}}"{{end}}
       {{- if .Form}} form="{{.Form}}"{{end}}
       {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
       {{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
       {{- if .AriaDescribedby}} aria-describedby="{{.AriaDescribedby}}"{{end}}
       {{- if .AriaInvalid}} aria-invalid="{{.AriaInvalid}}"{{end}}
       {{- if .AriaRequired}} aria-required="{{.AriaRequired}}"{{end}}
       data-slot="input"
       {{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>
{{end}}

1. Save the component module

Copy input.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/live/page.html.heex
<.input type="email" name="email" placeholder="[email protected]" required />
Source — input.ex
lib/my_app_web/components/input.ex
defmodule ShadcnHtmx.Components.Input do
  @moduledoc """
  Input — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/input.tsx. Works with plain HEEx and LiveView forms;
  htmx attributes and any other input attribute pass through via `:rest`.

  ## Examples

      <.input type="email" name="email" placeholder="[email protected]" required />
      <.input type="search" name="q"
              hx-get="/search" hx-target="#results"
              hx-trigger="input changed delay:300ms" />

  See repos/mdn/files/en-us/web/html/reference/elements/input/ for native
  attribute semantics.
  """

  use Phoenix.Component

  @base "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 " <>
          "[&.htmx-request]:opacity-70"

  attr :type, :string,
    default: "text",
    values:
      ~w(text password email number search tel url date time datetime-local month week color file hidden)

  attr :class, :string, default: nil

  attr :rest, :global,
    include:
      ~w(hx-get hx-post hx-put hx-patch hx-delete hx-target hx-swap hx-trigger hx-indicator hx-vals hx-include hx-disable
         id name value placeholder required disabled readonly
         minlength maxlength min max step pattern
         inputmode enterkeyhint autocomplete autocapitalize autofocus list accept capture multiple size dirname form
         spellcheck autocorrect
         aria-label aria-labelledby aria-describedby aria-invalid aria-required)

  def input(assigns) do
    assigns = assign(assigns, :base_class, @base)

    ~H"""
    <input
      type={@type}
      class={[@base_class, @class]}
      data-slot="input"
      {@rest}
    />
    """
  end
end

1. Load Tailwind

See the Button page for the Tailwind + htmx CDN setup.

2. Paste the input markup

index.html
<input type="email" name="email" placeholder="[email protected]" required
       class="flex h-9 w-full min-w-0 rounded-md border border-input
              bg-transparent px-3 py-1 text-base shadow-xs … " />
Snippets — types, states, htmx wiring
snippets/input.html
<!--
  shadcn-htmx — raw HTML input snippets.

  Mirrors registry/ui/input.tsx. Drop these onto any page that loads Tailwind
  CSS v4 and the shadcn theme variables (background, foreground, input, ring,
  destructive, primary). See app/styles/input.css for the variable defaults.

  BASE (shared by every input):
    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
    [&.htmx-request]:opacity-70
-->

<!-- ─── Common types ────────────────────────────────────────────────── -->

<!-- Email — browser validates locally; required signals the constraint -->
<input type="email" name="email" placeholder="[email protected]" required autocomplete="email" 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 selection:bg-primary selection:text-primary-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">

<!-- Password — autocomplete="current-password" / "new-password" -->
<input type="password" name="password" autocomplete="current-password" 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 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">

<!-- Number — inputmode="decimal" hints the OS keyboard -->
<input type="number" name="amount" min="0" step="0.01" inputmode="decimal" 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 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">

<!-- Search — pair with hx-get for live-search -->
<input type="search" name="q" placeholder="Search…" autocomplete="off" data-slot="input"
  hx-get="/search" hx-target="#results" hx-trigger="input changed delay:300ms"
  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 [&.htmx-request]:opacity-70">

<!-- ─── States ──────────────────────────────────────────────────────── -->

<!-- Invalid — pair with an error message via aria-describedby -->
<div>
  <input type="email" name="email" aria-invalid="true" aria-describedby="email-error"
         placeholder="[email protected]" 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="email-error" class="mt-1 text-sm text-destructive">Enter a valid email address.</p>
</div>

<!-- Disabled — also removes from tab order -->
<input type="text" disabled value="Read-only after save" 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 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30">

<!-- Readonly — value is selectable + focusable, but not editable -->
<input type="text" readonly value="https://example.com/abc-123" 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">

<!-- ─── File ────────────────────────────────────────────────────────── -->
<input type="file" name="avatar" accept="image/*" 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 file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50">

<!-- ─── Sensitive / no-correction ───────────────────────────────────── -->
<!-- spellcheck="false" + autocorrect="off": codes, usernames, tokens, API keys.
     spellcheck content may be sent to a third party ("spell-jacking"); both are
     enumerated global attributes (MDN global_attributes/spellcheck, /autocorrect). -->
<input type="text" name="coupon" placeholder="Coupon code" spellcheck="false" autocorrect="off" autocapitalize="characters" 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">

Examples

Types — the browser already knows

type changes validation, keyboard, autofill, and screen-reader announcements all at once.

Every HTML5 input type is a contract with the platform: email brings constraint validation + an @ key, tel swaps the keyboard to digits, date opens a native picker, search draws the clear-text affordance and announces itself as a search box. Reach for the right type before reaching for JavaScript.

<Input type="email"  name="email" placeholder="[email protected]" />
<Input type="tel"    name="phone" inputmode="tel"  placeholder="+90 555 …" />
<Input type="number" name="amount" inputmode="decimal" placeholder="0.00" />
<Input type="date"   name="when" />
<Input type="search" name="q" placeholder="Search…" />
{{ input(type="email",  name="email", placeholder="[email protected]") }}
{{ input(type="tel",    name="phone", inputmode="tel",  placeholder="+90 555 …") }}
{{ input(type="number", name="amount", inputmode="decimal", placeholder="0.00") }}
{{ input(type="date",   name="when") }}
{{ input(type="search", name="q", placeholder="Search…") }}
{{template "input" (dict "Type" "email"  "Name" "email"  "Placeholder" "[email protected]")}}
{{template "input" (dict "Type" "tel"    "Name" "phone"  "InputMode" "tel"  "Placeholder" "+90 555 …")}}
{{template "input" (dict "Type" "number" "Name" "amount" "InputMode" "decimal" "Placeholder" "0.00")}}
{{template "input" (dict "Type" "date"   "Name" "when")}}
{{template "input" (dict "Type" "search" "Name" "q"      "Placeholder" "Search…")}}
<.input type="email"  name="email" placeholder="[email protected]" />
<.input type="tel"    name="phone" inputmode="tel"  placeholder="+90 555 …" />
<.input type="number" name="amount" inputmode="decimal" placeholder="0.00" />
<.input type="date"   name="when" />
<.input type="search" name="q" placeholder="Search…" />
<div class="grid w-full max-w-md gap-3">
  <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-label="Email" data-slot="input" name="email" placeholder="[email protected]" autocomplete="email"/>
  <input type="tel" 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-label="Phone" data-slot="input" name="phone" placeholder="+90 555 …" autocomplete="tel" inputmode="tel"/>
  <input type="number" 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-label="Amount" data-slot="input" name="amount" placeholder="0.00" inputmode="decimal"/>
  <input type="date" 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-label="When" data-slot="input" name="when"/>
  <input type="search" 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-label="Search" data-slot="input" name="q" placeholder="Search…" autocomplete="off"/>
</div>

Invalid + error message

aria-invalid styles the field; aria-describedby connects it to the error text.

Don't rely on red alone — pair aria-invalid="true" with a visible error and aria-describedby pointing at it. Screen readers will read the error after the field's label, so the user hears both context and what to fix. The browser's native :invalid pseudo only fires after a submit attempt — you usually want the explicit attribute instead.

Enter a valid email address.

<label htmlFor="email">Email</label>
<Input id="email" type="email" name="email"
       value={value} ariaInvalid={!valid}
       ariaDescribedby={!valid ? "email-error" : undefined} />
{!valid && <p id="email-error" class="text-sm text-destructive">
  Enter a valid email address.
</p>}
<label for="email">Email</label>
{{ input(id="email", type="email", name="email",
         value=value, aria_invalid=(not valid),
         aria_describedby=("email-error" if not valid else none)) }}
{% if not valid %}
  <p id="email-error" class="text-sm text-destructive">
    Enter a valid email address.
  </p>
{% endif %}
{{template "input" (dict
  "ID" "email" "Type" "email" "Name" "email"
  "Value" .Value "AriaInvalid" (ternary "true" "" (not .Valid))
  "AriaDescribedby" (ternary "email-error" "" (not .Valid))
)}}
<label for="email">Email</label>
<.input id="email" type="email" name="email"
        value={@value}
        aria-invalid={if !@valid, do: "true"}
        aria-describedby={if !@valid, do: "email-error"} />
<div class="grid w-full max-w-md gap-2">
  <label class="text-xs font-medium" for="ex-invalid-email">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="ex-invalid-email-error" aria-invalid="true" data-slot="input" id="ex-invalid-email" name="email" value="not-an-email"/>
  <p id="ex-invalid-email-error" class="text-sm text-destructive">Enter a valid email address.</p>
</div>

Disabled vs. readonly

Two different contracts. Disabled removes the field entirely; readonly keeps it focusable + selectable.

disabled is total: no focus, no events, value not submitted with the form. readonly is gentler: the user can focus, select, and copy the value; it just can't be edited, and it does submit with the form. Use readonly for pre-filled IDs and computed values; reach for disabled when the field literally doesn't apply yet.

<Input disabled value="Cannot focus or edit" />
<Input readonly value="https://example.com/abc-123" />
{{ input(disabled=true, value="Cannot focus or edit") }}
{{ input(readonly=true, value="https://example.com/abc-123") }}
{{template "input" (dict "Disabled" true "Value" "Cannot focus or edit")}}
{{template "input" (dict "Readonly" true "Value" "https://example.com/abc-123")}}
<.input disabled value="Cannot focus or edit" />
<.input readonly value="https://example.com/abc-123" />
<div class="grid w-full max-w-md gap-3">
  <div class="space-y-1">
    <label class="text-xs font-medium text-muted-foreground" for="ex-disabled-1">Disabled</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" data-slot="input" id="ex-disabled-1" disabled="" value="Cannot focus or edit"/>
  </div>
  <div class="space-y-1">
    <label class="text-xs font-medium text-muted-foreground" for="ex-readonly-1">Readonly</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" data-slot="input" id="ex-readonly-1" readonly="" value="https://example.com/abc-123"/>
  </div>
</div>

Type into the box. htmx debounces by 300ms then GETs /input/search, the server returns a tiny HTML list, which replaces the results node.

The whole pattern is one element + four attributes — no state, no client logic, no JSON. hx-trigger with input changed delay:300ms fires after the user pauses typing. hx-target picks the destination, hx-swap="innerHTML" replaces its contents. While in flight, htmx adds .htmx-request to the input so the field dims itself.

  • Results appear here.
<Input type="search" name="q" placeholder="Type to search…"
       hx-get="/api/search" hx-target="#results"
       hx-trigger="input changed delay:300ms, search" />
<ul id="results" aria-live="polite"></ul>
{{ input(type="search", name="q",
         hx_get="/api/search", hx_target="#results",
         hx_trigger="input changed delay:300ms, search") }}
<ul id="results" aria-live="polite"></ul>
{{template "input" (dict
  "Type" "search" "Name" "q"
  "Attrs" (dict
    "hx-get" "/api/search"
    "hx-target" "#results"
    "hx-trigger" "input changed delay:300ms, search"
  )
)}}
<.input type="search" name="q"
        hx-get="/api/search" hx-target="#results"
        hx-trigger="input changed delay:300ms, search" />
<ul id="results" aria-live="polite"></ul>
<div class="grid w-full max-w-md gap-3">
  <label class="text-xs font-medium" for="ex-search">Search</label>
  <input type="search" 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="ex-search" name="q" placeholder="Type to search…" autocomplete="off" hx-get="/input/search" hx-target="#ex-search-results" hx-trigger="input changed delay:300ms, search"/>
  <ul id="ex-search-results" class="space-y-1 text-sm text-muted-foreground" aria-live="polite">
    <li>Results appear here.</li>
  </ul>
</div>

htmx — live server validation

On blur, the server checks the value and returns either the field as valid or as aria-invalid with an error message attached.

Server-side validation belongs on the server. With htmx you let the server own the truth and just swap its HTML back in. hx-trigger="blur" checks only after the user leaves the field; hx-swap="outerHTML" replaces the whole field, so the server can flip aria-invalid and inject the error message in one shot.

<Input id="email" type="email" name="email"
       hx-post="/api/validate-email"
       hx-trigger="blur" hx-swap="outerHTML" />
{{ input(id="email", type="email", name="email",
         hx_post="/api/validate-email",
         hx_trigger="blur", hx_swap="outerHTML") }}
{{template "input" (dict
  "ID" "email" "Type" "email" "Name" "email"
  "Attrs" (dict
    "hx-post" "/api/validate-email"
    "hx-trigger" "blur"
    "hx-swap" "outerHTML"
  )
)}}
<.input id="email" type="email" name="email"
        hx-post="/api/validate-email"
        hx-trigger="blur" hx-swap="outerHTML" />
<div class="grid w-full max-w-md gap-2">
  <label class="text-xs font-medium" for="ex-validate-email">Email (validated on blur)</label>
  <div id="ex-validate-email-field" class="grid gap-2">
    <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="ex-validate-email" name="email" placeholder="[email protected]" hx-post="/input/validate-email" hx-trigger="blur" hx-target="#ex-validate-email-field" hx-swap="outerHTML"/>
  </div>
</div>

API Reference

<Input>

PropTypeDefaultDescription
spellcheckboolean
Browser spellchecker hint (enumerated global attribute). Set false for fields holding PII, codes, usernames, or tokens — spellcheck content may be sent to a third party ("spell-jacking").
autocorrect"on"|"off"
Autocorrect hint (enumerated global attribute). Disable for names, usernames, addresses, or coupon/API codes where OS autocorrect is harmful. password/email/url are always off per spec.
type"text"|"email"|"password"|"tel"|"url"|"search"|"number"|"date"|"datetime-local"|"month"|"time"|"week"|"color"|"file""text"
Native HTML input type. Drives keyboard, validation, picker UI.MDN<input type>
minlength / maxlengthnumber
Character bounds for text-like types.
min / max / stepnumber | string
Bounds + increment for number / range / date types.
patternstring
Regex the value must match for the input to be valid.
autocompletestring
Browser auto-fill hint (email, current-password, postal-code, etc.).MDNautocomplete
inputmode"text"|"numeric"|"decimal"|"tel"|"email"|"url"|"search"|"none"
Mobile keyboard hint.
enterkeyhint"done"|"go"|"next"|"previous"|"search"|"send"|"enter"
Mobile Enter-key label.
dirnamestring
Submit the writer's text direction (ltr/rtl) as a second form field.
capture"user"|"environment"|boolean
type="file" only — request the OS camera.
autocapitalize"off"|"on"|"sentences"|"words"|"characters"
Mobile keyboard capitalisation hint.
liststring
Id of a <datalist> for native suggestions.
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