InputGroup
Preview
Basic
Loading…
Preview
Code
ts
import InputGroup from '@/components/form/InputGroup';src/components/form/InputGroup.tsx
AI prompt
text
Build an input group component in React + TypeScript + Tailwind CSS that joins addons (text, icons, buttons, selects) before and after an input into one bordered control.
## Look
- Group: `flex w-full items-stretch`, `role="group"`.
- Addon (export `InputGroupAddon`): a grey cap the same height and border as a text input — `inline-flex shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-lg border px-2.5 text-xs leading-none border-slate-300 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400`, icons inside sized to 14px (`[&>svg]:h-3.5 [&>svg]:w-3.5`).
## Behaviour
- The group doesn't draw a frame; it works on the children's OWN borders:
- every child but the first: `-ml-px` (two borders collapse into one) and no left radius;
- every child but the last: no right radius.
Use `[&>*:not(:first-child)]:…` / `[&>*:not(:last-child)]:…` rather than `first:`/`last:` — the extra pseudo-class out-specifies a child's own `rounded-lg`, which is the only way to override a button's corners from outside.
- Every child is `relative`, and the focused child is lifted with `z-[1]` (`[&>*:focus]`, `[&>*:focus-within]`) so its focus ring isn't painted over by the next addon.
- Inputs grow (`[&>input]:flex-1 min-w-0`); a `<select>` keeps its own width — give it `w-auto` when used as a unit picker.
- Children are plain elements in visual order; each keeps its own classes. Forward other div props.
## API
`InputGroup({ children, className, ...divProps })`, `InputGroupAddon({ children, className, ...spanProps })`.
## Demo
Four rows: "https://" + input "example.com" + ".org"; a DollarSign icon addon + number input "0.00" + "USD"; a number "Weight" input + a kg/lb select; a "Search keyword" input + an icon-only clear (X) secondary button + a primary "Search" button with a search icon.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
import { cn } from '@/lib/cn';
export interface InputGroupAddonProps extends React.HTMLAttributes<HTMLSpanElement> {
children: React.ReactNode;
}
/**
* A grey cap for text or an icon ("https://", "$", a search glass) beside the
* input. Same border and height as `field-input`, so it reads as part of the
* control rather than a label floating next to it.
*/
export function InputGroupAddon({ children, className, ...rest }: InputGroupAddonProps) {
return (
<span
{...rest}
className={cn(
'inline-flex shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-lg border px-2.5 text-xs leading-none',
'border-slate-300 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400',
'[&>svg]:h-3.5 [&>svg]:w-3.5',
className,
)}
>
{children}
</span>
);
}
export interface InputGroupProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Addons, inputs, selects and buttons, in visual order. Each keeps its own
* classes — the group only squares the inner corners and overlaps the borders.
*/
children: React.ReactNode;
}
/**
* Joins addons, inputs and buttons into one control with a single outline.
*
* It works on the children's OWN borders instead of drawing a frame around
* them: every child but the first loses its left radius and is pulled 1px left
* so two borders collapse into one, and every child but the last loses its
* right radius. The selectors are `:not(:first-child)` / `:not(:last-child)`
* rather than plain `first:`, because the extra pseudo-class out-specifies a
* child's own `rounded-lg` — `cn()` does not merge classes, so winning on
* specificity is the only way to override a `<Button>`'s corners from outside.
*
* The focused child is lifted one level above its neighbours (not into the
* z-index bands — it only has to beat its siblings), or the right half of its
* focus ring would be painted over by the addon that follows it.
*
* Inputs grow to fill; a `<select>` keeps whatever width its own classes give
* it, so pass `w-auto` to one used as an addon-style picker.
*/
export default function InputGroup({ children, className, ...rest }: InputGroupProps) {
return (
<div
role="group"
{...rest}
className={cn(
'flex w-full items-stretch',
'[&>*]:relative [&>*:focus]:z-[1] [&>*:focus-within]:z-[1]',
'[&>*:not(:first-child)]:-ml-px [&>*:not(:first-child)]:rounded-l-none',
'[&>*:not(:last-child)]:rounded-r-none',
'[&>input]:min-w-0 [&>input]:flex-1',
className,
)}
>
{children}
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children* | React.ReactNode | — | Addons, inputs, selects and buttons, in visual order. Each keeps its own classes — the group only squares the inner corners and overlaps the borders. |