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…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export type InputGroupSize = "sm" | "md" | "lg"

/** How long a state announcement stays in the live region before it is emptied. */
const ANNOUNCE_CLEAR_MS = 2500

/**
 * Everything the browser already focuses by itself. A press that lands inside one
 * of these keeps its native behaviour; a press anywhere else on the shell — the
 * padding, a static affix — is redirected to the field.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/input-group.json

Prompt

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 / prefixIcon prop matrix to outgrow.
  • State flows down, never sidewayssize, invalid and disabled are 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-hidden so it is not narrated as stray text, and its id is folded into the field's aria-describedby; an aria-hidden node that is the direct target of aria-describedby still contributes its text, so the affix is silent alone and audible with the field.
  • Disabled without the blur — the native disabled attribute blurs the control to the document body the moment it flips, so the field goes aria-disabled + readOnly and simply drops its name to 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.

On This Page