Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
API navigation

API

Input group

An input with addons on any side.

Installation

pnpm dlx zazz-ui add input-group
npx zazz-ui add input-group
yarn dlx zazz-ui add input-group
bunx zazz-ui add input-group

Also 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

input-group attributes
AttributeKindValuesNotes
data-ui="input-group"Identityfree valueIdentity token in data-ui.
data-input-group-alignPresetblock-end block-start inline-end inline-startPreset: a named bundle of values.
data-input-group-slotSlotaddon textSlot: 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.

input-group hooks
HookDefaultState
--ui-input-group-cursortext—
--ui-input-group-displayflex—
--ui-input-group-flex-wrapwrap—
--ui-input-group-font-sizevar(--font-size-sm)—
--ui-input-group-font-weightvar(--font-weight-strong)—
--ui-input-group-gapvar(--spacing)—
--ui-input-group-itemscenter—
--ui-input-group-min-hvar(--ui-field-h)—
--ui-input-group-p0—
--ui-input-group-shadow0 0 transparent—
--ui-input-group-textvar(--color-muted-foreground)—
--ui-input-group-textarea-min-h3lh—
--ui-input-group-textarea-pxcalc(var(--spacing) * 2)—
--ui-input-group-textarea-pycalc(var(--spacing) * 1.5)—
--ui-input-group-w100%—