defuss-shadcn / Forms & Inputs / input
InputATM
A styled text input built on the native <input> element. Includes label, description, error, sizes, file input, textarea auto-grow, and focus ring. Zero JavaScript.
On this page (19)
Every example on this page is one executable source: the editor bytes and the sandboxed preview are the same string - editing the code changes the preview (no second render tree can drift from the sample).
§Default
Standard text input with label.
§With description
Use .field-description below the input for help text (wired with aria-describedby).
§Sizes
The five-step data-size ladder. The State tab's size select mutates the marked root input.
§Disabled
Non-interactive but legible: muted surface and text inside the full-strength border, not-allowed cursor - flip the disabled row in the State tab.
§Invalid
aria-invalid="true" with a .field-error message: the page's own verdict, so it shows at once. The browser's automatic check (:user-invalid) is deliberately quieter - it never judges a standalone text field, and inside a form it can wait for a submit (see Form composition and Custom validation below).
§Required
Just the required attribute - the label's red star renders automatically, and the State tab toggles it live.
§With icon
Wrap the field in .input-group: the group draws the frame, and an addon placed BEFORE the .input sits at the start. Icons get .input-group-icon (decorative, aria-hidden).
§Icon at the end
Same group, the addon placed AFTER the .input - DOM order is the placement, nothing else changes. Start and end addons combine freely.
§Clear button
A trailing .input-group-button is a real, focusable button inside the field. Here it clears the search and returns focus; it hides itself while the field is empty (hidden toggled on input).
§Password with show / hide
The eye button flips type between password and text; aria-pressed tells screen readers whether the password is showing, and the icon follows it.
§Text addons
Static text around the value - a protocol, a domain, a unit - with .input-group-text. It is part of the frame, not of the value the form submits.
§File
Native file picker - the selector button is styled automatically.
§Textarea
<textarea> with the .input class. Auto-grows with content via field-sizing: content - zero JS.
§Readonly
Non-editable value the user can still select and copy - so it gets a copy button: a trailing .input-group-button writes the value with navigator.clipboard and confirms with a check for 1.5 s. If the clipboard is blocked (e.g. a sandboxed frame), it selects the text instead so Ctrl/⌘+C works.
§With button
Pair an input with a button for inline actions (the df$ handler below logs input events into the sandbox console).
§Form composition
data-validate="submit" on the form: the browser still checks required and the email format, but a field only turns red once someone tries to submit - leaving a half-typed address is not an error. Two listeners stamp data-submitted on the attempt (invalid fires when the browser blocks it, submit when it passes); reset clears it. Without data-validate, a field inside a form turns red as soon as it is left with an invalid value.
§Custom validation
Your rule, your message, your moment. novalidate turns off the browser's bubbles; the page checks on submit - a username already taken, an invite code in a set shape, two passwords that must agree - and marks each failing field with aria-invalid, fills its .field-error and sets setCustomValidity (so :invalid and form.checkValidity() agree). Focus moves to the first problem. After that first attempt, each field re-checks as it is edited, so an error clears the moment it is fixed. Try: admin / abc / two different passwords.
§States
Machine contract: the table below is verified against input.schema.json by bun run verify - state names, types, enum values and defaults must match in both directions. The CodeExample State tabs render their controls from exactly these rows (hint editor hints included), and state values observed from the live DOM win over schema defaults.
| State | Type | Values | Default | Description |
|---|---|---|---|---|
value | string | — | "Hello world" | Current field value. Examples may set another value - the panel observes the DOM first. |
disabled | boolean | true, false | false | Disables interaction (native disabled property). |
readonly | boolean | true, false | false | Read-only value, selectable/copyable (readonly attribute; muted styling). |
required | boolean | true, false | false | Marks the field required (required attribute; the adjacent .label renders its star automatically). |
invalid | boolean | true, false | false | Error presentation (aria-invalid attribute → destructive border/ring; pair with a .field-error). |
type | enum | text, email, password, search, number | text | Input behavior / mobile keyboard (semantic type values). |
size | enum | xs, sm, md, lg, xl | md | Height ladder via data-size; the unsized default IS the md step (2.25rem). |
§CSS view file
/* -- Input component ------------------------------------------- */@layer components { .input { /* border-box is load-bearing for the shared field ladder: the UA gives <input> content-box, so `height` would otherwise exclude the 2px frame and render 2px taller than the border-box fields (select, button-based triggers). With it, every declared height IS the rendered box. The unsized default IS the ladder's md step (2.25rem = 36px) - same height, padding and font as [data-size="md"] below, so an unlabeled field and an explicit md field are the same control. */ box-sizing: border-box; height: 2.25rem; width: 100%; border: 1px solid var(--input); border-radius: var(--radius-md); background: var(--background); padding: 0 0.75rem; font-size: 0.875rem; font-family: var(--font-sans); color: var(--foreground); outline: none; box-shadow: var(--shadow-xs); transition: border-color 150ms, box-shadow 150ms; &:focus { border-color: var(--ring); box-shadow: 0 0 0 2px oklch(from var(--ring) l c h / 0.2); } &::placeholder { color: var(--muted-foreground); } /* -- Readonly state -------------------------------------- */ /* :not(:disabled) is load-bearing: a disabled input ALSO matches :read-only, and this rule's specificity (class + pseudo + :not attr) outranks the plain :disabled below - without the exclusion, disabled controls would render with the readonly look (0.7/default). */ &:read-only:not([type="file"], :disabled) { background: var(--muted); cursor: default; opacity: 0.7; &:focus { border-color: var(--input); box-shadow: var(--shadow-xs); } } /* Disabled = legible but inert: a muted surface and muted text inside the full-strength border - never an opacity fade that lets the box dissolve into the page. */ &:disabled { background-color: var(--muted); color: var(--muted-foreground); box-shadow: none; cursor: not-allowed; } /* -- Invalid state --------------------------------------- */ /* WHEN a field turns red: aria-invalid="true" always (the page decided). The browser's own verdict (:user-invalid, i.e. after the user left a changed field) only counts inside a <form> - a standalone text box has nothing to submit, so a half-typed value is never judged - and inside form[data-validate="submit"] only once the page has stamped data-submitted on the form (a submit attempt): check when the person is finished, not the instant they leave the box. */ &:is([aria-invalid="true"], :user-invalid:is(form *):not(form[data-validate="submit"]:not([data-submitted]) *)) { border-color: var(--destructive); &:focus { border-color: var(--destructive); box-shadow: 0 0 0 2px oklch(from var(--destructive) l c h / 0.2); } } /* -- File input ------------------------------------------ */ &[type="file"] { padding: 0; font-size: 0.875rem; &::file-selector-button { height: 100%; border: none; border-right: 1px solid var(--input); background: var(--muted); color: var(--foreground); font-size: 0.875rem; font-weight: 500; font-family: var(--font-sans); padding: 0 0.75rem; margin-right: 0.75rem; cursor: pointer; transition: background 150ms; &:hover { background: var(--accent); } } } /* -- Native in-field buttons ------------------------------ The clear "×" of type="search" and the calendar/clock icon of the date/time types are clickable controls INSIDE the text field - without this they inherit the field's text cursor and look like more text to type into (the file picker's button above already points). */ &::-webkit-search-cancel-button, &::-webkit-calendar-picker-indicator { cursor: pointer; } /* -- Sizes ----------------------------------------------- */ &[data-size="xs"] { height: 1.75rem; padding: 0 0.5rem; font-size: 0.75rem; } &[data-size="sm"] { height: 2rem; padding: 0 0.625rem; font-size: 0.8125rem; } &[data-size="md"] { height: 2.25rem; padding: 0 0.75rem; font-size: 0.875rem; } &[data-size="lg"] { height: 2.75rem; padding: 0 1rem; font-size: 1rem; } &[data-size="xl"] { height: 3.25rem; padding: 0 1.25rem; font-size: 1.125rem; } } /* -- Input group: icons, text and buttons inside the field --- The GROUP draws the field frame (border, radius, shadow, focus ring, invalid / disabled / readonly surfaces); the .input inside goes frameless and fills the rest. DOM order is the placement: an addon before the .input sits at the start, one after it at the end - so a trailing copy / show-password / clear button is just markup after the input, and any addon width works without hand-tuned padding. */ .input-group { box-sizing: border-box; display: flex; align-items: center; width: 100%; height: 2.25rem; border: 1px solid var(--input); border-radius: var(--radius-md); background: var(--background); box-shadow: var(--shadow-xs); color: var(--muted-foreground); font-size: 0.875rem; transition: border-color 150ms, box-shadow 150ms; & > .input { flex: 1; min-width: 0; height: 100%; border: none; border-radius: inherit; background: transparent; box-shadow: none; font-size: inherit; &:focus { box-shadow: none; } &:not(:first-child) { padding-inline-start: 0.5rem; } &:not(:last-child) { padding-inline-end: 0.5rem; } } &:has(> .input:focus) { border-color: var(--ring); box-shadow: 0 0 0 2px oklch(from var(--ring) l c h / 0.2); } &:has(> .input:is([aria-invalid="true"], :user-invalid:is(form *):not(form[data-validate="submit"]:not([data-submitted]) *))) { border-color: var(--destructive); &:has(> .input:focus) { box-shadow: 0 0 0 2px oklch(from var(--destructive) l c h / 0.2); } } /* readonly: the group carries the muted surface (the input's own readonly background would stop short of the addons) */ &:has(> .input:read-only:not(:disabled)) { background: var(--muted); & > .input { background: transparent; } &:has(> .input:focus) { border-color: var(--input); box-shadow: var(--shadow-xs); } } &:has(> .input:disabled) { background: var(--muted); box-shadow: none; cursor: not-allowed; & > .input { background: transparent; } } /* decorative icon (inline <svg> or lucide <i>) */ & > .input-group-icon { flex-shrink: 0; width: 1rem; height: 1rem; color: var(--muted-foreground); pointer-events: none; &:first-child { margin-inline-start: 0.75rem; } &:last-child { margin-inline-end: 0.75rem; } } /* static text: a unit, a protocol, a domain */ & > .input-group-text { flex-shrink: 0; white-space: nowrap; color: var(--muted-foreground); user-select: none; &:first-child { margin-inline-start: 0.75rem; } &:last-child { margin-inline-end: 0.75rem; } } /* an in-field action: copy, show/hide, clear, open a picker */ & > .input-group-button { flex-shrink: 0; display: inline-grid; place-items: center; box-sizing: border-box; height: calc(100% - 0.5rem); aspect-ratio: 1; padding: 0; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--muted-foreground); cursor: pointer; transition: background-color 150ms, color 150ms; & svg { width: 1rem; height: 1rem; } /* icon swaps (copy -> check, eye -> eye-off): the UA's [hidden] rule only covers HTML elements, so a hidden <svg> needs its own */ & svg[hidden] { display: none; } &:hover { background: var(--accent); color: var(--accent-foreground); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; } &:disabled { cursor: not-allowed; background: transparent; color: var(--muted-foreground); } &:first-child { margin-inline-start: 0.25rem; } &:last-child { margin-inline-end: 0.25rem; } & + .input-group-button { margin-inline-start: 0.125rem; } } /* -- Sizes: the same ladder as .input, set on the group ------ */ &[data-size="xs"] { height: 1.75rem; font-size: 0.75rem; } &[data-size="sm"] { height: 2rem; font-size: 0.8125rem; } &[data-size="md"] { height: 2.25rem; font-size: 0.875rem; } &[data-size="lg"] { height: 2.75rem; font-size: 1rem; } &[data-size="xl"] { height: 3.25rem; font-size: 1.125rem; } } /* -- Auto-growing textarea --------------------------------- */ textarea.input { field-sizing: content; min-height: 5rem; padding-top: 0.5rem; padding-bottom: 0.5rem; } /* -- Field description ------------------------------------- */ .field-description { font-size: 0.8125rem; color: var(--muted-foreground); margin: 0.375rem 0 0; } /* -- Field error ------------------------------------------- */ .field-error { font-size: 0.8125rem; color: var(--destructive); margin: 0.375rem 0 0; } /* -- Accessibility ---------------------------------------- */ @media (prefers-reduced-motion: reduce) { .input { transition: none; } .input[type="file"]::file-selector-button { transition: none; } } @media (prefers-contrast: more) { .input { border-width: 2px; } .input:disabled { border-color: var(--muted-foreground); } } @media (forced-colors: active) { .input { border-color: ButtonBorder; color: ButtonText; background: Field; } .input:focus { outline: 2px solid Highlight; outline-offset: 1px; } .input:disabled { opacity: 1; color: GrayText; border-color: GrayText; } .input:is([aria-invalid="true"], :user-invalid:is(form *):not(form[data-validate="submit"]:not([data-submitted]) *)) { border-color: LinkText; } .field-error { color: LinkText; } .input-group { border-color: ButtonBorder; background: Field; & > .input:focus { outline: none; } &:has(> .input:focus) { outline: 2px solid Highlight; outline-offset: 1px; } &:has(> .input:disabled) { border-color: GrayText; } & > .input-group-button { color: ButtonText; } } }}Comments, ideas or improvements? Edit this page's source on GitHub