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.jsonUsage
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
- Compound layout.
Rootis a flex container withrole="group".Addonslots align viadata-alignand reorder withorder-first/order-last. The control (InputorTextarea) sits in the middle and grows to fill the remaining space. - 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. - Click-to-focus. Clicking idle space inside an
Addonfocuses the sibling control.Buttondescendants short-circuit this so their own click handler fires unchanged. - 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. - Sizing.
Rootcontrols the surface height viasize(sm/md/lg). TheTextareavariant ignores the height token and letsrowsdrive size.
Props
InputGroupCompound.Root
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
disabled | boolean | false | Disable the whole group. Fades the surface and prevents click-to-focus. |
className | string | — | Merged onto the rendered root <div> via cn(). |
InputGroupCompound.Addon
| Prop | Type | Default | Description |
|---|---|---|---|
align | 'inline-start' | 'inline-end' | 'block-start' | 'block-end' | 'inline-start' | Position relative to the control. Block alignments stack vertically. |
className | string | — | Merged 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
| Prop | Type | Default | Description |
|---|---|---|---|
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-labelor an outer<label>). - Focus styles are driven by
:focus-visibleon the inner control and surfaced on the Root via:has(). Focus ring uses the--cb-accenttoken at 2px with a 30% halo so it stays visible against muted surfaces. - Setting
aria-invalidon the control flips the Root border + ring to--cb-error. Pair withField.Error(from@craft-bits/core) for the announcement message. disabledcascades visually viadata-disabled="true"on the Root andopacity-50on addons; the actualdisabledattribute 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'sInput,Button, andTextareachrome. The craft-bits extraction generalizes it into a self-contained Radix-style compound, replaces project tokens withcb-*semantic tokens, exposes typed Variant/Align/Size unions on every part, and addstone,block-start/block-endalignment, and explicitdisabledcascading.