Input
A text input field with optional start/end icon decoration and an interactive in-field action slot.
Installation
$
Usage
import { Input } from "@/components/ui/input"Examples
Default
Sizes
Heights are 24/28/32/40, the same ramp and the same names as Button, so passing the same size to a field and the button beside it gives one height and one corner radius. The pair at the bottom is the case this exists for: a form control used to sit a tier squarer than an action, which read as a mistake the moment the two were stacked at the same width.
With Icons
iconStart and iconEnd render decorative, non-interactive icons inside the field.
With Action
actionEnd renders an interactive control inside the field: a copy, clear, or reveal-password button. Pass an icon-sm Button.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "default" | "ghost" | "default" | default is the bordered form field. ghost is borderless and transparent with no forced height or font-size and minimal focus, for title composers and inline-edit fields; size it via className. |
| size | "xs" | "sm" | "default" | "lg" | "default" | Control height (24/28/32/40), using Button's size names so the same size on a field and the button beside it gives the same height and the same corner radius. Height, side padding, and radius move together. Ignored by ghost, which has no chrome. Note this REPLACES the native HTML size attribute, which sets character width and is superseded by className="w-*". |
| iconStart | LucideIcon | - | Decorative, non-interactive icon rendered at the leading edge of the field. Pass the component itself (Search), not an element. |
| iconEnd | LucideIcon | - | Decorative, non-interactive icon rendered at the trailing edge of the field. Pass the component itself (Mail), not an element. |
| actionEnd | React.ReactNode | - | Interactive element rendered inside the field at the trailing edge: a copy, clear, or reveal-password button. Pass an icon-sm Button. |
| className | string | - | Applied to the input itself, or to the wrapper div when any icon/action adornment is present. |
import * as React from "react"
import type { LucideIcon } from "lucide-react"
import { cn } from "@/registry/lib/utils"
// `size` is omitted from the native props on purpose: HTML's own `size` is a
// NUMBER (visible character width) and would collide with the control-height
// prop below. The native one is near-useless next to `className="w-*"`, so the
// design-system meaning wins the name.
type InputProps = Omit<React.ComponentProps<"input">, "size"> & {
/**
* Visual treatment:
* - "default" → the standard bordered form field (h-8, bg, shadow-xs).
* - "ghost" → borderless/transparent, no forced height or font-size, minimal
* focus (no ring/border swap). Size it via `className`
* (e.g. `text-2xl h-auto`). For title composers and inline-edit fields.
*/
variant?: "default" | "ghost"
/**
* Control height, matching Button's ramp name for name, so an Input and a
* Button given the same `size` are the same height and can sit in one row or
* one stacked form without a hand-tuned `className`. Ignored by `ghost`,
* which has no chrome and is sized by the caller.
*/
size?: "xs" | "sm" | "default" | "lg"
iconStart?: LucideIcon
iconEnd?: LucideIcon
/** Interactive element rendered inside the field at the trailing edge - e.g.
* a copy, clear, or reveal-password button. Unlike `iconEnd` (decorative,
* non-interactive), this receives pointer events. Pass a Button/icon-button. */
actionEnd?: React.ReactNode
}
// Height + side padding + radius move together, exactly as they do on Button:
// radius reads the action ramp (3/8 of the height) so a field and the button
// under it are the same shape, and padding steps a constant 4px. A form
// control is not a squarer tier than an action any more; one control language.
// Type holds at the `default` step from `sm` up, because a text field you type
// into should not shrink its content below 13px just to fit a shorter box.
const SIZE_CLASSES = {
// `text-base` on mobile at every size, including this one: iOS zooms the
// viewport on focus for any field under 16px, and no height is worth that.
// The step-down is desktop-only.
xs: "h-6 px-2 rounded-action-xs text-base md:text-1xs",
sm: "h-7 px-3 rounded-action-sm text-base md:text-sm",
default: "h-8 px-3 rounded-action text-base md:text-sm",
lg: "h-10 px-4 rounded-action-lg text-base md:text-sm",
} as const
function Input({ className, type, variant = "default", size = "default", iconStart: IconStart, iconEnd: IconEnd, actionEnd, ...props }: InputProps) {
const hasIconStart = !!IconStart
const hasIconEnd = !!IconEnd
const hasActionEnd = !!actionEnd
// Shared by both variants: color/placeholder/selection, full-width sizing,
// native outline reset, file-input reset, and disabled state.
const commonClasses = "file:text-foreground placeholder:text-subtle-foreground selection:bg-accent selection:text-accent-foreground w-full min-w-0 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70"
// default: the bordered form-control chrome (fixed height, fill, shadow,
// accent focus ring, destructive invalid state).
const defaultClasses = cn(
"border-border border bg-input-bg backdrop-blur-sm shadow-xs transition-[color,box-shadow,border-color] duration-150 focus-visible:border-accent focus-visible:ring-ring-accent focus-visible:ring-1 aria-invalid:ring-ring-destructive aria-invalid:border-destructive",
SIZE_CLASSES[size]
)
// ghost: no chrome. No forced height or font-size (caller sizes via className),
// no ring/border on focus. Flush left (px-0); adornment clearance still applies.
const ghostClasses = "border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
const inputClasses = cn(commonClasses, variant === "ghost" ? ghostClasses : defaultClasses)
// Without any adornment, apply className directly to input
if (!hasIconStart && !hasIconEnd && !hasActionEnd) {
return (
<input
type={type}
data-slot="input"
className={cn(inputClasses, className)}
{...props}
/>
)
}
// With an adornment, className goes on the wrapper; input gets clearance padding
return (
<div className={cn("relative", className)}>
{IconStart && (
<IconStart aria-hidden="true" className="absolute left-3 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground pointer-events-none z-10" />
)}
<input
type={type}
data-slot="input"
className={cn(
inputClasses,
// Clearance = where the adornment ends + the kit's 8px icon-to-text
// gap (`gap-2` on Select, Combobox, Dropdown items, Nav). A 12px icon
// at `left-3` ends at 24, so text starts at 32; an `icon-sm` action at
// `right-1` ends at 28, so text stops at 36. Derived, not eyeballed:
// these were 28/28/40, which read as 4px, 4px, and 12px gaps.
hasIconStart && "pl-8",
hasIconEnd && "pr-8",
hasActionEnd && "pr-9"
)}
{...props}
/>
{IconEnd && (
<IconEnd aria-hidden="true" className="absolute right-3 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground pointer-events-none z-10" />
)}
{actionEnd && (
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center">
{actionEnd}
</div>
)}
</div>
)
}
export { Input }
export type { InputProps }