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.json2. Use it
import { Input } from "@/components/ui/input"
<Input type="email" name="email" placeholder="[email protected]" required />Or copy the source manually
/** @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
{% from "components/input.html" import input %}
{{ input(type="email", name="email", placeholder="[email protected]", required=true) }}Source — 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
tpl.ExecuteTemplate(w, "input", map[string]any{
"Type": "email",
"Name": "email",
"Placeholder": "[email protected]",
"Required": true,
})Source — 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
<.input type="email" name="email" placeholder="[email protected]" required />Source — 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
<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
<!--
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 [&.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 [&.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 [&.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 [&.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 [&.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 [&.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 [&.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 [&.htmx-request]:opacity-70" data-slot="input" id="ex-readonly-1" readonly="" value="https://example.com/abc-123"/>
</div>
</div>Further reading
htmx — live search
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 [&.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>Further reading
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 [&.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>
| Prop | Type | Default | Description |
|---|---|---|---|
spellcheck | boolean | — | 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 / maxlength | number | — | Character bounds for text-like types. |
min / max / step | number | string | — | Bounds + increment for number / range / date types. |
pattern | string | — | Regex the value must match for the input to be valid. |
autocomplete | string | — | 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. |
dirname | string | — | 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. |
list | string | — | Id of a <datalist> for native suggestions. |
id | string | — | Pairs the input with a <label for>. |
name | string | — | Form field name on submit. |
value | string | — | Initial value. |
placeholder | string | — | Placeholder text when empty. |
required | boolean | false | Native HTML required for form validation. |
disabled | boolean | false | Disable — unfocusable, not submitted. |
readonly | boolean | false | Read-only — focusable + selectable but not editable. |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
ariaDescribedby | string | — | Id of an element describing this control (announced after the name).MDNaria-describedby |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |