Input Group React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React input group that joins a field with text addons, selects, icons and buttons into one box with one focus ring and one error state.
Several controls read as one field: the whole row takes a single soft ring when any part has focus, a single red ring when it is invalid, and dims as one when disabled. Tab walks through the controls in order. A click or tap on a text addon like https:// or kg lands the caret in the input next to it; a click on the label focuses the first control. Selects inside open their list as usual, as a bottom sheet below 768px. A solid button at the end (Apply) sits flush with the rounded corner and shows its spinner while loading without changing width. On a phone the row stays on one line down to 320px: addons never wrap, the input shrinks, text is 16px so iOS never zooms and the md row grows to 44px.
Input Group is a Pro React component in the React input components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add input-group, together with Field, Button and UIProvider and the npm package @radix-ui/react-slot.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add input-groupInput Group is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import InputGroup from @/components/ui/input-group.
import { useState } from "react"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupButton } from "@/components/ui/input-group"
import { Select } from "@/components/ui/select"
const DOMAINS = [
{ value: ".com", label: ".com" },
{ value: ".io", label: ".io" },
{ value: ".dev", label: ".dev" },
]
export function SiteAddress() {
const [domain, setDomain] = useState(".com")
const [pending, setPending] = useState(false)
return (
<div className="flex flex-col gap-4">
<InputGroup label="Website address" description="The name without spaces.">
<InputGroupAddon>https://</InputGroupAddon>
<Input aria-label="Domain" placeholder="northwind" name="domain" />
<Select aria-label="Extension" options={DOMAINS} value={domain} onValueChange={(next) => next && setDomain(next)} />
</InputGroup>
<InputGroup label="Discount code" error="This coupon has expired">
<Input aria-label="Coupon code" placeholder="Coupon code" />
<InputGroupButton
variant="solid"
loading={pending}
onClick={() => {
setPending(true)
setTimeout(() => setPending(false), 1200)
}}
>
Apply
</InputGroupButton>
</InputGroup>
</div>
)
}Props
labeldefault""ReactNodeNames the whole row (the group is aria-labelledby it); a click on it focuses the first control. The controls inside stay bare and take an aria-label each (Domeniu, Extensie).
descriptiondefault""ReactNodeHelper text under the row. It describes the group and every control inside (aria-describedby), together with the text addons.
errordefault""ReactNodeReplaces the description with the animated error and turns the whole row red; every control inside gets aria-invalid.
requireddefaultfalsebooleanThe red mark after the label; the controls inside get required.
invaliddefaultfalsebooleanThe red ring and aria-invalid on every control without a message.
disableddefaultfalsebooleanDims the row once to 50% and disables every control and button inside.
readOnlydefaultfalsebooleanValues stay visible and selectable but cannot change; selects do not open and the row drops its hover tint. The demo fills in a sample value.
sizedefault"md""sm" | "md" | "lg"Height 32 / 40 / 48px (md 44px on touch). Pass it to the group only: the controls, addons and buttons inside follow it.
variantdefault"soft""soft" | "outline"soft is the filled well, outline a bordered box. Ghost and underline make no sense for a joined row.
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"Corner shape of the row, scaled with the size like the button's (md is 8 / 10 / 12px); full makes a pill. Inner parts are clipped to it.
separatorsdefaulttruebooleanHairlines between segments (controls, filled addons, ghost buttons). Plain addons are text inside a segment and a solid button is its own edge, so they never get one.
childrenReactNodeInputGroupAddon, bare W2 controls (Input, Select, NumberInput, CurrencyInput... with no label of their own, an aria-label each) and InputGroupButton, in order. The row never wraps (the input shrinks instead); on a phone keep it to one or two controls plus addons, and split longer rows into separate groups.
classNamesPartial<Record<"field" | "label" | "description" | "error" | "box" | "separator", string>>Extra classes for the parts: field is the wrapper with the label, box the joined row.
classNamestringClasses for the outermost element: the wrapper when there is a label, description or error, else the row.
Parts
InputGroupAddon
componentText or an icon at either end. position is inferred from the order when omitted. variant "plain" (default) is muted text that hugs the input, "filled" a tinted segment like a classic addon. A click or tap focuses the nearest input (links and buttons inside keep their own click). asChild renders a <label htmlFor>. Text addons (USD, kg) join the controls' aria-describedby; describe={false} opts out. Props: ComponentProps<"span"> & { position?: "start" | "end"; variant?: "plain" | "filled"; asChild?: boolean; describe?: boolean }.
InputGroupButton
componentA Button flush with the row: variant "ghost" and radius "none" by default, full height, the group's size (an explicit sm / md / lg sets its own text size), inset focus outline, disabled with the group. size="icon" makes it square; loading keeps its width. Props: ButtonProps.
InputGroupSeparator
componentA 1px hairline placed by hand, for example with separators={false}. Decorative (aria-hidden). Takes span props.
Examples
Price
A CurrencyInput joined with a currency Select: one box, one ring, and the amount reformats for USD or EUR.
Coupon
A solid Apply button flush with the end: it checks the code for 1.2s, then SUMMER24 shows the error in red and FALL25 applies the discount.
Weight and size
A plain kg addon that reads as text inside the box, and a filled cm segment like a classic addon.
Search with category
A pill search bar: category Select, the query and an icon button that runs a pretend search. Below 768px the category opens as a bottom sheet.
Country code
A filled +1 segment before a tel input. For real phone numbers use phone-input.
Sizes and variants
sm, md and lg in soft and outline, lined up with a plain Input under each set.
Dependencies
Files
- components/ui/input-group.tsx
FAQ
Is the React Input Group component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Input Group?
Run npx wingo-ui@latest add input-group. It also adds the Wingo UI items it builds on (Field, Button and UIProvider) and the npm package @radix-ui/react-slot. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/input-group.
How does Input Group behave on phones?
Selects inside open their list as usual, as a bottom sheet below 768px. On a phone the row stays on one line down to 320px: addons never wrap, the input shrinks, text is 16px so iOS never zooms and the md row grows to 44px.