Input
A simple text input component.
import { Input } from "@/components/input";
export default function InputExample() {
return (
<div className="w-90">
<Input placeholder="Type something..." />
</div>
);
}Dependencies
Source Code
import type { VariantProps } from "cva";
import { createContext, use, useRef } from "react";
import { Slot } from "@/components/slot";
import { composeRefs } from "@/lib/compose-refs";
import { cn, cva } from "@/lib/utils/classnames";
const inputFrameBase = [
"w-full",
"font-medium [&,&_*]:placeholder:text-foreground-secondary",
"border outline-none",
"transition",
"flex items-center",
"[:focus-visible,:has(:focus-visible)]:ring-(length:--ring-width) ring-ring [:focus-visible,:has(:focus-visible)]:border-accent [:focus-visible,:has(:focus-visible)]:text-foreground",
"[:disabled,:has(input:disabled)]:cursor-not-allowed [:disabled,:has(input:disabled)]:opacity-50",
"[[data-invalid],:has([data-invalid])]:border-error! [[data-invalid],:has([data-invalid])]:ring-error/20 [[data-invalid],:has([data-invalid])]:hover:border-error",
];
const inputFrameVariants = {
default: [
"border-border shadow-xs hover:border-[color-mix(in_oklab,var(--color-border),var(--color-foreground)_8%)]",
],
minimal: [
"border-transparent bg-transparent hover:bg-background-secondary [:focus-visible,:has(:focus-visible)]:bg-background",
],
};
const inputFrameSize = {
xs: "h-6 rounded-lg text-sm",
sm: "h-8 rounded-lg text-sm",
md: "h-10 py-(--inset) rounded-xl text-base",
lg: "h-12 py-(--inset) rounded-2xl text-base",
};
const inputElementStyles = [
"file:mr-3 file:inline-flex file:h-full file:items-center file:cursor-pointer file:border-0 file:bg-transparent file:p-0 file:font-medium file:text-foreground",
"[&::-webkit-calendar-picker-indicator]:hidden",
"[&[type=date],&[type=time],&[type=datetime-local],&[type=month],&[type=week],&[type=file]]:py-0",
];
const inputHorizontalPaddingBySize = {
xs: "gap-1 px-2",
sm: "gap-1 px-3",
md: "gap-2 px-4",
lg: "gap-2 px-5",
};
const inputStyle = cva({
base: [...inputFrameBase, ...inputElementStyles],
variants: {
variant: inputFrameVariants,
size: {
xs: cn(inputFrameSize.xs, inputHorizontalPaddingBySize.xs),
sm: cn(inputFrameSize.sm, inputHorizontalPaddingBySize.sm),
md: cn(inputFrameSize.md, inputHorizontalPaddingBySize.md),
lg: cn(inputFrameSize.lg, inputHorizontalPaddingBySize.lg),
},
},
defaultVariants: {
variant: "default",
size: "md",
},
});
const inputGroupStyle = cva({
base: inputFrameBase,
variants: {
variant: inputFrameVariants,
size: inputFrameSize,
},
defaultVariants: {
variant: "default",
size: "md",
},
});
const inputInGroupStyle = cva({
base: ["h-full w-full min-w-8 cursor-[inherit] outline-none", ...inputElementStyles],
variants: {
size: {
xs: "px-2 [&:has(+[data-input-addon])]:pr-1 [[data-input-addon]+&]:pl-1",
sm: "px-3 [&:has(+[data-input-addon])]:pr-2 [[data-input-addon]+&]:pl-2",
md: "px-4 [&:has(+[data-input-addon])]:pr-3 [[data-input-addon]+&]:pl-3",
lg: "px-5 [&:has(+[data-input-addon])]:pr-4 [[data-input-addon]+&]:pl-4",
},
},
defaultVariants: {
size: "md",
},
});
const inputAddonStyle = cva({
base: ["flex h-full shrink-0 items-center justify-center"],
variants: {
size: {
xs: "first:pl-2 last:pr-2",
sm: "first:pl-3 last:pr-3",
md: "first:pl-4 last:pr-4",
lg: "first:pl-5 last:pr-5",
},
},
defaultVariants: {
size: "md",
},
});
export type InputSize = "xs" | "sm" | "md" | "lg";
export type InputVariant = "default" | "minimal";
interface InputGroupContextValue {
size: InputSize;
variant: InputVariant;
}
const InputGroupContext = createContext<InputGroupContextValue | null>(null);
const useInputStyle = (props: VariantProps<typeof inputStyle> & { className?: string }) => {
const ctx = use(InputGroupContext);
if (ctx) {
return inputInGroupStyle({ size: ctx.size });
}
return inputStyle(props);
};
interface InputProps
extends Omit<React.ComponentPropsWithRef<"input">, "size">,
VariantProps<typeof inputStyle> {
invalid?: boolean;
}
const Input = ({ className, invalid, size, variant, ...props }: InputProps) => {
return (
<input
{...(invalid && { "data-invalid": true, "aria-invalid": true })}
className={cn(useInputStyle({ variant, size }), className)}
{...props}
/>
);
};
interface InputGroupProps
extends React.ComponentPropsWithRef<"div">,
VariantProps<typeof inputGroupStyle> {}
const InputGroup = ({
className,
size = "md",
variant = "default",
children,
onClick,
...props
}: InputGroupProps) => {
const handleGroupClick = (e: React.MouseEvent<HTMLDivElement>) => {
onClick?.(e);
// If the click is on the group container (not on an input or addon), focus the first input child.
if (!e.defaultPrevented && e.target === e.currentTarget) {
const firstInput = e.currentTarget.querySelector("input");
firstInput?.focus();
}
};
return (
<InputGroupContext value={{ size: size ?? "md", variant: variant ?? "default" }}>
{/** biome-ignore lint/a11y/useKeyWithClickEvents: intentional */}
{/** biome-ignore lint/a11y/noStaticElementInteractions: intentional */}
<div
data-ui-input-group
className={inputGroupStyle({ variant, size, className })}
onClick={handleGroupClick}
{...props}
>
{children}
</div>
</InputGroupContext>
);
};
interface InputAddonProps extends React.ComponentPropsWithRef<"div"> {
asChild?: boolean;
}
const InputAddon = ({ ref, className, asChild, onClick, ...props }: InputAddonProps) => {
const ctx = use(InputGroupContext);
const Comp = asChild ? Slot : "div";
const internalRef = useRef<HTMLDivElement | null>(null);
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
onClick?.(e);
if (e.defaultPrevented) return;
const target = e.target as HTMLElement;
if (
target.closest(
'button, a, input, select, textarea, [role="button"], [role="link"], [role="combobox"], [role="menuitem"], [role="checkbox"], [role="radio"], [role="switch"], [role="tab"]',
)
) {
return;
}
const group = e.currentTarget.closest<HTMLElement>("[data-ui-input-group]");
const focusable = group?.querySelector<HTMLElement>("input, textarea, select");
focusable?.focus();
};
return (
<Comp
data-input-addon
className={cn(inputAddonStyle({ size: ctx?.size }), className)}
ref={composeRefs(ref, internalRef)}
onClick={handleClick}
{...props}
/>
);
};
const CompoundInput = Object.assign(Input, {
Group: InputGroup,
Addon: InputAddon,
});
export type { InputGroupProps, InputProps };
export { CompoundInput as Input, inputStyle, useInputStyle };Features
- Multiple Variants: Supports default and minimal styles
- Validation States: Built-in support for invalid state
- Flexible Addons: Support for prefix and suffix elements
- Interactive Elements: Optional interactive addons for enhanced functionality
Anatomy
<Input.Group>
<Input.Addon />
<Input />
<Input.Addon />
</Input.Group>
Input is for text-like single-line controls: text, email, password, search, tel, url, number, file, and the date/time family (date, time, datetime-local, month, week). For the non-text-like input types, reach for the dedicated primitive: Checkbox, Radio, Slider (for range), ColorPicker, Switch, Toggle, OTPInput, or DatePicker when you want a full picker UI rather than native entry.
API Reference
Input
Extends the input element.
| Prop | Default | Type | Description |
|---|---|---|---|
variant | "default" | "default""minimal" | The visual style variant of the input. |
size | "md" | "xs""sm""md""lg" | The size of the input. |
invalid | - | boolean | Whether the input is in an invalid state. |
Input.Group
Extends the div element. Wraps an Input together with one or more Input.Addon siblings β useful for icon prefixes, text suffixes, password toggles, country pickers, etc.
The group owns the visual frame: border, height, focus ring, invalid/disabled state. Pass size and variant here, not on the inner Input β sizing inside the group is inherited from this context, so the inner Inputβs own size/variant props are ignored.
| Prop | Default | Type |
|---|---|---|
size | "md" | "xs""sm""md""lg" |
variant | "default" | "default""minimal" |
Input.Addon
Extends the div element. Renders flush at the groupβs edges with size-aware padding inherited from Input.Group. Drop interactive content (a <button>, a Tooltip.Trigger, a Popover.Trigger) directly inside β no pointer-events-auto ceremony needed.
Static addons (icons, text) are click-through to the input β clicking a leading lock icon focuses the input the way youβd expect. The addon detects whether the click target is interactive (button, a, input, [role="button"], etc.) and only forwards focus when it isnβt, so interactive content keeps its own click semantics.
For component-as-addon composition (a popover trigger, a country picker, etc.), pass asChild and provide your own element. The addonβs flex/sizing/padding classes are merged onto the child.
| Prop | Default | Type |
|---|---|---|
asChild | - | boolean |
Examples
Simple
Basic usage of the input component.
import { Input } from "@/components/input";
export default function InputExample() {
return (
<div className="w-90">
<Input placeholder="Type something..." />
</div>
);
}Minimal
A more subtle variant with minimal styling.
import { Input } from "@/components/input";
export default function InputMinimal() {
return (
<div className="w-90">
<Input variant="minimal" placeholder="Type something..." />
</div>
);
}Sizes
import { Input } from "@/components/input";
export default function InputSizes() {
return (
<div className="w-90 space-y-4">
<Input.Group size="xs">
<Input placeholder="Search something" />
<Input.Addon>Extra-small</Input.Addon>
</Input.Group>
<Input.Group size="sm">
<Input placeholder="Search something" />
<Input.Addon>Small</Input.Addon>
</Input.Group>
<Input.Group size="md">
<Input placeholder="Search something" />
<Input.Addon>Medium</Input.Addon>
</Input.Group>
<Input.Group size="lg">
<Input placeholder="Search something" />
<Input.Addon>Large</Input.Addon>
</Input.Group>
</div>
);
}Disabled
Example of a disabled input state. Inside a group, the disabled visual cascades to the wrapper via :has(input:disabled), so addons dim with the input.
import { Input } from "@/components/input";
export default function InputDisabled() {
return (
<div className="w-90">
<Input placeholder="Type something..." disabled />
</div>
);
}import { LockIcon } from "@phosphor-icons/react/dist/ssr";
import { Input } from "@/components/input";
export default function InputGroupDisabled() {
return (
<div className="w-90">
<Input.Group>
<Input.Addon>
<LockIcon />
</Input.Addon>
<Input placeholder="Locked" disabled />
<Input.Addon>.com</Input.Addon>
</Input.Group>
</div>
);
}Invalid
Showing validation state with an invalid input. Inside a group, the error border and ring carry across via :has([data-invalid]) β works the same whether invalid is set on the inner Input or aria-invalid is injected by Field.Control.
import { Input } from "@/components/input";
export default function InputInvalid() {
return (
<div className="w-90">
<Input placeholder="Type something..." defaultValue="Pedro" invalid />
</div>
);
}import { WarningIcon } from "@phosphor-icons/react/dist/ssr";
import { Input } from "@/components/input";
export default function InputGroupInvalid() {
return (
<div className="w-90">
<Input.Group>
<Input.Addon>
<WarningIcon />
</Input.Addon>
<Input placeholder="Type something..." defaultValue="not-an-email" invalid />
</Input.Group>
</div>
);
}Icon
Input with an icon prefix.
import { MagnifyingGlassIcon } from "@phosphor-icons/react/dist/ssr";
import { Input } from "@/components/input";
export default function InputIcon() {
return (
<div className="w-90 space-y-4">
<Input.Group>
<Input.Addon>
<MagnifyingGlassIcon />
</Input.Addon>
<Input placeholder="Search something" />
</Input.Group>
<Input.Group variant="minimal">
<Input.Addon>
<MagnifyingGlassIcon />
</Input.Addon>
<Input placeholder="Search something" />
</Input.Group>
</div>
);
}Text Addons
Using text prefixes and suffixes.
import { Input } from "@/components/input";
export default function InputTextAddons() {
return (
<div className="w-90">
<Input.Group>
<Input.Addon>https://</Input.Addon>
<Input placeholder="subdomain" />
<Input.Addon>.significa.co</Input.Addon>
</Input.Group>
</div>
);
}Icon and Action
Combining an icon prefix with an interactive suffix.
import { EyeClosedIcon, EyeIcon, LockIcon } from "@phosphor-icons/react/dist/ssr";
import { useState } from "react";
import { Input } from "@/components/input";
export default function InputIconAction() {
const [showPassword, setShowPassword] = useState(false);
return (
<div className="w-90">
<Input.Group>
<Input.Addon>
<LockIcon />
</Input.Addon>
<Input type={showPassword ? "text" : "password"} placeholder="Your password here" />
<Input.Addon asChild>
<button
type="button"
className="cursor-pointer"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeIcon /> : <EyeClosedIcon />}
</button>
</Input.Addon>
</Input.Group>
</div>
);
}Interactive Addon
Example with clickable addon elements.
import { InfoIcon } from "@phosphor-icons/react/dist/ssr";
import { useRef } from "react";
import { Input } from "@/components/input";
import { Tooltip } from "@/components/tooltip";
export default function InputInteractiveAddon() {
const input = useRef<HTMLInputElement>(null);
return (
<div className="w-90">
<Input.Group>
<Input.Addon asChild>
<button type="button" onClick={() => alert("interactive")}>
+351
</button>
</Input.Addon>
<Input ref={input} placeholder="000 000 000" />
<Input.Addon>
<Tooltip>
<Tooltip.Trigger>
<InfoIcon />
</Tooltip.Trigger>
<Tooltip.Content>Your phone number will be visible</Tooltip.Content>
</Tooltip>
</Input.Addon>
</Input.Group>
</div>
);
}Component as Addon
Composing a Menu.Trigger (or any other compound primitive) as an addon via asChild. Demonstrates a phone field with a country picker β the trigger lives inside the input frame, the menu floats from it with keyboard nav and focus management.
import { CaretDownIcon } from "@phosphor-icons/react/dist/ssr";
import { useState } from "react";
import { Input } from "@/components/input";
import { Menu } from "@/components/menu";
const countries = [
{ code: "PT", name: "Portugal", dial: "+351", flag: "π΅πΉ" },
{ code: "ES", name: "Spain", dial: "+34", flag: "πͺπΈ" },
{ code: "FR", name: "France", dial: "+33", flag: "π«π·" },
{ code: "DE", name: "Germany", dial: "+49", flag: "π©πͺ" },
{ code: "GB", name: "United Kingdom", dial: "+44", flag: "π¬π§" },
{ code: "US", name: "United States", dial: "+1", flag: "πΊπΈ" },
] as const;
type Country = (typeof countries)[number];
// Phone field with a country picker as a leading addon. Demonstrates the
// asChild β asChild chain (`Input.Addon` β `Menu.Trigger` β `<button>`)
// for composing complex interactive content into the input frame. Menu
// brings keyboard nav, focus management, and proper ARIA roles for free.
export default function InputComponentAddon() {
const [country, setCountry] = useState<Country>(countries[0]);
return (
<div className="w-90">
<Menu>
<Input.Group>
<Input.Addon asChild>
<Menu.Trigger asChild>
<button
type="button"
className="flex cursor-pointer items-center gap-1 text-foreground-secondary transition-colors hover:text-foreground"
>
<span className="text-base tabular-nums leading-none">{country.dial}</span>
<CaretDownIcon className="size-3" />
</button>
</Menu.Trigger>
</Input.Addon>
<Input type="tel" placeholder="000 000 000" />
</Input.Group>
<Menu.Items className="w-64">
{countries.map((c) => (
<Menu.Item className="justify-between" key={c.code} onSelect={() => setCountry(c)}>
<span className="flex items-center gap-2">
<span className="text-base leading-none">{c.flag}</span>
<span className="flex-1 truncate">{c.name}</span>
<span className="text-foreground-secondary text-sm">{c.dial}</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
);
}File
The native ::file-selector-button is restyled to read as inline text inside the input frame β no native border, background, or chunky chrome.
import { Input } from "@/components/input";
export default function InputFileExample() {
return (
<div className="w-90">
<Input type="file" />
</div>
);
}Date & Time
The browserβs calendar/clock picker icon is suppressed in Chromium and absent by default in Safari, so date, time, datetime-local, month, and week render as a clean text frame in both. Firefox keeps its native calendar button on date / datetime-local / month / week (it doesnβt expose a pseudo-element to hide it) β reach for DatePicker when you need a unified picker UI across browsers.
import { CalendarIcon, ClockIcon } from "@phosphor-icons/react/dist/ssr";
import { Input } from "@/components/input";
export default function InputDateTime() {
return (
<div className="w-90 space-y-4">
<div className="flex gap-2">
<Input type="date" defaultValue="2026-05-29" />
<Input type="time" defaultValue="10:30" />
</div>
<Input.Group>
<Input.Addon>
<CalendarIcon />
</Input.Addon>
<Input type="date" defaultValue="2026-05-29" />
</Input.Group>
<Input.Group>
<Input.Addon>
<ClockIcon />
</Input.Addon>
<Input type="time" defaultValue="10:30" step="1" />
</Input.Group>
</div>
);
}Best Practices
-
Validation:
- Provide clear error messages
- Use appropriate input types
-
Addons:
- Keep icons and text addons relevant
- Ensure interactive elements are clearly clickable
- Maintain consistent spacing
Previous
File Upload
Next
Kbd