Segmented Input
A group of connected input fields that appear as a single segmented visual unit.
Preview
Loading...
import * as React from "react";
import { Button } from "@/components/ui/Button";
import {
SegmentedInput,
SegmentedInputItem,
} from "@/components/ui/SegmentedInput";
export function SegmentedInputDemo() {
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 w-full max-w-sm flex-col gap-1">
<label className="text-sm font-medium text-gray-12">
Enter your details
</label>
<SegmentedInput className="w-full" aria-label="Name segments">
<SegmentedInputItem
placeholder="First"
value={values.first}
onChange={onValueChange("first")}
aria-label="First name"
/>
<SegmentedInputItem
placeholder="Second"
value={values.second}
onChange={onValueChange("second")}
aria-label="Middle name"
/>
<SegmentedInputItem
placeholder="Third"
value={values.third}
onChange={onValueChange("third")}
aria-label="Last name"
/>
</SegmentedInput>
</div>
);
}Installation
Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces
pnpm dlx shadcn@latest add @aura/segmented-inputManual
Install the following dependencies:
pnpm install class-variance-authority radix-uiCopy and paste the class names utility into your utils/class-names.ts file.
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy and paste the input component into your components/ui/Input.tsx file.
/**
* @description Displays a form input field or a component that looks like an input field.
*/
import * as React from "react";
import { cn } from "@/utils/class-names";
function Input({ className, ...props }: React.ComponentProps<"input">) {
return <input data-slot="input" className={cn(className)} {...props} />;
}
export { Input };
Copy and paste the SegmentedInput component into your components/ui/SegmentedInput.tsx file.
"use client";
/**
* @description A group of connected input fields that appear as a single segmented visual unit.
*/
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import {
Direction as DirectionPrimitive,
Slot as SlotPrimitive,
} from "radix-ui";
import { cn } from "@/utils/class-names";
import { Input } from "@/components/ui/Input";
const ROOT_NAME = "SegmentedInput";
const ITEM_NAME = "SegmentedInputItem";
type Direction = "ltr" | "rtl";
type Orientation = "horizontal" | "vertical";
type Size = "default" | "sm" | "lg";
type Position = "isolated" | "first" | "middle" | "last";
interface SegmentedInputContextValue {
dir?: Direction;
orientation?: Orientation;
size?: Size;
disabled?: boolean;
invalid?: boolean;
required?: boolean;
}
const SegmentedInputContext =
React.createContext<SegmentedInputContextValue | null>(null);
function useSegmentedInputContext(consumerName: string) {
const context = React.useContext(SegmentedInputContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface SegmentedInputProps extends React.ComponentProps<"div"> {
dir?: Direction;
orientation?: Orientation;
size?: Size;
asChild?: boolean;
disabled?: boolean;
invalid?: boolean;
required?: boolean;
}
function SegmentedInput(props: SegmentedInputProps) {
const {
size = "default",
dir: dirProp,
orientation = "horizontal",
children,
className,
asChild,
disabled,
invalid,
required,
...rootProps
} = props;
const dir = DirectionPrimitive.useDirection(dirProp);
const contextValue = React.useMemo<SegmentedInputContextValue>(
() => ({
dir,
orientation,
size,
disabled,
invalid,
required,
}),
[dir, orientation, size, disabled, invalid, required],
);
const childrenArray = React.Children.toArray(children);
const childrenCount = childrenArray.length;
const segmentedInputItems = React.Children.map(children, (child, index) => {
if (React.isValidElement<SegmentedInputItemProps>(child)) {
if (!child.props.position) {
let position: Position;
if (childrenCount === 1) {
position = "isolated";
} else if (index === 0) {
position = "first";
} else if (index === childrenCount - 1) {
position = "last";
} else {
position = "middle";
}
return React.cloneElement(child, { position });
}
}
return child;
});
const RootPrimitive = asChild ? SlotPrimitive.Slot : "div";
return (
<SegmentedInputContext.Provider value={contextValue}>
<RootPrimitive
role="group"
aria-orientation={orientation}
data-slot="segmented-input"
data-orientation={orientation}
data-disabled={disabled ? "" : undefined}
data-invalid={invalid ? "" : undefined}
data-required={required ? "" : undefined}
dir={dir}
{...rootProps}
className={cn(
"flex",
orientation === "horizontal" ? "flex-row" : "flex-col",
className,
)}
>
{segmentedInputItems}
</RootPrimitive>
</SegmentedInputContext.Provider>
);
}
const segmentedInputItemVariants = cva(
[
"relative min-w-0 flex-1 rounded-md border border-gray-7 bg-gray-1 text-gray-12 transition-colors outline-none",
"selection:bg-accent-4 selection:text-gray-12 placeholder:text-gray-11",
"focus-visible:z-10 focus-visible:border-gray-8 focus-visible:ring-2 focus-visible:ring-gray-8",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:border-danger aria-invalid:ring-2 aria-invalid:ring-danger/20",
].join(" "),
{
variants: {
position: {
isolated: "",
first: "rounded-e-none",
middle: "-ms-px rounded-none border-l-0",
last: "-ms-px rounded-s-none border-l-0",
},
orientation: {
horizontal: "",
vertical: "",
},
size: {
// Aura --spacing 13px: avoid text-xs/text-sm on editable inputs (iOS zoom)
sm: "h-2.5 px-1",
default: "h-3 px-1",
lg: "h-4 px-1.5",
},
},
compoundVariants: [
{
position: "first",
orientation: "vertical",
class: "ms-0 rounded-e-md rounded-b-none border-l",
},
{
position: "middle",
orientation: "vertical",
class: "ms-0 -mt-px rounded-none border-t-0 border-l",
},
{
position: "last",
orientation: "vertical",
class: "ms-0 -mt-px rounded-s-md rounded-t-none border-t-0 border-l",
},
],
defaultVariants: {
position: "isolated",
orientation: "horizontal",
size: "default",
},
},
);
interface SegmentedInputItemProps
extends
React.ComponentProps<"input">,
Omit<VariantProps<typeof segmentedInputItemVariants>, "size"> {
asChild?: boolean;
}
function SegmentedInputItem(props: SegmentedInputItemProps) {
const { asChild, className, position, disabled, required, ...inputProps } =
props;
const context = useSegmentedInputContext(ITEM_NAME);
const isDisabled = disabled ?? context.disabled;
const isRequired = required ?? context.required;
const ItemPrimitive = asChild ? SlotPrimitive.Slot : Input;
return (
<ItemPrimitive
aria-invalid={context.invalid}
aria-required={isRequired}
data-disabled={isDisabled ? "" : undefined}
data-invalid={context.invalid ? "" : undefined}
data-orientation={context.orientation}
data-position={position}
data-required={isRequired ? "" : undefined}
data-slot="segmented-input-item"
disabled={isDisabled}
required={isRequired}
{...inputProps}
className={cn(
segmentedInputItemVariants({
position,
orientation: context.orientation,
size: context.size,
className,
}),
)}
/>
);
}
export {
SegmentedInput,
SegmentedInputItem,
SegmentedInput as Root,
SegmentedInputItem as Item,
segmentedInputItemVariants,
type SegmentedInputProps,
type SegmentedInputItemProps,
};
Usage
FormInput
export const FormInput = () => {
const [phoneNumber, setPhoneNumber] = React.useState({
countryCode: "+1",
areaCode: "",
number: "",
});
const onSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
},
[],
);
return (
<form onSubmit={onSubmit} className="flex w-full max-w-sm flex-col gap-2">
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-12">Phone Number</label>
<SegmentedInput
className="w-full"
aria-label="Phone number input"
>
<SegmentedInputItem
placeholder="+1"
value={phoneNumber.countryCode}
onChange={(event) =>
setPhoneNumber((prev) => ({
...prev,
countryCode: event.target.value,
}))
}
className="w-5 flex-none"
aria-label="Country code"
/>
<SegmentedInputItem
placeholder="555"
value={phoneNumber.areaCode}
onChange={(event) =>
setPhoneNumber((prev) => ({
...prev,
areaCode: event.target.value,
}))
}
className="w-6 flex-none"
maxLength={3}
inputMode="numeric"
pattern="[0-9]*"
aria-label="Area code"
/>
<SegmentedInputItem
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"
/>
</SegmentedInput>
</div>
<Button type="submit" size="sm">
Submit
</Button>
</form>
);
};RgbColor
export const RgbColor = () => {
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-1">
<label className="text-sm font-medium text-gray-12">RGB Color</label>
<div className="flex items-center gap-1">
<SegmentedInput className="w-fit" aria-label="RGB color input">
<SegmentedInputItem
placeholder="255"
value={rgb.r}
onChange={onChannelChange("r")}
className="w-5 flex-none"
inputMode="numeric"
pattern="[0-9]*"
min={0}
max={255}
aria-label="Red channel (0-255)"
/>
<SegmentedInputItem
placeholder="128"
value={rgb.g}
onChange={onChannelChange("g")}
className="w-5 flex-none"
inputMode="numeric"
pattern="[0-9]*"
min={0}
max={255}
aria-label="Green channel (0-255)"
/>
<SegmentedInputItem
placeholder="0"
value={rgb.b}
onChange={onChannelChange("b")}
className="w-5 flex-none"
inputMode="numeric"
pattern="[0-9]*"
min={0}
max={255}
aria-label="Blue channel (0-255)"
/>
</SegmentedInput>
<span
className="size-3 rounded-sm border border-gray-6"
style={{ backgroundColor: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})` }}
aria-hidden
/>
</div>
</div>
);
};Vertical
export const Vertical = () => {
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 w-full max-w-sm flex-col gap-1">
<label className="text-sm font-medium text-gray-12">
Mailing Address
</label>
<SegmentedInput
aria-label="Mailing address input"
className="w-full"
orientation="vertical"
>
<SegmentedInputItem
aria-label="Street address"
placeholder="Street Address"
value={address.street}
onChange={onFieldChange("street")}
/>
<SegmentedInputItem
aria-label="City"
placeholder="City"
value={address.city}
onChange={onFieldChange("city")}
/>
<SegmentedInputItem
aria-label="ZIP code"
placeholder="ZIP Code"
value={address.zipCode}
onChange={onFieldChange("zipCode")}
/>
</SegmentedInput>
<p className="text-xs text-gray-11">
Tab between fields to move through the vertical segments.
</p>
</div>
);
};Sizes
export const Sizes = () => (
<div className="flex w-full max-w-sm flex-col gap-2">
<SegmentedInput size="sm" className="w-full" aria-label="Small size">
<SegmentedInputItem placeholder="Small" aria-label="Small first" />
<SegmentedInputItem placeholder="Small" aria-label="Small second" />
</SegmentedInput>
<SegmentedInput size="default" className="w-full" aria-label="Default size">
<SegmentedInputItem placeholder="Default" aria-label="Default first" />
<SegmentedInputItem placeholder="Default" aria-label="Default second" />
</SegmentedInput>
<SegmentedInput size="lg" className="w-full" aria-label="Large size">
<SegmentedInputItem placeholder="Large" aria-label="Large first" />
<SegmentedInputItem placeholder="Large" aria-label="Large second" />
</SegmentedInput>
</div>
)Invalid
export const Invalid = () => (
<div className="flex w-full max-w-sm flex-col gap-1">
<label className="text-sm font-medium text-gray-12">Invalid state</label>
<SegmentedInput invalid className="w-full" aria-label="Invalid segments">
<SegmentedInputItem defaultValue="12" aria-label="Part one" />
<SegmentedInputItem defaultValue="ab" aria-label="Part two" />
<SegmentedInputItem defaultValue="" placeholder="Required" aria-label="Part three" />
</SegmentedInput>
</div>
)