Inputs
Input Group
A composable affix shell for one field — static text, icon slots and interactive trailing controls flush inside a single border, with size, invalid and disabled flowing down to every slot.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/input-group.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "InputGroup" component set: one bordered
shell holding a single field plus whatever must sit flush against it. No
animation library, no Radix — one div, plain children, one React context.
Contract
- Exports: InputGroup, InputGroupAddon, InputGroupInput, InputGroupButton and a
useInputGroup() hook. Composition, not configuration: affix order is DOM order,
so nothing needs a "position" prop.
- InputGroup: forwardRef<HTMLDivElement> extending React.HTMLAttributes<HTMLDivElement>
plus size?: "sm" | "md" | "lg" (default "md"), invalid?: boolean (default false),
disabled?: boolean (default false). Remaining props spread onto the shell.
- Context value { size, disabled, invalid, describedBy, registerAddon, bindInput }.
useInputGroup() exposes only { size, disabled, invalid } for controls the
consumer writes themselves. Every slot reads it through one accessor that
throws "<X> must be used inside <InputGroup>" when the context is missing, so a
stray addon fails at author time instead of rendering unstyled.
- InputGroupAddon: forwardRef<HTMLDivElement>, variant?: "panel" | "inline"
(default "panel"), interactive?: boolean (default false).
- InputGroupInput: forwardRef<HTMLInputElement> with every native input prop
EXCEPT `disabled` — the group owns that state, so there is one mechanism rather
than two that disagree about focus.
- InputGroupButton: forwardRef<HTMLButtonElement>, variant?: "ghost" | "primary"
(default "ghost"), disabled?: boolean, type defaults to "button" and is
overridable so it can be a real submit button.
Behavior — ARIA contract
- The shell has no role. It is a presentational container; an unnamed role="group"
would only add noise to the field's announcement.
- A non-interactive addon is aria-hidden="true" so it is never narrated as loose
text next to the field. It also gets an id (useId, or the consumer's own id),
registers that id with the group, and the group joins the ids into the field's
aria-describedby. An aria-hidden node that is the DIRECT target of
aria-describedby is still used to compute the description, so the reader hears
"https:// … .com" as part of the field and nowhere else. Consumer-supplied
aria-describedby is appended after the affix ids.
- Order the ids by document position (compareDocumentPosition), not by mount
order: a prefix that mounts conditionally must still be read before the suffix.
- interactive addons (a <select>, a link, a custom button) are NOT aria-hidden and
are NOT registered as a description — they speak for themselves and must keep
their own accessible name. aria-hidden over focusable content is invalid ARIA.
- invalid sets aria-invalid on the field (a consumer-supplied aria-invalid wins).
Behavior — disabled without the native attribute
- Never put the native `disabled` attribute on a control the reader may be
standing on: the browser blurs it the instant it becomes disabled and focus
falls back to <body>. Instead:
* the field goes aria-disabled + readOnly (readOnly is the guard that makes
aria-disabled honest — the value stays selectable and copyable, nothing can
be typed);
* the field drops its `name` while disabled, which reproduces exactly what the
native attribute does to the submitted form data;
* every InputGroupButton gets aria-disabled plus an onClick guard that calls
preventDefault and returns — no pointer-events:none, so it stays hoverable,
focusable and announced as unavailable.
- InputGroupButton also takes its own `disabled` for "nothing to copy yet";
it dims itself only when the group is NOT disabled, because the shell is
already at 50% and two stacked opacities fade it to a quarter.
Behavior — keyboard and pointer
- Tab order is DOM order and nothing rewrites it: the field, then any interactive
addon or button that follows it. Put interactive slots after the input so the
field is the group's first stop; the component does not fight this with
tabindex, which would desynchronise reading order from focus order.
- Every pointer path has a keyboard twin: pressing the shell's dead zone or a
static affix focuses the field (Tab does the same); the trailing button is
Enter/Space like any button; the <select> is arrows/Home/End natively.
- Shell pointerdown handler: bail if the consumer's handler called
preventDefault, bail if event.button !== 0, bail if event.target.closest() hits
input/textarea/select/button/a[href]/[contenteditable]/[tabindex]:not([tabindex="-1"]) —
those focus themselves. Otherwise preventDefault (suppressing the compatibility
mouse event stops the shell taking the press first, so the caret keeps its
position) and focus the field through a ref the input registered via callback ref.
- A polite live region (role="status" aria-atomic, sr-only) announces disabled and
invalid FLIPS, because nothing else does: aria-invalid and readOnly change under
a focused caret silently. Guards: skip the first commit (an already-invalid form
must not shout on load) and skip unless shell.contains(document.activeElement).
Clear the region after ~2.5s on a timer so the same message can be announced
twice; clear that timer on unmount.
- Render the live region as a sibling AFTER the shell, not inside it: an extra
element child would break the :first-child / :last-child rules the seams and
edge insets depend on. It is sr-only (absolutely positioned), so it costs no layout.
Edge cases
- Long value: the field is min-w-0 flex-1 and every addon is shrink-0, so an
overlong value scrolls inside the field and the affixes never move. An affix
long enough to squeeze the field out is a content problem — shorten it or move
it into the label.
- An addon that unmounts must unregister its id, or aria-describedby points at a
dead node. The register function returns its own remover and the effect returns it.
- An icon-only addon contributes an empty description fragment, which is harmless;
do not re-register on children changes or the effect will loop.
- No input in the group: the shell click handler no-ops instead of throwing.
Rendering & styling
- Semantic tokens only: shell border-input + bg-transparent + dark:bg-input/30;
panel addon bg-muted / text-muted-foreground; ghost button hover:bg-accent
hover:text-accent-foreground; primary button bg-primary / text-primary-foreground;
invalid uses border-destructive + ring-destructive/20; focus uses ring-ring/50
and outline-ring. No hex, no rgb().
- The shell owns the focus ring via has-[:focus-visible]:ring-3 — not focus-within.
A text field matches :focus-visible even on a mouse click, so the field behaves
identically, while mouse-clicking the trailing button no longer lights up the
whole field.
- The shell is overflow-hidden + rounded-lg so children clip to the corners; that
is why inner buttons use focus-visible:outline-2 with -outline-offset-2 (an
outer ring would be clipped away).
- Seams: only a filled `panel` affix earns a hairline. A panel addon draws
border-l when it is not :first-child; the field, buttons and inline addons draw
border-l via the sibling variant [[data-addon=panel]+&]. That yields exactly one
1px line between every adjacent pair and none around a ghost icon button.
- Sizes are three small lookup maps (shell height + text, slot gap + glyph size,
per-variant padding), not cva — the axes are independent enough that a table
reads better than a variant matrix. Field text is text-base with md:text-sm so
iOS Safari does not zoom on focus.
- An inline addon only pays the shell's edge inset when it is at an edge
(first:pl-* / last:pr-*), so the gap to the value is the field's own padding.
- Reduced motion: the only motion is the colour/ring transition, and it carries
motion-reduce:transition-none. Nothing about the component depends on it.
- cn() merges the consumer className on every slot; data-slot, data-size,
data-invalid, data-disabled and data-addon are exposed for styling from outside.
Customization levers
- Size axis: add "xl" as one entry in each of the five maps (shell, slot, panel
pad, inline pad, field pad/text). Keep the field's padding equal to the panel
addon's so the value and a suffix sit on the same rhythm.
- Affix weight: bg-muted panel is the "keycap" look; swap it for bg-transparent +
text-muted-foreground (i.e. use variant="inline") for the quiet Stripe look, or
bg-primary/10 text-primary for an emphasised protocol chip.
- Shape: rounded-lg → rounded-md for a denser form, or rounded-full for a search
pill; overflow-hidden means nothing else has to change.
- Seam: drop the [data-addon=panel] sibling rules entirely for a seamless shell
where the muted fill alone separates the affix.
- Trailing controls: InputGroupButton covers copy/submit/reveal; anything else
(a unit menu, a country picker) goes in <InputGroupAddon interactive> and reads
size/disabled/invalid from useInputGroup() so it dies with the field.
- Invalid styling: the ring is the loud part — drop ring-3 and keep only
border-destructive for a quieter form.
- The live region wording is four strings in one branch; localise them there.Concepts
- Composition beats configuration — the affixes are children, so their order is DOM order and their content is whatever you render; there is no
prefix/suffix/prefixIconprop matrix to outgrow. - State flows down, never sideways —
size,invalidanddisabledare set once on the shell and reach every slot through context, which is what stops an addon from looking alive next to a dead field or small next to a large one. - Described, not read — a static affix is
aria-hiddenso it is not narrated as stray text, and its id is folded into the field'saria-describedby; anaria-hiddennode that is the direct target ofaria-describedbystill contributes its text, so the affix is silent alone and audible with the field. - Disabled without the blur — the native
disabledattribute blurs the control to the document body the moment it flips, so the field goesaria-disabled+readOnlyand simply drops itsnameto stay out of the form data; buttons guard their own handler instead. - One seam per boundary — only a filled affix earns a hairline, and it is drawn by whichever element follows it, so adjacent slots can never stack two 1px lines into a 2px one.
- The shell owns the focus ring —
:has(:focus-visible)rather than:focus-within, so entering the field lights the whole shell while mouse-clicking the trailing copy button does not.
Date Picker
A typeable single-date field with a calendar panel — Intl-driven formatting and parsing, a complete keyboard grid, and refusals that keep what you typed.
Icon Picker
A searchable icon grid in a popover — the pickable set is a name → component map you supply, paged into bounded windows, with combobox keyboard navigation and recents.