v1.0

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

PropTypeDefaultDescription
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.