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';
// Shared visual frame for both standalone `<Input>` and `<Input.Group>`: the
// border, focus ring, invalid/disabled states, variant colors, height, and
// border-radius. The two CVAs below specialize this with size variants that
// either include or exclude horizontal padding (`px-*`) and `gap-*`.
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',
],
};
// Vertical inset uses `--inset` (= `--radius * 2`) so the group's vertical
// breathing room scales with the radius dial β keeping addon/stepper corners
// concentric across the dial, not just at the default `--radius`.
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 inputHorizontalPaddingBySize = {
xs: 'gap-1 px-2',
sm: 'gap-1 px-3',
md: 'gap-2 px-4',
lg: 'gap-2 px-5',
};
// Standalone <Input> β owns its own horizontal padding (text inset).
// Also exported for primitives that style their own native control with
// input-like chrome (Textarea, OTPInput, DatePicker trigger, Listbox search,
// Select).
const inputStyle = cva({
base: inputFrameBase,
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',
},
});
// Group wrapper β same frame, no horizontal padding. Addons own outer padding
// at the edges; the in-group input owns its own text padding.
const inputGroupStyle = cva({
base: inputFrameBase,
variants: {
variant: inputFrameVariants,
size: inputFrameSize,
},
defaultVariants: {
variant: 'default',
size: 'md',
},
});
// Adjacent-to-addon: tighten the input's `px` by one step on the side that
// touches an addon, so the addon-to-text gap doesn't read as a doubled
// `px-{size}`. `[[data-input-addon]+&]:pl-*` matches when an addon precedes
// the input; `[&:has(+[data-input-addon])]:pr-*` matches when one follows.
const inputInGroupStyle = cva({
base: ['h-full w-full min-w-8 cursor-[inherit] outline-none'],
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',
},
});
// Addons carry padding only on their outer edge (the side that touches the
// group's border). The inner edge has none β the input's own `px-{size}`
// provides the gap to its content. This prevents addon `px` and input `px`
// from stacking and creating an oversized icon-to-text gap.
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);
// Inside a group, sizing is inherited from the group context (the group decides
// the size, the input fills it). Standalone, the input picks size/variant from
// its own props.
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);
// Static addons (icons, text) pass clicks through to focus the input β the
// expected behavior for "leading icon" / "currency prefix" patterns. If the
// click hit something interactive, let it handle the click instead. For
// `asChild`, Slot's prop merge gives the child's `onClick` precedence over
// ours, so interactive composed elements keep their own semantics.
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>
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>
);
} 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