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
The plain field. It is full-width, so give it a width or a cap (max-w-xs here) or it takes whatever its container happens to be.
Sizes
Heights are 24/28/32/40, the same ramp and the same names as Button, so the same size on 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 field and its submit, stacked at the same width.
With Icons
iconStart and iconEnd render decorative, non-interactive icons inside the field. Same two forms as Button: an element (<Search />) or a bare component (Search).
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 that this replaces the native HTML size attribute, which sets character width and is superseded by className="w-*". |
| iconStart | TablerIcon | ReactNode | - | Decorative, non-interactive icon rendered at the leading edge of the field. Prefer an element (<Search />); a bare component (Search) still works but cannot be passed from a Server Component. |
| iconEnd | TablerIcon | ReactNode | - | Decorative, non-interactive icon rendered at the trailing edge of the field. Prefer an element (<Mail />); a bare component still works but cannot be passed from a Server Component. |
| 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 { cn } from "@/registry/lib/utils"
import { renderIconProp, type IconProp } from "@/registry/lib/icon-prop"
// `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"
/** Decorative, non-interactive icon at the leading edge. Takes an ELEMENT
* (`<Search />`) or a bare component (`Search`), same as Button. Prefer the
* element: it is the only form that can be passed from a Server Component. */
iconStart?: IconProp
/** Decorative, non-interactive icon at the trailing edge. Same two forms as
* `iconStart`. */
iconEnd?: IconProp
/** 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
// Positioning, size, and colour are the field's business, not the caller's, so
// they are applied to the icon in either form. `renderIconProp` puts them after
// an element's own className, so these win the merge.
const ICON_CLASSES =
"absolute top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground pointer-events-none z-10"
// @use-when a single-line text field.
function Input({ className, type, variant = "default", size = "default", iconStart, 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)}>
{renderIconProp(iconStart, {
"aria-hidden": "true",
className: cn(ICON_CLASSES, "left-3"),
})}
<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}
/>
{renderIconProp(iconEnd, {
"aria-hidden": "true",
className: cn(ICON_CLASSES, "right-3"),
})}
{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 }