Components
Input group
An input with attached addons (icons, labels, or buttons) on any side.
An .input-group wraps an .input with .input-group__addon elements. Place an addon with
data-align: inline (leading or trailing) or block (above or below).
Default
Loading components…
<div class="field"> <label class="field__label" for="f-search">Search</label> <label class="input-group"> <input class="input" id="f-search" type="search" name="q" inputmode="search" placeholder="Search…" autocomplete="off" spellcheck="false" enterkeyhint="search" aria-describedby="f-search-hint" /> <span class="input-group__addon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"> <rect width="256" height="256" fill="none" /> <circle cx="112" cy="112" r="80" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> <line x1="168.57" y1="168.57" x2="224" y2="224" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> </svg> </span> <span class="input-group__addon" data-align="inline-end"> <button class="button" data-variant="ghost" data-size="icon-sm" type="submit" aria-label="Submit search" > <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"> <rect width="256" height="256" fill="none" /> <line x1="40" y1="128" x2="216" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> <polyline points="144 56 216 128 144 200" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> </svg> </button> </span> </label> <div class="field__description"> <span class="field__hint" id="f-search-hint">Search across every page in the docs.</span> </div></div><div class="field"> <label class="field__label" for="f-url">Website</label> <label class="input-group"> <input class="input" id="f-url" type="url" name="url" inputmode="url" required placeholder="zazz.design" autocomplete="url" autocapitalize="off" spellcheck="false" enterkeyhint="go" aria-describedby="f-url-hint" /> <span class="input-group__addon"> <span class="input-group__text">https://</span> </span> <span class="input-group__addon" data-align="inline-end"> <button class="button" data-variant="primary" data-size="sm" type="submit">Apply</button> </span> </label> <div class="field__description"> <span class="field__hint" id="f-url-hint">Where people can find you online.</span> <span class="field__error" role="alert">Enter a valid URL, e.g. zazz.design.</span> </div></div><div class="field"> <label class="field__label" for="f-prompt">Prompt</label> <label class="input-group"> <textarea id="f-prompt" class="textarea" name="prompt" inputmode="text" placeholder="Ask anything…" aria-describedby="f-prompt-hint" ></textarea> <span class="input-group__addon" data-align="block-end"> <button class="button bg-background ml-auto" data-size="sm" type="submit"> <span>Send</span> <kbd>↵</kbd> </button> </span> </label> <div class="field__description"> <span class="field__hint" id="f-prompt-hint" >Enter to send · Shift + Enter for a new line.</span > </div></div>/** * _input-group.css — Input group (.input-group) * * @layer variables, components * @requires layers.css, _variables.css, _fields.css, _input.css, * _textarea.css, _button.css * @uses :focus-within — lifts shell ring/border as one field * @uses :has() — restore padding when addons absent; invalid text color * @uses data-align on .input-group__addon — inline-start/end, block-start/end * @tokens --input-group-* (@layer variables) */@layer variables { :root { --input-group-display: flex; --input-group-wrap: wrap; --input-group-align: center; --input-group-gap: var(--step-1); --input-group-inline-size: 100%; --input-group-min-block-size: var(--field-height); --input-group-padding: 0; --input-group-cursor: text; --input-group-text-color: var(--muted-foreground); --input-group-text-weight: var(--font-weight-strong); --input-group-text-size: var(--font-size-sm); --input-group-textarea-min-block-size: 3lh; --input-group-textarea-padding-inline: var(--step-2); --input-group-textarea-padding-block: var(--step-1_5); --input-group-shadow: 0 0 transparent; }}@layer zazz.components { /* =========================================================================== INPUT GROUP — .input-group (shadcn-style composition) - A single bordered shell that fuses a text control with addons (icons, text, kbd) and buttons. The nested control goes borderless/transparent so the group reads as one field; :focus-within lifts the whole shell. - Addons are placed AFTER the control in the markup (for predictable tab/focus order) and positioned with data-align: inline-start (default) · inline-end · block-start · block-end block-* span the full width on their own row — that's how a textarea grows a toolbar above or below it. - Composition: .input-group > [.input | .textarea] + .input-group__addon where an addon may hold .input-group__text, an <svg>, <kbd>, or .button. - The shell is a <label> for click-to-focus, but it is NOT the field's label. Wrap it in a .field with a real .field__label[for] (and optional .field__hint / .field__error) so the control is named by visible text, never by a legend. - Shares the --field-* tokens (height, radius, surface) defined in _fields.css. =========================================================================== */ .input-group { display: var(--input-group-display); flex-wrap: var(--input-group-wrap); align-items: var(--input-group-align); gap: var(--input-group-gap); inline-size: var(--input-group-inline-size); min-block-size: var(--input-group-min-block-size); padding: var(--input-group-padding); /* The shell is a <label>: clicking a non-interactive addon focuses the nested control, so the whole field reads as one clickable target. */ cursor: var(--input-group-cursor); color: var(--field-foreground); background-color: var(--field-background); border: 1px solid var(--field-border); border-radius: var(--field-radius); --_ring-offset-width: 0px; --_ring-width: 0px; --_ring: color-mix(in oklch, var(--button-ring-color) var(--ring-opacity), transparent); box-shadow: 0 0 0 var(--_ring-offset-width) var(--ring-offset-color), 0 0 0 calc(var(--_ring-offset-width) + var(--_ring-width)) var(--_ring), var(--input-group-shadow); outline: var(--outline-width) var(--outline-style) transparent; outline-offset: var(--outline-offset); transition: var(--default-transition); } .input-group:hover { background-color: var(--field-background--hover); border-color: var(--field-border--hover); } .input-group:focus-within { background-color: var(--field-background--focus); border-color: var(--field-border--focus); --_ring-offset-width: var(--ring-offset-width); --_ring-width: var(--ring-width); outline-color: transparent; } /* Nested control sheds its own chrome and fills the row */ .input-group .input, .input-group .textarea { flex: 1; min-inline-size: 0; background-color: transparent; border: none; border-radius: 0; outline: none; box-shadow: none; } .input-group .input { flex: 1; padding-inline: 0; &:is(:hover, :active, :focus) { background-color: transparent; } } /* Restore the control's own inline padding on whichever edge has no addon, so text never sits flush against the shell. An addon already supplies the spacing on its side; block-* addons take their own row and don't count here. (Default/no-data-align addons are inline-start.) */ .input-group:not( :has(.input-group__addon:not([data-align]), .input-group__addon[data-align="inline-start"]) ) .input { padding-inline-start: var(--field-padding); } .input-group:not(:has(.input-group__addon[data-align="inline-end"])) .input { padding-inline-end: var(--field-padding); } /* A textarea claims the full width; addons stack above/below via block-* */ .input-group .textarea { flex-basis: 100%; min-block-size: var(--input-group-textarea-min-block-size); padding-inline: var(--input-group-textarea-padding-inline); padding-block: var(--input-group-textarea-padding-block); resize: none; } /* Addon — groups icons, text, kbd, or buttons together */ .input-group__addon { display: flex; align-items: center; justify-content: center; flex-shrink: 0; gap: var(--step-1); color: var(--muted-foreground); white-space: nowrap; block-size: var(--field-height); min-inline-size: var(--field-height); padding-inline: var(--field-addon-padding); } .input-group__addon > svg { inline-size: var(--field-icon-size); block-size: var(--field-icon-size); } /* Alignment — inline-start is the default (addon visually leads the control) */ .input-group__addon:not([data-align]), .input-group__addon[data-align="inline-start"] { order: -1; } .input-group__addon[data-align="inline-end"] { order: 1; } .input-group__addon[data-align="block-start"] { order: -2; flex-basis: 100%; padding-block: var(--field-addon-padding); align-items: flex-start; } .input-group__addon[data-align="block-end"] { order: 2; flex-basis: 100%; padding-block: var(--field-addon-padding); align-items: flex-end; } /* Text addon — units, protocols, @handles ("https://", "USD", "@user") */ .input-group__text { display: inline-flex; align-items: center; gap: var(--gap-xs); padding-inline: var(--field-addon-padding); font-size: var(--input-group-text-size); font-weight: var(--input-group-text-weight); color: var(--input-group-text-color); min-inline-size: max-content; } .input-group__text > svg { inline-size: var(--field-icon-size); block-size: var(--field-icon-size); } /* Buttons embedded in a group shrink to nest cleanly inside the shell */ .input-group .button { flex-shrink: 0; border-radius: var(--field-button-radius); } :where(.field:has(:user-invalid)) .input-group__text { --input-group-text-color: var(--destructive); }}Password group
The password-group example wraps the field in <input-password>, which adds the show/hide
toggle behavior and exposes the component script in the JS tab.
Loading components…
<div class="field"> <label class="field__label" for="f-login-password">Password</label> <input-password> <label class="password-group"> <input class="input" id="f-login-password" name="password" type="password" autocomplete="new-password" minlength="8" required placeholder="••••••••" aria-describedby="f-login-password-hint f-login-password-warning" /> <span class="password-group__addon" data-align="inline-end"> <button class="button password-group__toggle" id="f-login-password-toggle" data-variant="ghost" data-size="icon-sm" type="button" aria-pressed="false" aria-label="Show password" aria-describedby="f-login-password-warning" > <svg class="password-group__icon password-group__icon--show" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true" > <rect width="256" height="256" fill="none" /> <path d="M128,56C48,56,16,128,16,128s32,72,112,72,112-72,112-72S208,56,128,56Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> <circle cx="128" cy="128" r="40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> </svg> <svg class="password-group__icon password-group__icon--hide" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true" > <rect width="256" height="256" fill="none" /> <line x1="48" y1="40" x2="208" y2="216" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> <path d="M154.9,157.6A40,40,0,0,1,101,98.4" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> <path d="M73.8,69.7C33.6,90.6,16,128,16,128s32,72,112,72a118.1,118.1,0,0,0,54.1-12.8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> <path d="M208.6,169.1C229.8,149.1,240,128,240,128S208,56,128,56a126,126,0,0,0-20.5,1.6" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> </svg> </button> </span> </label> </input-password> <div class="field__description"> <span class="field__hint" id="f-login-password-hint">Use eight or more characters.</span> <span class="field__error" role="alert">Password must be at least 8 characters.</span> </div> <span class="sr-only" id="f-login-password-warning" >Warning: showing the password makes it visible to anyone near your screen.</span ></div>// password.js"use strict";/** * @fileoverview `<input-password>` — HTML web component for password visibility. * @description Light-DOM custom element that adds show/hide behavior to a * standard password field. Wrap the existing `.password-group` markup — the * element finds the input and the `.password-group__toggle` button, flips the * input between `type="password"` and `type="text"` on click, and keeps * `aria-pressed` and `aria-label` in sync. The icon swap is pure CSS, driven * by `aria-pressed` (see _password-group.css). * * Without JavaScript the field degrades to a regular password input; the * toggle button simply does nothing. * * Configuration (attributes on `<input-password>`): * - `label-show`: Toggle label while the password is hidden (default "Show password"). * - `label-hide`: Toggle label while the password is visible (default "Hide password"). * * @example * <input-password> * <label class="password-group"> * <input class="input" type="password" autocomplete="current-password" /> * <span class="password-group__addon" data-align="inline-end"> * <button class="button password-group__toggle" type="button" * aria-pressed="false" aria-label="Show password">…</button> * </span> * </label> * </input-password> */class InputPassword extends HTMLElement { /** @type {AbortController|null} */ #controller = null; connectedCallback() { if (this.#controller) return; const input = this.querySelector('input[type="password"], input[type="text"]'); const toggle = this.querySelector(".password-group__toggle"); if (!(input instanceof HTMLInputElement) || !(toggle instanceof HTMLElement)) return; this.#controller = new AbortController(); toggle.addEventListener( "click", () => { const reveal = input.type === "password"; input.type = reveal ? "text" : "password"; toggle.setAttribute("aria-pressed", String(reveal)); toggle.setAttribute( "aria-label", reveal ? this.getAttribute("label-hide") || "Hide password" : this.getAttribute("label-show") || "Show password", ); }, { signal: this.#controller.signal }, ); } disconnectedCallback() { this.#controller?.abort(); this.#controller = null; }}// Register the element (guarded against double script loads)if (typeof window !== "undefined" && !customElements.get("input-password")) { customElements.define("input-password", InputPassword);}// Attach to window for parity with the other component scripts, and export for// module consumers (loaded for its side effect — the custom-element registration).if (typeof window !== "undefined") { window.InputPassword = InputPassword;}export { InputPassword };/** * _input-group.css — Input group (.input-group) * * @layer variables, components * @requires layers.css, _variables.css, _fields.css, _input.css, * _textarea.css, _button.css * @uses :focus-within — lifts shell ring/border as one field * @uses :has() — restore padding when addons absent; invalid text color * @uses data-align on .input-group__addon — inline-start/end, block-start/end * @tokens --input-group-* (@layer variables) */@layer variables { :root { --input-group-display: flex; --input-group-wrap: wrap; --input-group-align: center; --input-group-gap: var(--step-1); --input-group-inline-size: 100%; --input-group-min-block-size: var(--field-height); --input-group-padding: 0; --input-group-cursor: text; --input-group-text-color: var(--muted-foreground); --input-group-text-weight: var(--font-weight-strong); --input-group-text-size: var(--font-size-sm); --input-group-textarea-min-block-size: 3lh; --input-group-textarea-padding-inline: var(--step-2); --input-group-textarea-padding-block: var(--step-1_5); --input-group-shadow: 0 0 transparent; }}@layer zazz.components { /* =========================================================================== INPUT GROUP — .input-group (shadcn-style composition) - A single bordered shell that fuses a text control with addons (icons, text, kbd) and buttons. The nested control goes borderless/transparent so the group reads as one field; :focus-within lifts the whole shell. - Addons are placed AFTER the control in the markup (for predictable tab/focus order) and positioned with data-align: inline-start (default) · inline-end · block-start · block-end block-* span the full width on their own row — that's how a textarea grows a toolbar above or below it. - Composition: .input-group > [.input | .textarea] + .input-group__addon where an addon may hold .input-group__text, an <svg>, <kbd>, or .button. - The shell is a <label> for click-to-focus, but it is NOT the field's label. Wrap it in a .field with a real .field__label[for] (and optional .field__hint / .field__error) so the control is named by visible text, never by a legend. - Shares the --field-* tokens (height, radius, surface) defined in _fields.css. =========================================================================== */ .input-group { display: var(--input-group-display); flex-wrap: var(--input-group-wrap); align-items: var(--input-group-align); gap: var(--input-group-gap); inline-size: var(--input-group-inline-size); min-block-size: var(--input-group-min-block-size); padding: var(--input-group-padding); /* The shell is a <label>: clicking a non-interactive addon focuses the nested control, so the whole field reads as one clickable target. */ cursor: var(--input-group-cursor); color: var(--field-foreground); background-color: var(--field-background); border: 1px solid var(--field-border); border-radius: var(--field-radius); --_ring-offset-width: 0px; --_ring-width: 0px; --_ring: color-mix(in oklch, var(--button-ring-color) var(--ring-opacity), transparent); box-shadow: 0 0 0 var(--_ring-offset-width) var(--ring-offset-color), 0 0 0 calc(var(--_ring-offset-width) + var(--_ring-width)) var(--_ring), var(--input-group-shadow); outline: var(--outline-width) var(--outline-style) transparent; outline-offset: var(--outline-offset); transition: var(--default-transition); } .input-group:hover { background-color: var(--field-background--hover); border-color: var(--field-border--hover); } .input-group:focus-within { background-color: var(--field-background--focus); border-color: var(--field-border--focus); --_ring-offset-width: var(--ring-offset-width); --_ring-width: var(--ring-width); outline-color: transparent; } /* Nested control sheds its own chrome and fills the row */ .input-group .input, .input-group .textarea { flex: 1; min-inline-size: 0; background-color: transparent; border: none; border-radius: 0; outline: none; box-shadow: none; } .input-group .input { flex: 1; padding-inline: 0; &:is(:hover, :active, :focus) { background-color: transparent; } } /* Restore the control's own inline padding on whichever edge has no addon, so text never sits flush against the shell. An addon already supplies the spacing on its side; block-* addons take their own row and don't count here. (Default/no-data-align addons are inline-start.) */ .input-group:not( :has(.input-group__addon:not([data-align]), .input-group__addon[data-align="inline-start"]) ) .input { padding-inline-start: var(--field-padding); } .input-group:not(:has(.input-group__addon[data-align="inline-end"])) .input { padding-inline-end: var(--field-padding); } /* A textarea claims the full width; addons stack above/below via block-* */ .input-group .textarea { flex-basis: 100%; min-block-size: var(--input-group-textarea-min-block-size); padding-inline: var(--input-group-textarea-padding-inline); padding-block: var(--input-group-textarea-padding-block); resize: none; } /* Addon — groups icons, text, kbd, or buttons together */ .input-group__addon { display: flex; align-items: center; justify-content: center; flex-shrink: 0; gap: var(--step-1); color: var(--muted-foreground); white-space: nowrap; block-size: var(--field-height); min-inline-size: var(--field-height); padding-inline: var(--field-addon-padding); } .input-group__addon > svg { inline-size: var(--field-icon-size); block-size: var(--field-icon-size); } /* Alignment — inline-start is the default (addon visually leads the control) */ .input-group__addon:not([data-align]), .input-group__addon[data-align="inline-start"] { order: -1; } .input-group__addon[data-align="inline-end"] { order: 1; } .input-group__addon[data-align="block-start"] { order: -2; flex-basis: 100%; padding-block: var(--field-addon-padding); align-items: flex-start; } .input-group__addon[data-align="block-end"] { order: 2; flex-basis: 100%; padding-block: var(--field-addon-padding); align-items: flex-end; } /* Text addon — units, protocols, @handles ("https://", "USD", "@user") */ .input-group__text { display: inline-flex; align-items: center; gap: var(--gap-xs); padding-inline: var(--field-addon-padding); font-size: var(--input-group-text-size); font-weight: var(--input-group-text-weight); color: var(--input-group-text-color); min-inline-size: max-content; } .input-group__text > svg { inline-size: var(--field-icon-size); block-size: var(--field-icon-size); } /* Buttons embedded in a group shrink to nest cleanly inside the shell */ .input-group .button { flex-shrink: 0; border-radius: var(--field-button-radius); } :where(.field:has(:user-invalid)) .input-group__text { --input-group-text-color: var(--destructive); }}API
| Attribute | Where | Values |
|---|---|---|
data-align | .input-group__addon | inline-start, inline-end, block-start, block-end |