Input Group
Composable input layout that wraps Input or Textarea with inline and block addons (icons, buttons, kbd hints) while preserving group-wide focus and error states.
Overview
InputGroup is a layout primitive that composes an input (or textarea) with leading and trailing addons. The wrapper drives shared visual states - focus, invalid, disabled - from the inner control using CSS :has() selectors, so every addon reacts in lockstep.
Four alignment positions are exposed via data-align on InputGroupAddon: inline-start (leading), inline-end (trailing), block-start (top), and block-end (bottom, useful for footers in textareas).
Preview
Installation
bash npx gremorie@latest add rx-input-group bash pnpm dlx gremorie@latest add rx-input-group
bash yarn dlx gremorie@latest add rx-input-group
bash bunx --bun gremorie@latest add rx-input-group
Usage
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@gremorie/rx-forms";
import { Search } from "lucide-react";
export function Example() {
return (
<InputGroup>
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput placeholder="Search the registry..." />
</InputGroup>
);
}Angular edition planned for Phase 5h. Star the repo to track progress.
API
<InputGroup>
The wrapper. Always renders with role="group" and data-slot="input-group". Drives the visual surface (border, shadow, focus ring, error ring) based on the inner control's state via :has().
Extends all React.ComponentProps<"div">.
<InputGroupAddon>
| Prop | Type | Default | Description |
|---|---|---|---|
align | "inline-start" | "inline-end" | "block-start" | "block-end" | "inline-start" | Where the addon sits relative to the input. block-* switches the group to a column layout. |
Extends all React.ComponentProps<"div">. Clicking the addon (outside of any nested <button>) focuses the input by querying the parent's first child input.
<InputGroupInput>
Use this instead of <Input> inside an InputGroup. It strips the input's own border, shadow, and focus ring so the group can own them. Carries data-slot="input-group-control" which is the hook the parent reads to drive group-wide focus state.
Extends all React.ComponentProps<"input">.
<InputGroupTextarea>
Use this instead of <Textarea> inside an InputGroup. Same role as InputGroupInput, applied to a multiline control. Pairs naturally with align="block-end" for a footer toolbar.
Extends all React.ComponentProps<"textarea">.
<InputGroupButton>
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "icon-xs" | "icon-sm" | "xs" | Smaller footprint than Button to fit inside the input height. |
variant | inherited from Button | "ghost" | Forwarded to the underlying Button. |
type | string | "button" | Defaults to "button" so it never accidentally submits a form. |
Extends all Button props (except size, which is replaced).
<InputGroupText>
Inline label for kbd hints or unit suffixes. Renders a styled <span> with muted foreground.
Extends all React.ComponentProps<"span">.
Composition
<InputGroup>owns the surface and reads the inner control's state via:has().<InputGroupAddon>sits at one of four positions and clicks-through-to-input.<InputGroupInput>or<InputGroupTextarea>is the actual editable control - never use plain<Input>insideInputGroupor focus/invalid wiring will break.<InputGroupButton>,<InputGroupText>, plain icons are valid children of<InputGroupAddon>.
:has() selector gotcha. InputGroup looks for a child input via :has(> textarea) / :has(> input). If you wrap the input in a component that uses display: contents (e.g. some compound primitives), the chain breaks and the group cannot resize itself for the textarea. Render InputGroupInput / InputGroupTextarea as a direct child of InputGroup whenever possible.
Variations
Search with submit button
A classic search bar. Leading icon, trailing button.
<InputGroup>
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput placeholder="Search the registry..." />
<InputGroupAddon align="inline-end">
<InputGroupButton size="sm">Go</InputGroupButton>
</InputGroupAddon>
</InputGroup>URL field with prefix
The leading addon shows a static label that clicks-through to focus the input.
<InputGroup>
<InputGroupAddon>
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupInput placeholder="example.com" />
</InputGroup>Textarea with footer toolbar
align="block-end" puts an addon under the textarea. Ideal for chat composers and comment forms.
import { Paperclip, Send } from 'lucide-react';
<InputGroup>
<InputGroupTextarea placeholder="Write a message..." rows={3} />
<InputGroupAddon align="block-end">
<InputGroupButton size="icon-sm" aria-label="Attach file">
<Paperclip />
</InputGroupButton>
<InputGroupButton size="sm" variant="default" className="ml-auto">
<Send />
Send
</InputGroupButton>
</InputGroupAddon>
</InputGroup>;Kbd hint for shortcut
Static keyboard hint stays out of the tab order and never steals focus.
<InputGroup>
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput placeholder="Search..." />
<InputGroupAddon align="inline-end">
<kbd className="rounded border bg-muted px-1.5 py-0.5 text-xs">/</kbd>
</InputGroupAddon>
</InputGroup>Accessibility
- Group semantics: rendered with
role="group". Pair witharia-labelso the addons + input read as one labelled unit when needed. - Click-through: clicking on an addon (anywhere except a nested button) focuses the inner input, matching the native
<label>behaviour users expect from prefix icons. - Focus state: the group reads
:has([data-slot=input-group-control]:focus-visible)so the entire surface shows the focus ring at once. - Invalid state: setting
aria-invalid="true"on the inner control propagates the destructive ring to the whole group. - Disabled state: setting
data-disabled="true"on the group dims all addons viagroup-data-[disabled=true]/input-group:opacity-50.