"use client";
import * as React from "react";
import { InputGroup, InputGroupItem } from "@/components/ui/input-group";
export function InputGroupDemo() {
const [values, setValues] = React.useState({
first: "",
second: "",
third: "",
});
const onValueChange = React.useCallback(
(field: keyof typeof values) =>
(event: React.ChangeEvent<HTMLInputElement>) => {
setValues((prev) => ({
...prev,
[field]: event.target.value,
}));
},
[],
);
return (
<div className="flex flex-col gap-2">
<label className="font-medium text-sm leading-none">
Enter your details
</label>
<InputGroup className="w-full max-w-sm">
<InputGroupItem
position="first"
placeholder="First"
value={values.first}
onChange={onValueChange("first")}
aria-label="First name"
/>
<InputGroupItem
position="middle"
placeholder="Second"
value={values.second}
onChange={onValueChange("second")}
aria-label="Middle name"
/>
<InputGroupItem
position="last"
placeholder="Third"
value={values.third}
onChange={onValueChange("third")}
aria-label="Last name"
/>
</InputGroup>
</div>
);
}
Installation
CLI
npx shadcn@latest add "https://diceui.com/r/input-group"
pnpm dlx shadcn@latest add "https://diceui.com/r/input-group"
yarn dlx shadcn@latest add "https://diceui.com/r/input-group"
bun x shadcn@latest add "https://diceui.com/r/input-group"
Manual
Install the following dependencies:
npm install class-variance-authority
pnpm add class-variance-authority
yarn add class-variance-authority
bun add class-variance-authority
Copy and paste the following code into your project.
"use client";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
const ROOT_NAME = "InputGroup";
const ITEM_NAME = "InputGroupItem";
type Direction = "ltr" | "rtl";
const DirectionContext = React.createContext<Direction | undefined>(undefined);
function useDirection(dirProp?: Direction): Direction {
const contextDir = React.useContext(DirectionContext);
return dirProp ?? contextDir ?? "ltr";
}
interface InputGroupContextValue {
dir?: Direction;
orientation?: "horizontal" | "vertical";
size?: "sm" | "default" | "lg";
disabled?: boolean;
invalid?: boolean;
required?: boolean;
}
const InputGroupContext = React.createContext<InputGroupContextValue | null>(
null,
);
InputGroupContext.displayName = ROOT_NAME;
function useInputGroupContext(consumerName: string) {
const context = React.useContext(InputGroupContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
const inputGroupItemVariants = cva("", {
variants: {
position: {
first: "rounded-e-none",
middle: "-ms-px rounded-none border-l-0",
last: "-ms-px rounded-s-none border-l-0",
isolated: "",
},
orientation: {
horizontal: "",
vertical: "",
},
},
compoundVariants: [
{
position: "first",
orientation: "vertical",
class: "ms-0 rounded-e-md rounded-b-none border-l-1",
},
{
position: "middle",
orientation: "vertical",
class: "-mt-px ms-0 rounded-none border-t-0 border-l-1",
},
{
position: "last",
orientation: "vertical",
class: "-mt-px ms-0 rounded-s-md rounded-t-none border-t-0 border-l-1",
},
],
defaultVariants: {
position: "isolated",
orientation: "horizontal",
},
});
interface InputGroupRootProps extends React.ComponentProps<"div"> {
id?: string;
dir?: Direction;
orientation?: "horizontal" | "vertical";
size?: "sm" | "default" | "lg";
asChild?: boolean;
disabled?: boolean;
invalid?: boolean;
required?: boolean;
}
function InputGroupRoot(props: InputGroupRootProps) {
const {
dir: dirProp,
orientation = "horizontal",
size = "default",
className,
asChild,
disabled,
invalid,
required,
...rootProps
} = props;
const id = React.useId();
const dir = useDirection(dirProp);
const contextValue = React.useMemo<InputGroupContextValue>(
() => ({
dir,
orientation,
size,
disabled,
invalid,
required,
}),
[dir, orientation, size, disabled, invalid, required],
);
const RootPrimitive = asChild ? Slot : "div";
return (
<InputGroupContext.Provider value={contextValue}>
<RootPrimitive
role="group"
aria-orientation={orientation}
data-slot="input-group"
data-orientation={orientation}
data-disabled={disabled ? "" : undefined}
data-invalid={invalid ? "" : undefined}
data-required={required ? "" : undefined}
{...rootProps}
id={id}
dir={dir}
className={cn(
"flex",
orientation === "horizontal" ? "flex-row" : "flex-col",
className,
)}
/>
</InputGroupContext.Provider>
);
}
InputGroupRoot.displayName = ROOT_NAME;
interface InputGroupItemProps
extends React.ComponentProps<"input">,
VariantProps<typeof inputGroupItemVariants> {
asChild?: boolean;
}
function InputGroupItem(props: InputGroupItemProps) {
const { asChild, className, position, disabled, required, ...inputProps } =
props;
const context = useInputGroupContext(ITEM_NAME);
const isDisabled = disabled ?? context.disabled;
const isRequired = required ?? context.required;
const InputPrimitive = asChild ? Slot : Input;
return (
<InputPrimitive
data-slot="input-group-item"
data-position={position}
data-orientation={context.orientation}
data-disabled={isDisabled ? "" : undefined}
data-invalid={context.invalid ? "" : undefined}
data-required={isRequired ? "" : undefined}
aria-invalid={context.invalid}
aria-required={isRequired}
disabled={isDisabled}
required={isRequired}
{...inputProps}
className={cn(
inputGroupItemVariants({
position,
orientation: context.orientation,
}),
className,
)}
/>
);
}
InputGroupItem.displayName = ITEM_NAME;
export {
InputGroupRoot as InputGroup,
InputGroupItem,
//
InputGroupRoot as Root,
InputGroupItem as Item,
};
Layout
Import the component and use it with different position props.
import { InputGroup, InputGroupItem } from "@/components/ui/input-group";
<InputGroup>
<InputGroupItem position="first" placeholder="First" />
<InputGroupItem position="middle" placeholder="Middle" />
<InputGroupItem position="last" placeholder="Last" />
</InputGroup>
Examples
Form Input
Use grouped inputs for structured form data like phone numbers or addresses.
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupItem } from "@/components/ui/input-group";
export function InputGroupFormDemo() {
const [phoneNumber, setPhoneNumber] = React.useState({
countryCode: "+1",
areaCode: "",
number: "",
});
const onSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
console.log(
"Phone number:",
`${phoneNumber.countryCode} ${phoneNumber.areaCode}-${phoneNumber.number}`,
);
},
[phoneNumber],
);
return (
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="font-medium text-sm leading-none">Phone Number</label>
<InputGroup className="w-full max-w-sm" aria-label="Phone number input">
<InputGroupItem
position="first"
placeholder="+1"
value={phoneNumber.countryCode}
onChange={(event) =>
setPhoneNumber((prev) => ({
...prev,
countryCode: event.target.value,
}))
}
className="w-16"
aria-label="Country code"
/>
<InputGroupItem
position="middle"
placeholder="555"
value={phoneNumber.areaCode}
onChange={(event) =>
setPhoneNumber((prev) => ({
...prev,
areaCode: event.target.value,
}))
}
className="w-20"
maxLength={3}
inputMode="numeric"
pattern="[0-9]*"
aria-label="Area code"
/>
<InputGroupItem
position="last"
placeholder="1234567"
value={phoneNumber.number}
onChange={(event) =>
setPhoneNumber((prev) => ({
...prev,
number: event.target.value,
}))
}
className="flex-1"
maxLength={7}
inputMode="numeric"
pattern="[0-9]*"
aria-label="Phone number"
/>
</InputGroup>
</div>
<Button type="submit" size="sm">
Submit
</Button>
</form>
);
}
RGB Color Input
Create color input controls using grouped inputs for RGB values.
"use client";
import * as React from "react";
import * as InputGroup from "@/components/ui/input-group";
export function InputGroupRgbDemo() {
const [rgb, setRgb] = React.useState({
r: 255,
g: 128,
b: 0,
});
const onChannelChange = React.useCallback(
(channel: keyof typeof rgb) =>
(event: React.ChangeEvent<HTMLInputElement>) => {
const value = Number.parseInt(event.target.value, 10);
if (!Number.isNaN(value) && value >= 0 && value <= 255) {
setRgb((prev) => ({
...prev,
[channel]: value,
}));
}
},
[],
);
return (
<div className="flex flex-col gap-2">
<label className="font-medium text-sm leading-none">RGB Color</label>
<InputGroup.Root className="w-fit" aria-label="RGB color input">
<InputGroup.Item
position="first"
placeholder="255"
value={rgb.r}
onChange={onChannelChange("r")}
className="w-16"
inputMode="numeric"
pattern="[0-9]*"
min="0"
max="255"
aria-label="Red channel (0-255)"
/>
<InputGroup.Item
position="middle"
placeholder="128"
value={rgb.g}
onChange={onChannelChange("g")}
className="w-16"
inputMode="numeric"
pattern="[0-9]*"
min="0"
max="255"
aria-label="Green channel (0-255)"
/>
<InputGroup.Item
position="last"
placeholder="0"
value={rgb.b}
onChange={onChannelChange("b")}
className="w-16"
inputMode="numeric"
pattern="[0-9]*"
min="0"
max="255"
aria-label="Blue channel (0-255)"
/>
</InputGroup.Root>
</div>
);
}
Vertical Layout
Display input groups in a vertical orientation.
Use arrow keys (up/down) to navigate between fields in vertical orientation.
"use client";
import * as React from "react";
import { InputGroup, InputGroupItem } from "@/components/ui/input-group";
export function InputGroupVerticalDemo() {
const [address, setAddress] = React.useState({
street: "",
city: "",
zipCode: "",
});
const onFieldChange = React.useCallback(
(field: keyof typeof address) =>
(event: React.ChangeEvent<HTMLInputElement>) => {
setAddress((prev) => ({
...prev,
[field]: event.target.value,
}));
},
[],
);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="font-medium text-sm leading-none">
Mailing Address
</label>
<InputGroup
aria-label="Mailing address input"
className="w-full max-w-sm"
orientation="vertical"
>
<InputGroupItem
aria-label="Street address"
position="first"
placeholder="Street Address"
value={address.street}
onChange={onFieldChange("street")}
/>
<InputGroupItem
aria-label="City"
position="middle"
placeholder="City"
value={address.city}
onChange={onFieldChange("city")}
/>
<InputGroupItem
aria-label="ZIP code"
position="last"
placeholder="ZIP Code"
value={address.zipCode}
onChange={onFieldChange("zipCode")}
/>
</InputGroup>
</div>
<p className="text-muted-foreground text-sm">
Use arrow keys (up/down) to navigate between fields in vertical
orientation.
</p>
</div>
);
}
API Reference
InputGroup
The main input group container component.
Prop | Type | Default |
---|---|---|
required? | boolean | false |
invalid? | boolean | false |
disabled? | boolean | false |
asChild? | boolean | false |
size? | "sm" | "default" | "lg" | "default" |
orientation? | "horizontal" | "vertical" | "horizontal" |
dir? | Direction | "ltr" |
id? | string | - |
InputGroupItem
Individual input items within the input group.
Prop | Type | Default |
---|---|---|
required? | boolean | - |
disabled? | boolean | - |
asChild? | boolean | false |
position? | "first" | "middle" | "last" | "isolated" | "isolated" |
The component supports all standard HTML input attributes and properties from the base Input component, plus the following:
- position: Controls the visual positioning and borders of the input within a group
first
: Removes right border radius, maintains left bordermiddle
: Removes all border radius, removes left border to prevent doublinglast
: Removes left border radius, removes left border to prevent doublingisolated
: Normal input styling (default)
Styling
The component automatically handles:
- Border radius removal for seamless connection
- Border overlap prevention with negative margins
- Focus ring z-index management
- Webkit number input spinner removal
- Firefox number input appearance normalization
Accessibility
The InputGroup component follows standard web accessibility practices. Users navigate between inputs using Tab and Shift+Tab keys, which is the expected behavior for form controls.
Keyboard Interactions
Key | Description |
---|---|
Tab | Moves focus to the next input in the group. |
ShiftTab | Moves focus to the previous input in the group. |