Input Group Compound

A focusable input or textarea wrapped in a single pill alongside leading and trailing addons (icons, prefix text, kbd hints, action buttons). The Root paints the border, focus ring, and invalid state; each Addon and the control plug in as compound parts. Distinct from InputGrid — which is an N × M matrix of standalone inputs.

Preview
$
USD
0 characters

Installation

npx shadcn@latest add https://craftbits.dev/r/input-group-compound.json

Usage

import { InputGroupCompound } from "@craft-bits/core";
 
<InputGroupCompound.Root>
  <InputGroupCompound.Addon align="inline-start">
    <InputGroupCompound.Text>$</InputGroupCompound.Text>
  </InputGroupCompound.Addon>
  <InputGroupCompound.Input
    inputMode="decimal"
    placeholder="0.00"
    aria-label="Amount"
  />
  <InputGroupCompound.Addon align="inline-end">
    <InputGroupCompound.Text>USD</InputGroupCompound.Text>
  </InputGroupCompound.Addon>
</InputGroupCompound.Root>

Search field with a trailing action button:

<InputGroupCompound.Root>
  <InputGroupCompound.Addon align="inline-start">
    <SearchIcon />
  </InputGroupCompound.Addon>
  <InputGroupCompound.Input placeholder="Search..." aria-label="Search" />
  <InputGroupCompound.Addon align="inline-end">
    <InputGroupCompound.Button variant="ghost" size="xs" onClick={clear}>
      Clear
    </InputGroupCompound.Button>
  </InputGroupCompound.Addon>
</InputGroupCompound.Root>

Textarea with a block-aligned action bar:

<InputGroupCompound.Root>
  <InputGroupCompound.Textarea rows={3} placeholder="Leave a note..." aria-label="Note" />
  <InputGroupCompound.Addon align="block-end">
    <InputGroupCompound.Text>0 characters</InputGroupCompound.Text>
    <InputGroupCompound.Button variant="primary" size="sm">Send</InputGroupCompound.Button>
  </InputGroupCompound.Addon>
</InputGroupCompound.Root>

Understanding the component

  1. Compound layout. Root is a flex container with role="group". Addon slots align via data-align and reorder with order-first / order-last. The control (Input or Textarea) sits in the middle and grows to fill the remaining space.
  2. State propagation via :has(). The Root carries no React state. Focus, invalid, and disabled styles propagate from the inner control via CSS :has([data-slot=input-group-control]:focus-visible) and :has([data-slot=input-group-control][aria-invalid=true]). This keeps the API a thin DOM compound — no provider, no prop drilling.
  3. Click-to-focus. Clicking idle space inside an Addon focuses the sibling control. Button descendants short-circuit this so their own click handler fires unchanged.
  4. Block alignment. align="block-start" / "block-end" stack the addon above or below the control (separated by a hairline rule) and flip the Root to a vertical flex container. Useful for character counters, action toolbars below a textarea, or attachment chips.
  5. Sizing. Root controls the surface height via size (sm / md / lg). The Textarea variant ignores the height token and lets rows drive size.

Props

InputGroupCompound.Root

PropTypeDefaultDescription
size'sm' | 'md' | 'lg''md'Container height + radius token. Ignored when wrapping a Textarea.
tone'default' | 'muted''default'Surface color. muted blends with bg-cb-bg-muted containers.
disabledbooleanfalseDisable the whole group. Fades the surface and prevents click-to-focus.
classNamestringMerged onto the rendered root <div> via cn().

InputGroupCompound.Addon

PropTypeDefaultDescription
align'inline-start' | 'inline-end' | 'block-start' | 'block-end''inline-start'Position relative to the control. Block alignments stack vertically.
classNamestringMerged onto the addon root <div>.

InputGroupCompound.Input / InputGroupCompound.Textarea

Forward every native attribute (value, onChange, placeholder, disabled, aria-invalid, …). The control is tagged with data-slot="input-group-control" so the Root can react to focus/invalid state.

InputGroupCompound.Button

PropTypeDefaultDescription
variant'ghost' | 'primary' | 'outline''ghost'Visual treatment. Ghost recedes into the addon until hovered.
size'xs' | 'sm' | 'icon-xs' | 'icon-sm''xs'Compact heights tuned to fit inside a sm/md Root. icon-* drops padding for square icon-only buttons.
type'button' | 'submit' | 'reset''button'Defaults to 'button' so it never submits an enclosing form by accident.

InputGroupCompound.Text

A simple <span> slot styled with the muted foreground color. Use for static prefixes / suffixes ($, https://, /month).

Accessibility

  • The Root renders role="group" so screen readers announce the cluster as a single labeled unit. Always pass a label on the control (aria-label or an outer <label>).
  • Focus styles are driven by :focus-visible on the inner control and surfaced on the Root via :has(). Focus ring uses the --cb-accent token at 2px with a 30% halo so it stays visible against muted surfaces.
  • Setting aria-invalid on the control flips the Root border + ring to --cb-error. Pair with Field.Error (from @craft-bits/core) for the announcement message.
  • disabled cascades visually via data-disabled="true" on the Root and opacity-50 on addons; the actual disabled attribute must still be set on the inner control for keyboard skipping.
  • The Addon's click-to-focus handler skips real <button> descendants so action buttons retain their own click semantics.
  • Native <input> autocomplete, autofill, IME composition, copy/paste, and form submission all flow through unchanged — the compound never wraps the control in a portal or shadow DOM.

Credits

  • Extracted from: algoflashcards (src/platform/ui/input-group.tsx). The source was a shadcn-style flat module that imported the project's Input, Button, and Textarea chrome. The craft-bits extraction generalizes it into a self-contained Radix-style compound, replaces project tokens with cb-* semantic tokens, exposes typed Variant/Align/Size unions on every part, and adds tone, block-start/block-end alignment, and explicit disabled cascading.