A text input with three sizes, invalid and read-only states, native validation styling and a 16px touch font so phones never zoom in.
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function InputDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="input-demo-email">Email</Label>
<Input
id="input-demo-email"
type="email"
autoComplete="email"
placeholder="[email protected]"
/>
</div>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/input.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react class-variance-authority cn - 3
Copy and paste the following code into your project.
components/ui/input.tsx "use client" import * as React from "react" import { Input as InputPrimitive } from "@base-ui/react/input" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { useAutosize } from "@/hooks/use-autosize" import { useComposedRef } from "@/hooks/use-composed-ref" import { useInvalidShake } from "@/hooks/use-invalid-shake" import { useMergedRef } from "@/hooks/use-merged-ref" import { prefersReducedMotion } from "@/lib/motion" import { NumberFlow } from "@/components/ui/number-flow" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } const inputVariants = cva( "w-full min-w-0 rounded-md bg-transparent text-sm text-foreground inset-ring-(length:--hairline) inset-ring-input transition-[color,background-color,box-shadow] duration-150 ease-out-cubic outline-none selection:bg-primary selection:text-primary-foreground file:me-3 file:inline-flex file:h-full file:items-center file:border-0 file:bg-transparent file:p-0 file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground placeholder:transition-opacity placeholder:duration-150 user-invalid:inset-ring-destructive focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden focus-visible:placeholder:opacity-70 user-invalid:focus-visible:ring-destructive/70 user-invalid:focus-visible:inset-ring-destructive disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:inset-ring-destructive aria-invalid:focus-visible:ring-destructive/70 aria-invalid:focus-visible:inset-ring-destructive data-invalid:inset-ring-destructive data-invalid:focus-visible:ring-destructive/70 data-invalid:focus-visible:inset-ring-destructive data-shake:motion-safe:animate-button-shake motion-reduce:transition-none dark:bg-input/30 dark:scheme-dark dark:user-invalid:focus-visible:ring-destructive/70 dark:aria-invalid:focus-visible:ring-destructive/70 dark:data-invalid:focus-visible:ring-destructive/70 forced-colors:border pointer-coarse:text-[max(16px,1rem)] [&::-webkit-search-cancel-button]:cursor-pointer [&::-webkit-search-cancel-button]:opacity-50 [&::-webkit-search-cancel-button]:transition-opacity [&::-webkit-search-cancel-button:hover]:opacity-100 [&[readonly]]:bg-muted/40 dark:[&[readonly]]:bg-input/20 [@media(hover:hover)]:hover:not-focus-visible:not-disabled:not-[[readonly]]:not-aria-invalid:not-data-invalid:not-user-invalid:inset-ring-ring/70", { variants: { size: { sm: "h-8 px-2.5 file:pe-2.5", default: "h-9 px-3", lg: "h-10 px-3.5", }, }, defaultVariants: { size: "default", }, } ) type InputControl = HTMLInputElement | HTMLTextAreaElement type InputProps = Omit<InputPrimitive.Props, "size"> & VariantProps<typeof inputVariants> & { htmlSize?: number shake?: boolean } function Input({ className, size = "default", htmlSize, shake = true, ref, ...props }: InputProps) { const resolvedSize = size ?? "default" const [inputRef, setRef] = useComposedRef<HTMLInputElement>(ref) useInvalidShake(inputRef, shake) return ( <InputPrimitive ref={setRef} data-slot="input" data-size={resolvedSize} size={htmlSize} className={mergeClassName( inputVariants({ size: resolvedSize }), className )} {...props} /> ) } function defaultAnnouncement(remaining: number) { return remaining === 0 ? "Character limit reached" : `${remaining} ${remaining === 1 ? "character" : "characters"} left` } type InputCountProps = Omit<React.ComponentProps<"span">, "children"> & { length: number maxLength?: number | null controlRef?: React.RefObject<InputControl | null> threshold?: number announcement?: (remaining: number) => string } function InputCount({ className, length, maxLength = null, controlRef, threshold, announcement = defaultAnnouncement, ref, ...props }: InputCountProps) { const max = maxLength !== null && maxLength > 0 ? maxLength : null const remaining = max === null ? null : Math.max(0, max - length) const near = max === null ? Infinity : Math.max(1, threshold ?? Math.ceil(Math.min(max * 0.1, 20))) const state = remaining === null ? undefined : remaining === 0 ? "limit" : remaining <= near ? "near" : undefined const [message, setMessage] = React.useState("") const [lastState, setLastState] = React.useState(state) if (state !== lastState) { setLastState(state) setMessage(state && remaining !== null ? announcement(remaining) : "") } const [countRef, setCountRef] = useComposedRef<HTMLSpanElement>(ref) React.useEffect(() => { const element = controlRef?.current const count = countRef.current if (!element || !count || max === null) { return } let timer: ReturnType<typeof setTimeout> | undefined const onBeforeInput = (event: Event) => { const input = event as InputEvent if ( !input.inputType?.startsWith("insert") || element.value.length < max || element.selectionStart !== element.selectionEnd || prefersReducedMotion() ) { return } count.removeAttribute("data-bump") void count.offsetWidth count.setAttribute("data-bump", "") clearTimeout(timer) timer = setTimeout(() => count.removeAttribute("data-bump"), 400) } element.addEventListener("beforeinput", onBeforeInput) return () => { element.removeEventListener("beforeinput", onBeforeInput) clearTimeout(timer) } }, [controlRef, max]) return ( <span ref={setCountRef} data-slot="input-count" data-state={state} className={cn( "inline-flex shrink-0 items-baseline text-xs text-muted-foreground tabular-nums transition-colors duration-200 ease-out-cubic data-bump:animate-button-shake data-[state=limit]:text-destructive data-[state=near]:text-foreground", className )} {...props} > <span aria-hidden="true" dir="ltr" className="inline-flex items-baseline"> <NumberFlow value={length} /> {max !== null && <span>/{max}</span>} </span> <span role="status" className="sr-only"> {message} </span> </span> ) } export { Input, InputCount, useAutosize, inputVariants, useComposedRef, useInvalidShake, useMergedRef, } export type { InputControl, InputCountProps, InputProps }components/ui/number-flow.tsx "use client" import * as React from "react" import { cn } from "cn" import { easeSpring } from "@/lib/motion" type NumberFlowTrend = "auto" | "up" | "down" | "shortest" type NumberFlowChar = { key: string section: number order: number value: string digit: number | null } type NumberFlowItem = NumberFlowChar & { exiting: boolean exitId: number } type NumberFlowProps = Omit< React.ComponentProps<"span">, "children" | "prefix" > & { value: number locales?: Intl.LocalesArgument format?: Intl.NumberFormatOptions prefix?: string suffix?: string trend?: NumberFlowTrend duration?: number easing?: string animated?: boolean onAnimationsStart?: () => void onAnimationsFinish?: () => void } const rows = 30 const restingClasses = "data-[digit='0']:translate-y-[-33.3333%] data-[digit='1']:translate-y-[-36.6667%] data-[digit='2']:translate-y-[-40%] data-[digit='3']:translate-y-[-43.3333%] data-[digit='4']:translate-y-[-46.6667%] data-[digit='5']:translate-y-[-50%] data-[digit='6']:translate-y-[-53.3333%] data-[digit='7']:translate-y-[-56.6667%] data-[digit='8']:translate-y-[-60%] data-[digit='9']:translate-y-[-63.3333%]" function getSpinDelta( from: number, to: number, trend: Exclude<NumberFlowTrend, "auto"> ) { let delta = to - from if (trend === "up") { if (delta < 0) { delta += 10 } } else if (trend === "down") { if (delta > 0) { delta -= 10 } } else if (delta > 5) { delta -= 10 } else if (delta < -4) { delta += 10 } return delta } function digitGlyphs( formatter: Intl.NumberFormat, locales: Intl.LocalesArgument ) { const { numberingSystem } = formatter.resolvedOptions() const plain = new Intl.NumberFormat(locales, { numberingSystem, useGrouping: false, }) return Array.from({ length: 10 }, (_, digit) => plain.format(digit)) } function formatChars( value: number, locales: Intl.LocalesArgument, format: Intl.NumberFormatOptions | undefined, prefix: string | undefined, suffix: string | undefined ) { const formatter = new Intl.NumberFormat(locales, format) const glyphs = digitGlyphs(formatter, locales) const parts = formatter.formatToParts(value) const integerCount = parts .filter((part) => part.type === "integer") .reduce((count, part) => count + Array.from(part.value).length, 0) const chars: NumberFlowChar[] = [] const occurrences = new Map<string, number>() let integerIndex = 0 let fractionIndex = 0 let seenNumber = false function symbol(section: number, type: string, text: string) { const name = `${section}-${type}` const occurrence = occurrences.get(name) ?? 0 occurrences.set(name, occurrence + 1) chars.push({ key: `s${name}-${occurrence}`, section, order: occurrence, value: text, digit: null, }) } if (prefix) { chars.push({ key: "prefix", section: 0, order: 0, value: prefix, digit: null, }) } for (const part of parts) { if (part.type === "integer") { seenNumber = true for (const glyph of Array.from(part.value)) { const place = integerCount - 1 - integerIndex const digit = glyphs.indexOf(glyph) chars.push({ key: `i${place}`, section: 2, order: -place, value: glyph, digit: digit === -1 ? null : digit, }) integerIndex += 1 } } else if (part.type === "group") { const place = integerCount - 1 - integerIndex chars.push({ key: `g${place}`, section: 2, order: -place - 0.5, value: part.value, digit: null, }) } else if (part.type === "decimal") { seenNumber = true chars.push({ key: "decimal", section: 3, order: 0, value: part.value, digit: null, }) } else if (part.type === "fraction") { for (const glyph of Array.from(part.value)) { const digit = glyphs.indexOf(glyph) chars.push({ key: `f${fractionIndex}`, section: 4, order: fractionIndex, value: glyph, digit: digit === -1 ? null : digit, }) fractionIndex += 1 } } else { symbol(seenNumber ? 5 : 1, part.type, part.value) } } if (suffix) { chars.push({ key: "suffix", section: 6, order: 0, value: suffix, digit: null, }) } return chars } function signature(chars: NumberFlowChar[]) { return chars.map((char) => `${char.key}:${char.value}`).join("|") } function mergeItems(previous: NumberFlowItem[], next: NumberFlowChar[]) { const keys = new Set(next.map((char) => char.key)) const kept = previous .filter((item) => !keys.has(item.key)) .map((item) => item.exiting ? item : { ...item, exiting: true, exitId: item.exitId + 1 } ) const fresh = next.map((char) => { const before = previous.find((item) => item.key === char.key) return { ...char, exiting: false, exitId: before?.exitId ?? 0 } }) return [...kept, ...fresh].sort( (a, b) => a.section - b.section || a.order - b.order ) } function subscribeReducedMotion(callback: () => void) { if (typeof window.matchMedia !== "function") { return () => {} } const query = window.matchMedia("(prefers-reduced-motion: reduce)") query.addEventListener("change", callback) return () => query.removeEventListener("change", callback) } function getReducedMotion() { return ( typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } function canAnimate() { return ( typeof Element !== "undefined" && typeof Element.prototype.animate === "function" ) } function liveIndex(column: HTMLElement) { const height = column.offsetHeight if (!height) { return null } const translate = getComputedStyle(column).translate const y = translate === "none" ? "0" : (translate.split(" ")[1] ?? "0") const amount = parseFloat(y) || 0 return y.endsWith("%") ? (-amount / 100) * rows : (-amount / height) * rows } function NumberFlow({ value, locales = "en-US", format, prefix, suffix, trend = "auto", duration = 600, easing = easeSpring, animated = true, onAnimationsStart, onAnimationsFinish, className, ...props }: NumberFlowProps) { const chars = formatChars(value, locales, format, prefix, suffix) const key = signature(chars) const reducedMotion = React.useSyncExternalStore( subscribeReducedMotion, getReducedMotion, () => false ) const animate = animated && !reducedMotion && canAnimate() const [state, setState] = React.useState(() => ({ key, items: chars.map((char) => ({ ...char, exiting: false, exitId: 0 })), })) if (state.key !== key) { setState({ key, items: animate ? mergeItems(state.items, chars) : chars.map((char) => ({ ...char, exiting: false, exitId: 0 })), }) } const rootRef = React.useRef<HTMLSpanElement>(null) const previousRef = React.useRef<Map< string, { digit: number | null; exiting: boolean; exitId: number } > | null>(null) const valueRef = React.useRef(value) const batchRef = React.useRef(0) const callbacksRef = React.useRef({ onAnimationsStart, onAnimationsFinish }) const finishExitRef = React.useRef((itemKey: string, exitId: number) => { setState((current) => ({ ...current, items: current.items.filter( (item) => !(item.key === itemKey && item.exiting && item.exitId === exitId) ), })) }) React.useLayoutEffect(() => { callbacksRef.current = { onAnimationsStart, onAnimationsFinish } }) React.useLayoutEffect(() => { const root = rootRef.current const previous = previousRef.current const current = new Map( state.items.map((item) => [ item.key, { digit: item.digit, exiting: item.exiting, exitId: item.exitId }, ]) ) const previousValue = valueRef.current previousRef.current = current valueRef.current = value if (!root || !previous || !animate) { return } const direction = trend !== "auto" ? trend : value > previousValue ? "up" : value < previousValue ? "down" : "shortest" const timing = { duration, easing } const started: Animation[] = [] const cells = root.querySelectorAll<HTMLElement>( ":scope > [data-number-flow-key]" ) for (const cell of cells) { const itemKey = cell.dataset.numberFlowKey! const item = current.get(itemKey) const before = previous.get(itemKey) if (!item || typeof cell.animate !== "function") { continue } const column = cell.querySelector<HTMLElement>( "[data-slot=number-flow-column]" ) const entering = !item.exiting && (!before || before.exiting) const leaving = item.exiting && (!before || !before.exiting) if (entering || leaving) { const running = cell.getAnimations() const fromWidth = running.length ? cell.getBoundingClientRect().width : entering ? 0 : cell.getBoundingClientRect().width const fromOpacity = running.length ? Number(getComputedStyle(cell).opacity) : entering ? 0 : 1 running.forEach((animation) => animation.cancel()) const toWidth = entering ? cell.getBoundingClientRect().width : 0 const animation = cell.animate( [ { width: `${fromWidth}px`, opacity: fromOpacity }, { width: `${toWidth}px`, opacity: entering ? 1 : 0 }, ], { ...timing, fill: leaving ? "forwards" : "none" } ) started.push(animation) if (leaving) { const exitId = item.exitId animation.finished.then( () => finishExitRef.current(itemKey, exitId), () => {} ) } } if (!column || item.digit === null || item.exiting) { continue } const target = item.digit const running = column.getAnimations() const live = running.length ? liveIndex(column) : null running.forEach((animation) => animation.cancel()) const from = live !== null ? live - 10 : entering ? 0 : before && before.digit !== null ? before.digit : target if (live === null && from === target && !entering) { continue } const delta = getSpinDelta( from, target, entering && live === null ? "up" : direction ) if (delta === 0) { continue } started.push( column.animate( [ { translate: `0 ${(-(target + 10 - delta) / rows) * 100}%` }, { translate: `0 ${(-(target + 10) / rows) * 100}%` }, ], timing ) ) } if (!started.length) { return } batchRef.current += 1 const batch = batchRef.current callbacksRef.current.onAnimationsStart?.() Promise.all(started.map((animation) => animation.finished)).then( () => { if (batch === batchRef.current) { callbacksRef.current.onAnimationsFinish?.() } }, () => {} ) }, [state.items, animate, duration, easing, trend, value]) return ( <span ref={rootRef} dir="ltr" className={cn("inline-block max-w-full tabular-nums", className)} {...props} data-slot="number-flow" > <span data-slot="number-flow-text" className="sr-only"> {state.items .filter((item) => !item.exiting) .map((item) => item.value) .join("")} </span> {state.items.map((item) => item.digit === null ? ( <span key={item.key} data-number-flow-key={item.key} data-slot="number-flow-symbol" aria-hidden className="inline-block max-w-full overflow-clip wrap-anywhere whitespace-pre-wrap" > {item.value} </span> ) : ( <span key={item.key} data-number-flow-key={item.key} data-slot="number-flow-digit" aria-hidden className="relative -my-[0.2em] inline-block overflow-clip [mask-image:linear-gradient(to_bottom,transparent,black_0.2em,black_calc(100%-0.2em),transparent)] py-[0.2em]" > <span className="opacity-0">{item.value}</span> <span aria-hidden data-slot="number-flow-column" data-digit={item.digit} className={cn( "pointer-events-none absolute inset-x-0 top-0 flex flex-col items-center select-none", restingClasses )} > {Array.from({ length: rows }, (_, row) => ( <span key={row} className="block py-[0.2em]"> {glyphFor(item, row)} </span> ))} </span> </span> ) )} </span> ) } function glyphFor(item: NumberFlowItem, row: number) { return String.fromCodePoint( item.value.codePointAt(0)! - (item.digit ?? 0) + (row % 10) ) } export { NumberFlow, getSpinDelta, formatChars, type NumberFlowProps, type NumberFlowTrend, }lib/motion.ts import * as React from "react" const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)" const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)" const easeSpring = "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)" const duration = { press: 100, release: 200, hover: 150, enter: 200, exit: 150, morph: 300, } as const function prefersReducedMotion() { return ( typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } type SizeAxis = "width" | "height" type SizeMorphOptions = { axis: SizeAxis enabled?: boolean duration?: number easing?: string } function readSize(element: HTMLElement, axis: SizeAxis) { const value = parseFloat(getComputedStyle(element)[axis]) if (Number.isFinite(value)) { return value } const rect = element.getBoundingClientRect() return axis === "width" ? rect.width : rect.height } function attachSizeMorph( element: HTMLElement, axis: SizeAxis, time: number, easing: string ) { if ( typeof MutationObserver === "undefined" || typeof element.animate !== "function" ) { return undefined } let settled = readSize(element, axis) let animation: Animation | null = null const morph = () => { const from = animation ? readSize(element, axis) : settled animation?.cancel() animation = null const to = readSize(element, axis) settled = to if ( Math.abs(from - to) < 0.5 || prefersReducedMotion() || !element.isConnected || element.getClientRects().length === 0 ) { element.removeAttribute("data-morphing") return } element.setAttribute("data-morphing", "") const running = element.animate( [{ [axis]: `${from}px` }, { [axis]: `${to}px` }], { duration: time, easing } ) animation = running running.onfinish = () => { if (animation === running) { animation = null element.removeAttribute("data-morphing") settled = readSize(element, axis) } } } const mutations = new MutationObserver(morph) mutations.observe(element, { childList: true, subtree: true, characterData: true, }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (!animation) { settled = readSize(element, axis) } }) resize?.observe(element) return () => { mutations.disconnect() resize?.disconnect() animation?.cancel() element.removeAttribute("data-morphing") } } function useSizeMorph<T extends HTMLElement>({ axis, enabled = true, duration: time = duration.morph, easing = easeOut, }: SizeMorphOptions): React.RefCallback<T> { return React.useCallback( (element: T | null) => { if (!element || !enabled) { return undefined } return attachSizeMorph(element, axis, time, easing) }, [axis, enabled, time, easing] ) } function useSlidingHighlight( barRef: React.RefObject<HTMLElement | null>, highlightRef: React.RefObject<HTMLElement | null>, selector: string, attribute = "data-popup-open" ) { React.useLayoutEffect(() => { const bar = barRef.current const highlight = highlightRef.current if (!bar || !highlight) { return } let current: HTMLElement | null = null const place = (trigger: HTMLElement, instant: boolean) => { if (instant || prefersReducedMotion()) { highlight.setAttribute("data-instant", "") } else { highlight.removeAttribute("data-instant") } const frame = bar.getBoundingClientRect() const box = trigger.getBoundingClientRect() const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1 highlight.style.left = "0px" highlight.style.width = `${box.width / scale}px` highlight.style.height = `${box.height / scale}px` highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)` } const sync = () => { const trigger = bar.querySelector<HTMLElement>(selector) if (trigger === current) { if (trigger) { place(trigger, true) } return } const appearing = current === null current = trigger if (!trigger) { highlight.removeAttribute("data-visible") return } place(trigger, appearing) if (appearing) { void highlight.offsetWidth } highlight.setAttribute("data-visible", "") } sync() const mutations = new MutationObserver(sync) mutations.observe(bar, { subtree: true, childList: true, attributes: true, attributeFilter: [attribute], }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (current) { place(current, true) } }) resize?.observe(bar) const onScroll = () => { if (current) { place(current, true) } } bar.addEventListener("scroll", onScroll, { capture: true, passive: true }) return () => { mutations.disconnect() resize?.disconnect() bar.removeEventListener("scroll", onScroll, { capture: true }) } }, [barRef, highlightRef, selector, attribute]) } export { duration, easeInOut, easeOut, easeSpring, prefersReducedMotion, useSizeMorph, useSlidingHighlight, } export type { SizeMorphOptions } - 4
Update the import paths to match your project setup.
import { Input } from "@/components/ui/input"<label htmlFor="email">Email</label>
<Input id="email" type="email" placeholder="[email protected]" />Sizes#
sm, default and lg match the button heights, so an input and a button of the same size line up in a row.
import { Input } from "@/components/ui/input"
export function InputSizes() {
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<Input size="sm" aria-label="Small" placeholder="Small" />
<Input aria-label="Default" placeholder="Default" />
<Input size="lg" aria-label="Large" placeholder="Large" />
</div>
)
}With a description#
Point aria-describedby at the helper text so screen readers read it after the label.
Shown on your profile and in mentions.
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function InputDescription() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="input-username">Username</Label>
<Input
id="input-username"
autoComplete="username"
placeholder="preet"
aria-describedby="input-username-description"
/>
<p
id="input-username-description"
className="text-sm text-muted-foreground"
>
Shown on your profile and in mentions.
</p>
</div>
)
}Invalid#
aria-invalid turns the edge and the focus ring red. Link the message with aria-describedby so it is announced, not just colored.
Enter a full email address, like [email protected].
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function InputInvalid() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="input-invalid">Email</Label>
<Input
id="input-invalid"
type="email"
defaultValue="preet@"
aria-invalid
aria-describedby="input-invalid-error"
/>
<p id="input-invalid-error" className="text-sm text-destructive">
Enter a full email address, like [email protected].
</p>
</div>
)
}Native validation#
Fields with required, type="email" or pattern only turn red after someone has typed in them or tried to submit, never on first render. A submit that finds a field invalid shakes it once, so the eye lands on what needs fixing. It never shakes while you type or tab around. Submit the empty form to see it.
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function InputNativeValidation() {
return (
<form className="flex w-full max-w-sm flex-col gap-3">
<div className="flex flex-col gap-2">
<Label htmlFor="input-native-email">Email</Label>
<Input
id="input-native-email"
name="email"
type="email"
required
placeholder="[email protected]"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="input-native-code">Invite code</Label>
<Input
id="input-native-code"
name="code"
required
pattern="[A-Z]{4}-[0-9]{4}"
placeholder="ABCD-1234"
/>
</div>
<Button type="submit" className="self-start">
Join
</Button>
</form>
)
}Disabled#
A disabled input can’t be focused, edited or submitted with the form.
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function InputDisabled() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="input-disabled">Workspace</Label>
<Input id="input-disabled" defaultValue="Acme Inc." disabled />
</div>
)
}Read only#
readOnly keeps the value focusable, selectable and submitted, with a muted surface so it doesn’t look editable. Prefer it over disabled for values people need to copy.
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function InputReadOnly() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="input-read-only">API key</Label>
<Input id="input-read-only" readOnly defaultValue="sk_live_51H8a…f2Qz" />
</div>
)
}File#
type="file" gets the same frame, with the browser’s button restyled as plain text.
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function InputFile() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="input-file">Avatar</Label>
<Input id="input-file" type="file" accept="image/*" />
</div>
)
}Input types#
Password, number, search, date and time share one height and frame. In dark mode, the browser’s pickers and spinners switch to dark too.
import { Input } from "@/components/ui/input"
export function InputTypes() {
return (
<div className="grid w-full max-w-sm gap-3">
<Input type="password" aria-label="Password" defaultValue="hunter2" />
<Input type="number" aria-label="Seats" defaultValue={12} min={1} />
<Input type="search" aria-label="Search" placeholder="Search…" />
<Input type="date" aria-label="Start date" defaultValue="2026-10-03" />
<Input type="time" aria-label="Start time" defaultValue="09:30" />
</div>
)
}Controlled#
onValueChange hands you the string directly, so there’s no event.target.value to unwrap. onChange still works too.
18/32
"use client"
import * as React from "react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
const limit = 32
export function InputControlled() {
const [value, setValue] = React.useState("Quarterly planning")
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="input-controlled">Project name</Label>
<Input
id="input-controlled"
value={value}
maxLength={limit}
onValueChange={setValue}
aria-describedby="input-controlled-count"
/>
<p
id="input-controlled-count"
className="text-end text-sm text-muted-foreground tabular-nums"
>
{value.length}/{limit}
</p>
</div>
)
}With a button#
Side by side with a gap, or joined into one control inside a <ButtonGroup />, where the input takes the remaining width.
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import { Input } from "@/components/ui/input"
export function InputWithButton() {
return (
<form className="flex w-full max-w-sm flex-col gap-4">
<div className="flex gap-2">
<Input type="email" aria-label="Email" placeholder="[email protected]" />
<Button type="submit">Subscribe</Button>
</div>
<ButtonGroup className="w-full">
<Input type="search" aria-label="Search" placeholder="Search…" />
<Button variant="outline">Search</Button>
</ButtonGroup>
</form>
)
}Grid#
Inputs fill their container, so place them in a grid. Give grid cells min-w-0 so long values can’t stretch a column.
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function InputGrid() {
return (
<div className="grid w-full max-w-sm grid-cols-2 gap-3">
<div className="flex min-w-0 flex-col gap-2">
<Label htmlFor="input-first-name">First name</Label>
<Input id="input-first-name" autoComplete="given-name" />
</div>
<div className="flex min-w-0 flex-col gap-2">
<Label htmlFor="input-last-name">Last name</Label>
<Input id="input-last-name" autoComplete="family-name" />
</div>
</div>
)
}Long content#
Long values scroll inside the field and long placeholders are cut off, without widening the layout.
import { Input } from "@/components/ui/input"
export function InputLongContent() {
return (
<div className="flex w-full max-w-56 flex-col gap-3">
<Input
aria-label="URL"
defaultValue="https://example.com/a/really/long/url/without/any/spaces/at/all"
/>
<Input
aria-label="Note"
placeholder="A placeholder that is far too long to fit in this field"
/>
</div>
)
}Right to left#
Text, caret and padding follow the direction. Use dir="auto" on fields that hold left-to-right values, like an email address in an Arabic form.
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function InputRtl() {
return (
<div dir="rtl" className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="input-rtl">البريد الإلكتروني</Label>
<Input id="input-rtl" placeholder="[email protected]" dir="auto" />
<Input aria-label="الاسم" placeholder="اكتب اسمك" />
</div>
)
}- Every input needs a name. Use a
<label>withhtmlFor, oraria-labelwhen there’s no visible label. A placeholder is not a label. - Connect helper and error text with
aria-describedby, and setaria-invalidonly once there is an error to show. - On touch screens the text is at least 16px, so iOS Safari doesn’t zoom the page when the input is focused.
- Inside a Base UI
Field, the label, description, error and validity are wired up for you.
Built on the Base UI input. It accepts every native input attribute.
| Prop | Type | Default |
|---|---|---|
sizeHeight and padding, matched to the buttons. | "sm" | "default" | "lg" | "default" |
htmlSizeThe native size attribute, renamed because size is the variant. | number | – |
value | string | number | string[] | – |
defaultValue | string | number | string[] | – |
onValueChangeCalled with the new value on every change. | (value: string, details) => void | – |
type | string | "text" |
disabled | boolean | false |
readOnly | boolean | false |
aria-invalidShows the invalid edge and focus ring. | boolean | – |
className | string | (state) => string | – |
shakeShake once when a form submit finds this input invalid. Works with native validation, Base UI Field and libraries that set aria-invalid. Skipped under reduced motion. | boolean | true |
render | ReactElement | (props, state) => ReactElement | <input> |
| Attribute | Description |
|---|---|
data-slot="input" | Target the input in CSS. |
data-size | The current size. |
data-shake | Present while the input shakes after a failed submit. |
data-disabled | Present when the input is disabled. |
data-invalid | Present when the surrounding Field is invalid. Styled like aria-invalid. |
data-valid | Present when the surrounding Field is valid. |
data-touched | Present after the input lost focus once, inside a Field. |
data-dirty | Present once the value changed, inside a Field. |
data-filled | Present when the input has a value, inside a Field. |
data-focused | Present while focused, inside a Field. |
The class names behind the input, for styling another element to match, such as a native <select> or <textarea>. Call it with { size }.
The character count behind <InputGroupCount /> and <FieldCounter />. Use those parts, which read the field for you. Reach for this one only when you track the length yourself.
| Prop | Type | Default |
|---|---|---|
lengthRequired. | number | – |
maxLength | number | null | – |
threshold | number | 10% of maxLength, at most 20 |
announcement | (remaining: number) => string | – |
| Attribute | Description |
|---|---|
data-slot="input-count" | Target the count in CSS. |
data-state="near" | "limit" | Present within the threshold, and at the limit. |