API
Input group
An input with addons on any side.
Installation
pnpm dlx zazz-ui add input-groupnpx zazz-ui add input-groupyarn dlx zazz-ui add input-groupbunx zazz-ui add input-groupAlso adds fields, kbd, button, input, textarea, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.
Examples
Default
Search across every page in the docs.
Where people can find you online.
Enter a valid URL, e.g. https://zazz.sh.
Enter to send · Shift + Enter for a new line.
<div data-ui="field">
<label data-field-slot="label" for="f-search">Search</label>
<label data-ui="input-group">
<input
data-ui="input"
id="f-search"
type="search"
name="q"
inputmode="search"
placeholder="Search…"
autocomplete="off"
spellcheck="false"
enterkeyhint="search"
aria-describedby="f-search-hint"
/>
<span data-input-group-slot="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 data-input-group-slot="addon" data-input-group-align="inline-end">
<button
data-ui="button"
data-button-variant="ghost"
data-button-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 data-field-slot="description">
<span data-field-slot="hint" id="f-search-hint">Search across every page in the docs.</span>
</div>
</div>
<div data-ui="field">
<label data-field-slot="label" for="f-url">Website</label>
<label data-ui="input-group">
<input
data-ui="input"
id="f-url"
type="url"
name="url"
inputmode="url"
required
placeholder="https://zazz.sh"
autocomplete="url"
autocapitalize="off"
spellcheck="false"
enterkeyhint="go"
aria-describedby="f-url-hint"
/>
<span data-input-group-slot="addon">
<span data-input-group-slot="text">https://</span>
</span>
<span data-input-group-slot="addon" data-input-group-align="inline-end">
<button data-ui="button" data-button-variant="primary" data-button-size="sm" type="submit">
Apply
</button>
</span>
</label>
<div data-field-slot="description">
<span data-field-slot="hint" id="f-url-hint">Where people can find you online.</span>
<span data-field-slot="error" role="alert">Enter a valid URL, e.g. https://zazz.sh.</span>
</div>
</div>
<div data-ui="field">
<label data-field-slot="label" for="f-prompt">Prompt</label>
<label data-ui="input-group">
<textarea
id="f-prompt"
data-ui="textarea"
name="prompt"
inputmode="text"
placeholder="Ask anything…"
aria-describedby="f-prompt-hint"
></textarea>
<span data-input-group-slot="addon" data-input-group-align="block-end">
<button
data-ui="button"
data-button-size="sm"
type="submit"
style="
--ml: auto;
--bg: var(--color-background);
--bg--active: var(--color-muted);
--bg--hover: var(--color-muted);
"
>
<span>Send</span> <kbd>↵</kbd>
</button>
</span>
</label>
<div data-field-slot="description">
<span data-field-slot="hint" id="f-prompt-hint"
>Enter to send · Shift + Enter for a new line.</span
>
</div>
</div>/**
* input-group.css — Input group ([data-ui~="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-input-group-align on the addon slot — inline-start/end, block-start/end
* @tokens --ui-input-group-* (@layer variables)
* @presets data-input-group-align="inline-start | inline-end | block-start | block-end" on the addon slot
*/
@layer variables {
:root {
--ui-input-group-display: flex;
--ui-input-group-flex-wrap: wrap;
--ui-input-group-items: center;
--ui-input-group-gap: var(--spacing);
--ui-input-group-w: 100%;
--ui-input-group-min-h: var(--ui-field-h);
--ui-input-group-p: 0;
--ui-input-group-cursor: text;
--ui-input-group-text: var(--color-muted-foreground);
--ui-input-group-font-weight: var(--font-weight-strong);
--ui-input-group-font-size: var(--font-size-sm);
--ui-input-group-textarea-min-h: 3lh;
--ui-input-group-textarea-px: calc(var(--spacing) * 2);
--ui-input-group-textarea-py: calc(var(--spacing) * 1.5);
--ui-input-group-shadow: 0 0 transparent;
}
}
@layer ui.components {
/* ===========================================================================
INPUT GROUP — :where([data-ui~="input-group"])
- 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: :where([data-ui~="input-group"]) > [[data-ui~="input"] | [data-ui~="textarea"]] + [data-input-group-slot~="addon"]
where an addon may hold [data-input-group-slot~="text"], an <svg>, <kbd>, or [data-ui~="button"].
- The shell is a <label> for click-to-focus, but it is NOT the field's label.
Wrap it in a data-ui="field" with a real data-field-slot="label"[for] (and optional [data-slot~="field-hint"]
/ data-field-slot="error") so the control is named by visible text, never by a legend.
- Shares the --ui-field-* tokens (height, radius, surface) defined in fields.css.
=========================================================================== */
:where([data-ui~="input-group"]) {
display: var(--_display-resolved, var(--ui-input-group-display));
flex-wrap: var(--_flex-wrap-resolved, var(--ui-input-group-flex-wrap));
align-items: var(--_items-resolved, var(--ui-input-group-items));
--_gap-len: var(--_gap-resolved, var(--ui-input-group-gap));
--_gap-num: var(--_gap-resolved, var(--ui-input-group-gap));
gap: calc(var(--_gap-len) + var(--_gap-num) * var(--spacing));
--_w-len: var(--_w-resolved, var(--ui-input-group-w));
--_w-num: var(--_w-resolved, var(--ui-input-group-w));
inline-size: calc(var(--_w-len) + var(--_w-num) * var(--spacing));
--_min-h-len: var(--_min-h-resolved, var(--ui-input-group-min-h));
--_min-h-num: var(--_min-h-resolved, var(--ui-input-group-min-h));
min-block-size: calc(var(--_min-h-len) + var(--_min-h-num) * var(--spacing));
--_p-len: var(--_p-resolved, var(--ui-input-group-p));
--_p-num: var(--_p-resolved, var(--ui-input-group-p));
padding: calc(var(--_p-len) + var(--_p-num) * var(--spacing));
/* 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(--_cursor-resolved, var(--ui-input-group-cursor));
color: var(--_text-resolved, var(--ui-field-text));
background-color: var(--_bg-resolved, var(--ui-field-bg));
border: 1px solid var(--_border-color-resolved, var(--ui-field-border-color));
border-radius: var(--_rounded-resolved, var(--ui-field-rounded));
--_ring-offset-width: 0px;
--_ring-width: 0px;
--_focus-in: max(0, -1 * sign(var(--_focus-ring-w, 1px)));
--_ring-fill: color-mix(in oklch, var(--ui-field-ring-color) var(--ring-opacity), transparent);
/* The ring is published to --_focus-shadow so a --shadow utility keeps it */
--_focus-shadow:
0 0 0 calc(var(--_ring-offset-width) * (1 - var(--_focus-in))) var(--ring-offset-shadow-color),
0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * (1 - var(--_focus-in)))
var(--_ring-fill, var(--ring-shadow-color)),
inset 0 0 0 calc(var(--_ring-offset-width) * var(--_focus-in)) var(--ring-offset-shadow-color),
inset 0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * var(--_focus-in))
var(--_ring-fill, var(--ring-shadow-color));
box-shadow:
var(--_focus-shadow, 0 0 #0000), var(--_shadow-resolved, var(--ui-input-group-shadow));
outline: abs(var(--_focus-ring-w, var(--outline-width))) var(--outline-style) transparent;
outline-offset: calc(
var(--outline-offset) - var(--_focus-in) *
(2 * var(--outline-offset) + abs(var(--_focus-ring-w, var(--outline-width))))
);
transition: var(--default-transition);
}
@media (hover: hover) {
:where([data-ui~="input-group"]):hover {
background-color: var(--_bg-resolved, var(--ui-field-bg--hover));
border-color: var(--_border-color-resolved, var(--ui-field-border-color--hover));
}
}
:where([data-ui~="input-group"]):focus-within {
background-color: var(--_bg-resolved, var(--ui-field-bg--focus-within));
border-color: var(--_border-color-resolved, var(--ui-field-border-color--focus-within));
--_ring-offset-width: var(--ring-offset-width);
--_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));
outline-color: transparent;
}
/* Nested control sheds its own chrome and fills the row */
:where([data-ui~="input-group"]) [data-ui~="input"],
:where([data-ui~="input-group"]) [data-ui~="textarea"] {
flex: 1;
min-inline-size: 0;
background-color: transparent;
border: none;
border-radius: 0;
outline: none;
box-shadow: none;
}
:where([data-ui~="input-group"]) [data-ui~="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.) */
:where([data-ui~="input-group"]):not(
:has(
[data-input-group-slot~="addon"]:not([data-input-group-align]),
[data-input-group-slot~="addon"][data-input-group-align="inline-start"]
)
)
[data-ui~="input"] {
padding-inline-start: var(--ui-field-px);
}
:where([data-ui~="input-group"]):not(
:has([data-input-group-slot~="addon"][data-input-group-align="inline-end"])
)
[data-ui~="input"] {
padding-inline-end: var(--ui-field-px);
}
/* A textarea claims the full width; addons stack above/below via block-* */
:where([data-ui~="input-group"]) [data-ui~="textarea"] {
flex-basis: 100%;
min-block-size: var(--ui-input-group-textarea-min-h);
padding-inline: var(--ui-input-group-textarea-px);
padding-block: var(--ui-input-group-textarea-py);
resize: none;
}
:where([data-ui~="input-group"]) [data-input-group-slot~="addon"] {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: var(--spacing);
color: var(--color-muted-foreground);
white-space: nowrap;
block-size: var(--ui-field-h);
min-inline-size: var(--ui-field-h);
padding-inline: var(--ui-field-addon-padding);
}
:where([data-ui~="input-group"]) [data-input-group-slot~="addon"] > svg {
inline-size: var(--ui-field-icon-size);
block-size: var(--ui-field-icon-size);
}
/* inline-start is the default alignment */
:where([data-ui~="input-group"]) [data-input-group-slot~="addon"]:not([data-input-group-align]),
:where([data-ui~="input-group"])
[data-input-group-slot~="addon"][data-input-group-align="inline-start"] {
order: -1;
}
:where([data-ui~="input-group"])
[data-input-group-slot~="addon"][data-input-group-align="inline-end"] {
order: 1;
}
:where([data-ui~="input-group"])
[data-input-group-slot~="addon"][data-input-group-align="block-start"] {
order: -2;
flex-basis: 100%;
padding-block: var(--ui-field-addon-padding);
align-items: flex-start;
}
:where([data-ui~="input-group"])
[data-input-group-slot~="addon"][data-input-group-align="block-end"] {
order: 2;
flex-basis: 100%;
padding-block: var(--ui-field-addon-padding);
align-items: flex-end;
}
/* Text addon — units, protocols, @handles ("https://", "USD", "@user") */
:where([data-ui~="input-group"]) [data-input-group-slot~="text"] {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding-inline: var(--ui-field-addon-padding);
font-size: var(--ui-input-group-font-size);
font-weight: var(--ui-input-group-font-weight);
color: var(--ui-input-group-text);
min-inline-size: max-content;
}
:where([data-ui~="input-group"]) [data-input-group-slot~="text"] > svg {
inline-size: var(--ui-field-icon-size);
block-size: var(--ui-field-icon-size);
}
/* Buttons embedded in a group shrink to nest cleanly inside the shell */
:where([data-ui~="input-group"]) [data-ui~="button"] {
flex-shrink: 0;
border-radius: var(--ui-field-inset-child-radius);
}
:where([data-ui~="field"]:has(:user-invalid))
:where([data-ui~="input-group"])
[data-input-group-slot~="text"] {
--ui-input-group-text: var(--color-destructive);
}
}
/* Disabled and committed-invalid states; an invalid control remaps the
--ui-field-* family on itself. */
@layer ui.components {
:where([data-ui~="input-group"]):disabled,
:where([data-ui~="input-group"]):has(:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
:where([data-ui~="input-group"]):has(:user-invalid) {
--ui-field-bg: color-mix(in oklch, var(--color-destructive) 5%, var(--color-input));
--ui-field-bg--hover: color-mix(in oklch, var(--color-destructive) 10%, var(--color-input));
--ui-field-border-color: var(--color-destructive);
--ui-field-border-color--hover: var(--color-destructive);
--ui-field-border-color--focus-within: var(--color-destructive);
--ui-field-ring-color: var(--color-destructive);
border-color: var(--color-destructive);
}
}Password group
Use eight or more characters.
Password must be at least 8 characters.
Warning: showing the password makes it visible to anyone near your screen.
<div data-ui="field">
<label data-field-slot="label" for="f-login-password">Password</label>
<ui-password>
<label data-ui="password-group">
<input
data-ui="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 data-password-group-slot="addon" data-password-group-align="inline-end">
<button
data-ui="button"
data-password-group-slot="toggle"
id="f-login-password-toggle"
data-button-variant="ghost"
data-button-size="icon-sm"
type="button"
aria-pressed="false"
aria-label="Show password"
aria-describedby="f-login-password-warning"
>
<svg
data-password-group-slot="icon 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
data-password-group-slot="icon 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>
</ui-password>
<div data-field-slot="description">
<span data-field-slot="hint" id="f-login-password-hint">Use eight or more characters.</span>
<span data-field-slot="error" role="alert">Password must be at least 8 characters.</span>
</div>
<span data-ui="sr-only" id="f-login-password-warning"
>Warning: showing the password makes it visible to anyone near your screen.</span
>
</div>/**
* input-group.css — Input group ([data-ui~="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-input-group-align on the addon slot — inline-start/end, block-start/end
* @tokens --ui-input-group-* (@layer variables)
* @presets data-input-group-align="inline-start | inline-end | block-start | block-end" on the addon slot
*/
@layer variables {
:root {
--ui-input-group-display: flex;
--ui-input-group-flex-wrap: wrap;
--ui-input-group-items: center;
--ui-input-group-gap: var(--spacing);
--ui-input-group-w: 100%;
--ui-input-group-min-h: var(--ui-field-h);
--ui-input-group-p: 0;
--ui-input-group-cursor: text;
--ui-input-group-text: var(--color-muted-foreground);
--ui-input-group-font-weight: var(--font-weight-strong);
--ui-input-group-font-size: var(--font-size-sm);
--ui-input-group-textarea-min-h: 3lh;
--ui-input-group-textarea-px: calc(var(--spacing) * 2);
--ui-input-group-textarea-py: calc(var(--spacing) * 1.5);
--ui-input-group-shadow: 0 0 transparent;
}
}
@layer ui.components {
/* ===========================================================================
INPUT GROUP — :where([data-ui~="input-group"])
- 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: :where([data-ui~="input-group"]) > [[data-ui~="input"] | [data-ui~="textarea"]] + [data-input-group-slot~="addon"]
where an addon may hold [data-input-group-slot~="text"], an <svg>, <kbd>, or [data-ui~="button"].
- The shell is a <label> for click-to-focus, but it is NOT the field's label.
Wrap it in a data-ui="field" with a real data-field-slot="label"[for] (and optional [data-slot~="field-hint"]
/ data-field-slot="error") so the control is named by visible text, never by a legend.
- Shares the --ui-field-* tokens (height, radius, surface) defined in fields.css.
=========================================================================== */
:where([data-ui~="input-group"]) {
display: var(--_display-resolved, var(--ui-input-group-display));
flex-wrap: var(--_flex-wrap-resolved, var(--ui-input-group-flex-wrap));
align-items: var(--_items-resolved, var(--ui-input-group-items));
--_gap-len: var(--_gap-resolved, var(--ui-input-group-gap));
--_gap-num: var(--_gap-resolved, var(--ui-input-group-gap));
gap: calc(var(--_gap-len) + var(--_gap-num) * var(--spacing));
--_w-len: var(--_w-resolved, var(--ui-input-group-w));
--_w-num: var(--_w-resolved, var(--ui-input-group-w));
inline-size: calc(var(--_w-len) + var(--_w-num) * var(--spacing));
--_min-h-len: var(--_min-h-resolved, var(--ui-input-group-min-h));
--_min-h-num: var(--_min-h-resolved, var(--ui-input-group-min-h));
min-block-size: calc(var(--_min-h-len) + var(--_min-h-num) * var(--spacing));
--_p-len: var(--_p-resolved, var(--ui-input-group-p));
--_p-num: var(--_p-resolved, var(--ui-input-group-p));
padding: calc(var(--_p-len) + var(--_p-num) * var(--spacing));
/* 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(--_cursor-resolved, var(--ui-input-group-cursor));
color: var(--_text-resolved, var(--ui-field-text));
background-color: var(--_bg-resolved, var(--ui-field-bg));
border: 1px solid var(--_border-color-resolved, var(--ui-field-border-color));
border-radius: var(--_rounded-resolved, var(--ui-field-rounded));
--_ring-offset-width: 0px;
--_ring-width: 0px;
--_focus-in: max(0, -1 * sign(var(--_focus-ring-w, 1px)));
--_ring-fill: color-mix(in oklch, var(--ui-field-ring-color) var(--ring-opacity), transparent);
/* The ring is published to --_focus-shadow so a --shadow utility keeps it */
--_focus-shadow:
0 0 0 calc(var(--_ring-offset-width) * (1 - var(--_focus-in))) var(--ring-offset-shadow-color),
0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * (1 - var(--_focus-in)))
var(--_ring-fill, var(--ring-shadow-color)),
inset 0 0 0 calc(var(--_ring-offset-width) * var(--_focus-in)) var(--ring-offset-shadow-color),
inset 0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * var(--_focus-in))
var(--_ring-fill, var(--ring-shadow-color));
box-shadow:
var(--_focus-shadow, 0 0 #0000), var(--_shadow-resolved, var(--ui-input-group-shadow));
outline: abs(var(--_focus-ring-w, var(--outline-width))) var(--outline-style) transparent;
outline-offset: calc(
var(--outline-offset) - var(--_focus-in) *
(2 * var(--outline-offset) + abs(var(--_focus-ring-w, var(--outline-width))))
);
transition: var(--default-transition);
}
@media (hover: hover) {
:where([data-ui~="input-group"]):hover {
background-color: var(--_bg-resolved, var(--ui-field-bg--hover));
border-color: var(--_border-color-resolved, var(--ui-field-border-color--hover));
}
}
:where([data-ui~="input-group"]):focus-within {
background-color: var(--_bg-resolved, var(--ui-field-bg--focus-within));
border-color: var(--_border-color-resolved, var(--ui-field-border-color--focus-within));
--_ring-offset-width: var(--ring-offset-width);
--_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));
outline-color: transparent;
}
/* Nested control sheds its own chrome and fills the row */
:where([data-ui~="input-group"]) [data-ui~="input"],
:where([data-ui~="input-group"]) [data-ui~="textarea"] {
flex: 1;
min-inline-size: 0;
background-color: transparent;
border: none;
border-radius: 0;
outline: none;
box-shadow: none;
}
:where([data-ui~="input-group"]) [data-ui~="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.) */
:where([data-ui~="input-group"]):not(
:has(
[data-input-group-slot~="addon"]:not([data-input-group-align]),
[data-input-group-slot~="addon"][data-input-group-align="inline-start"]
)
)
[data-ui~="input"] {
padding-inline-start: var(--ui-field-px);
}
:where([data-ui~="input-group"]):not(
:has([data-input-group-slot~="addon"][data-input-group-align="inline-end"])
)
[data-ui~="input"] {
padding-inline-end: var(--ui-field-px);
}
/* A textarea claims the full width; addons stack above/below via block-* */
:where([data-ui~="input-group"]) [data-ui~="textarea"] {
flex-basis: 100%;
min-block-size: var(--ui-input-group-textarea-min-h);
padding-inline: var(--ui-input-group-textarea-px);
padding-block: var(--ui-input-group-textarea-py);
resize: none;
}
:where([data-ui~="input-group"]) [data-input-group-slot~="addon"] {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: var(--spacing);
color: var(--color-muted-foreground);
white-space: nowrap;
block-size: var(--ui-field-h);
min-inline-size: var(--ui-field-h);
padding-inline: var(--ui-field-addon-padding);
}
:where([data-ui~="input-group"]) [data-input-group-slot~="addon"] > svg {
inline-size: var(--ui-field-icon-size);
block-size: var(--ui-field-icon-size);
}
/* inline-start is the default alignment */
:where([data-ui~="input-group"]) [data-input-group-slot~="addon"]:not([data-input-group-align]),
:where([data-ui~="input-group"])
[data-input-group-slot~="addon"][data-input-group-align="inline-start"] {
order: -1;
}
:where([data-ui~="input-group"])
[data-input-group-slot~="addon"][data-input-group-align="inline-end"] {
order: 1;
}
:where([data-ui~="input-group"])
[data-input-group-slot~="addon"][data-input-group-align="block-start"] {
order: -2;
flex-basis: 100%;
padding-block: var(--ui-field-addon-padding);
align-items: flex-start;
}
:where([data-ui~="input-group"])
[data-input-group-slot~="addon"][data-input-group-align="block-end"] {
order: 2;
flex-basis: 100%;
padding-block: var(--ui-field-addon-padding);
align-items: flex-end;
}
/* Text addon — units, protocols, @handles ("https://", "USD", "@user") */
:where([data-ui~="input-group"]) [data-input-group-slot~="text"] {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding-inline: var(--ui-field-addon-padding);
font-size: var(--ui-input-group-font-size);
font-weight: var(--ui-input-group-font-weight);
color: var(--ui-input-group-text);
min-inline-size: max-content;
}
:where([data-ui~="input-group"]) [data-input-group-slot~="text"] > svg {
inline-size: var(--ui-field-icon-size);
block-size: var(--ui-field-icon-size);
}
/* Buttons embedded in a group shrink to nest cleanly inside the shell */
:where([data-ui~="input-group"]) [data-ui~="button"] {
flex-shrink: 0;
border-radius: var(--ui-field-inset-child-radius);
}
:where([data-ui~="field"]:has(:user-invalid))
:where([data-ui~="input-group"])
[data-input-group-slot~="text"] {
--ui-input-group-text: var(--color-destructive);
}
}
/* Disabled and committed-invalid states; an invalid control remaps the
--ui-field-* family on itself. */
@layer ui.components {
:where([data-ui~="input-group"]):disabled,
:where([data-ui~="input-group"]):has(:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
:where([data-ui~="input-group"]):has(:user-invalid) {
--ui-field-bg: color-mix(in oklch, var(--color-destructive) 5%, var(--color-input));
--ui-field-bg--hover: color-mix(in oklch, var(--color-destructive) 10%, var(--color-input));
--ui-field-border-color: var(--color-destructive);
--ui-field-border-color--hover: var(--color-destructive);
--ui-field-border-color--focus-within: var(--color-destructive);
--ui-field-ring-color: var(--color-destructive);
border-color: var(--color-destructive);
}
}Attributes
| Attribute | Kind | Values | Notes |
|---|---|---|---|
data-ui="input-group" | Identity | free value | Identity token in data-ui. |
data-input-group-align | Preset | block-end block-start inline-end inline-start | Preset: a named bundle of values. |
data-input-group-slot | Slot | addon text | Slot: a part inside the primitive. |
Requires: fields, input, textarea, button.
Hooks
Set a hook on :root, on a subtree, or inline on one element. A state hook (--…--hover) themes that state only; the primitive keeps its other states.
| Hook | Default | State |
|---|---|---|
--ui-input-group-cursor | text | — |
--ui-input-group-display | flex | — |
--ui-input-group-flex-wrap | wrap | — |
--ui-input-group-font-size | var(--font-size-sm) | — |
--ui-input-group-font-weight | var(--font-weight-strong) | — |
--ui-input-group-gap | var(--spacing) | — |
--ui-input-group-items | center | — |
--ui-input-group-min-h | var(--ui-field-h) | — |
--ui-input-group-p | 0 | — |
--ui-input-group-shadow | 0 0 transparent | — |
--ui-input-group-text | var(--color-muted-foreground) | — |
--ui-input-group-textarea-min-h | 3lh | — |
--ui-input-group-textarea-px | calc(var(--spacing) * 2) | — |
--ui-input-group-textarea-py | calc(var(--spacing) * 1.5) | — |
--ui-input-group-w | 100% | — |