Input
A text input field with optional start/end icon decoration and an interactive in-field action slot.
Installation
npx shadcn@latest add https://ui.mattdowney.com/r/input.jsonUsage
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.
States
Default, read-only and disabled side by side. Read-only sits flat on the muted fill at full opacity and still takes focus, so its value can be selected and copied; disabled dims to 70% and does not.
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, radius, and adornment icon size (12/14/14/16px, Button's ramp) 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. Its glyph is resized to the field's icon size, the same as iconStart. |
| 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 text-muted-foreground pointer-events-none z-10"
// The icon box follows Button's ramp for the same size name (12 / 14 / 14 / 16
// across xs / sm / default / lg), so a field and the button under it carry the
// same glyph. It was a flat 12px at every size, which read tiny next to 14px
// type in a 32px field. `xs` holds at 12 for the reason Button's does: its box
// is 24px and the size exists for dense rows.
//
// Each entry also carries the text clearance that box implies. Clearance is
// where the adornment ends plus the kit's 8px icon-to-text gap (`gap-2` on
// Select, Combobox, Dropdown items, Nav). An icon sits at `left-3`/`right-3`,
// so text starts at 12 + icon + 8: 32 / 34 / 34 / 36px. Derived, not
// eyeballed: an older 28/28/40 read as 4px, 4px, and 12px gaps.
const ICON_SIZE = {
xs: { icon: "size-3", action: "[&_svg]:size-3!", start: "pl-8", end: "pr-8" },
sm: { icon: "size-3.5", action: "[&_svg]:size-3.5!", start: "pl-8.5", end: "pr-8.5" },
default: { icon: "size-3.5", action: "[&_svg]:size-3.5!", start: "pl-8.5", end: "pr-8.5" },
lg: { icon: "size-4", action: "[&_svg]:size-4!", start: "pl-9", end: "pr-9" },
} as const
// @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
const iconSize = ICON_SIZE[size]
// 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). Read-only sits flat on the
// muted fill at full opacity: it can still be focused, selected and copied,
// so it must not look disabled, but it must not look typeable either.
const defaultClasses = cn(
"border-border border bg-input-bg backdrop-blur-sm shadow-xs transition-[color,box-shadow,border-color] duration-small ease-small focus-visible:border-accent focus-visible:ring-ring-accent focus-visible:ring-1 aria-invalid:ring-ring-destructive aria-invalid:border-destructive [&[readonly]]:bg-muted [&[readonly]]:shadow-none",
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, iconSize.icon, "left-3"),
})}
<input
type={type}
data-slot="input"
className={cn(
inputClasses,
// Icon clearance comes from ICON_SIZE above. An action's clearance
// does not move with the ramp: an `icon-sm` button is a 24px box at
// every field size, so at `right-1` it ends at 28 and text stops at
// 36 whatever glyph it holds.
hasIconStart && iconSize.start,
hasIconEnd && iconSize.end,
hasActionEnd && "pr-9"
)}
{...props}
/>
{renderIconProp(iconEnd, {
"aria-hidden": "true",
className: cn(ICON_CLASSES, iconSize.icon, "right-3"),
})}
{actionEnd && (
// The glyph inside the action takes the field's icon size, so a
// reveal-password eye matches a leading search icon in the same field.
// `!` because the Button inside sets its own `[&_svg]:size-*` at the
// same specificity, and which of two equal rules wins is compile order.
<div className={cn("absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center", iconSize.action)}>
{actionEnd}
</div>
)}
</div>
)
}
export { Input }
export type { InputProps }