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
data-invalid={invalid}
aria-invalid={invalid}
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