Input field for React
Text input with label, hint, error as text, icons, prefix, suffix and addons.
One of 9 components in Forms and inputs
The text field done properly
The input is the field every form starts with. This one ties its label, hint and error to the field with the right attributes, so screen readers read all three, and it shows errors as text next to the field instead of only a red border.
States shows default, focus, filled, disabled, read only and error side by side. Icons and addons adds a search or mail icon, a prefix such as https or a currency, a suffix such as a unit and attached buttons. Sizes matches the three button sizes, so an input and a button in one row line up. Password has a show and hide button and the right autocomplete for new and current passwords.
Set type and autocomplete for every field, phones then show the right keyboard and fill known data. Keep the label visible, a placeholder is a hint and disappears while typing. Say in the error what to do, Enter a date like 24.12.2026, not Invalid input.
Details
What Input brings along, read from the registry and the source.
- Category
- Forms and inputs
- Area
- Elements
- Variants
- 4
- Version
- 1.0.1 · since 1.0.0
- File
- components/ui/input.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- English and German, 3 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The InputProps interface from components/ui/input.tsx, with the comments from the code. Also takes every prop of FieldOwnProps, Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'prefix'>.
sizeoptionalControlSizesmmdlg
iconoptionalIconNameIcon at the start
prefixoptionalReact.ReactNodeQuiet text inside the box, before the value, e.g. "https://"
suffixoptionalReact.ReactNodeQuiet text inside the box, after the value, e.g. "€" or "kg"
startAddonoptionalReact.ReactNodeA boxed segment attached at the start, e.g. a protocol or a country code
endAddonoptionalReact.ReactNodeA boxed segment attached at the end, e.g. a domain
trailingoptionalReact.ReactNodeAny element at the end inside the box, e.g. a button, a key hint or a spinner
invalidoptionalbooleanMarks the control invalid without an error text (use error for text). The box shakes once when it turns invalid.
successoptionalbooleanMarks the value as accepted: a green edge and a check that draws itself
floatingLabeloptionalbooleanThe label sits inside the box like a placeholder and floats up when the field has focus or a value. The box is 56 px tall. Off by default.
refoptionalReact.Ref<HTMLInputElement>inputClassNameoptionalstring
Built-in texts
Input writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 3 texts
| Key | English | German |
|---|---|---|
| optional | English Optional | German Optional |
| show | English Show password | German Passwort zeigen |
| hide | English Hide password | German Passwort verbergen |
Accessibility
Read from the source of the component.
- Sets aria-describedby, aria-invalid, aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 442 lines
'use client';
import { createContext, useContext, useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
/*
* Text input and the field system every form control shares: a label,
* a hint, an error as text, and the wiring between them (id, aria-describedby,
* aria-invalid). Wrap any control in <Field>, or pass label/hint/error
* straight to the control.
*/
const TEXTS = {
en: { optional: 'Optional', show: 'Show password', hide: 'Hide password' },
de: { optional: 'Optional', show: 'Passwort zeigen', hide: 'Passwort verbergen' },
};
export type ControlSize = 'sm' | 'md' | 'lg';
/** Heights match the button sizes, so inputs and buttons line up in a row (small grows to 44 px on phones, like the small button). 16 px text on phones keeps iOS from zooming. */
export const CONTROL_HEIGHT: Record<ControlSize, string> = {
sm: 'h-11 rounded-md text-base sm:h-9 sm:text-[13px]',
md: 'h-11 rounded-lg text-base sm:text-sm',
lg: 'h-12 rounded-lg text-base sm:text-[15px]',
};
export const CONTROL_PAD: Record<ControlSize, string> = { sm: 'px-3', md: 'px-3.5', lg: 'px-4' };
const PAD_START: Record<ControlSize, string> = { sm: 'ps-3', md: 'ps-3.5', lg: 'ps-4' };
const PAD_END: Record<ControlSize, string> = { sm: 'pe-3', md: 'pe-3.5', lg: 'pe-4' };
const MARGIN_START: Record<ControlSize, string> = { sm: 'ms-3', md: 'ms-3.5', lg: 'ms-4' };
const ICON_SIZE: Record<ControlSize, number> = { sm: 15, md: 17, lg: 18 };
/** The box of every control: border, surface, hover, focus ring, invalid and disabled */
export const controlBox = cn(
// The focus ring grows out of the border and fades in, it does not snap on
'border border-input bg-background text-foreground shadow-[var(--shadow-sm)] transition-[border-color,box-shadow,background-color,scale] duration-300 ease-[var(--ease-kit)]',
'hover:border-border-strong',
'focus-within:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent),var(--shadow-sm)]',
'data-invalid:border-[color-mix(in_oklab,var(--destructive)_75%,var(--border))] data-invalid:focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--destructive)_16%,transparent),var(--shadow-sm)]',
'data-disabled:cursor-not-allowed data-disabled:bg-muted data-disabled:text-muted-foreground data-disabled:shadow-none data-disabled:hover:border-input',
);
/** The same box for a single focusable element (select trigger, buttons that look like fields) */
export const controlBoxFocus = cn(
controlBox,
'outline-none focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent),var(--shadow-sm)]',
'aria-invalid:border-[color-mix(in_oklab,var(--destructive)_75%,var(--border))]',
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground disabled:shadow-none',
);
/* ------------------------------------------------------------------ */
/* Motion shared by the form controls */
/* ------------------------------------------------------------------ */
const EASE = 'cubic-bezier(0.2,0.7,0.2,1)';
const SHAKE: Keyframe[] = [
{ transform: 'translateX(0)' },
{ transform: 'translateX(-6px)' },
{ transform: 'translateX(5px)' },
{ transform: 'translateX(-3px)' },
{ transform: 'translateX(2px)' },
{ transform: 'translateX(0)' },
];
/** Shakes the element once when it turns invalid. Not on the first render, not with reduced motion. */
export function useShake(ref: React.RefObject<HTMLElement | null>, invalid: boolean) {
const was = useRef(invalid);
useEffect(() => {
const turned = invalid && !was.current;
was.current = invalid;
const el = ref.current;
if (!turned || !el || prefersReducedMotion() || typeof el.animate !== 'function') return;
el.animate(SHAKE, { duration: 420, easing: 'ease-out' });
}, [invalid, ref]);
}
/*
* What is on the page from the start just stands there; what arrives later
* (an error after typing, a check after a valid entry) comes in with a short
* movement. The first control effect marks the moment the page came alive.
*/
let alive = 0;
/** ms since the first form control on this page ran its effects */
export function sinceAlive() {
const now = performance.now();
if (!alive) alive = now;
return now - alive;
}
/** Rises in a few pixels when it mounts after the page came alive */
export function useArrival<T extends HTMLElement>() {
const ref = useRef<T>(null);
useEffect(() => {
const el = ref.current;
if (sinceAlive() < 400 || !el || prefersReducedMotion() || typeof el.animate !== 'function') return;
el.animate([{ transform: 'translateY(-4px)', opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }], { duration: 320, easing: EASE });
}, []);
return ref;
}
/** A check that draws itself (stroke-dashoffset) when it appears after the page came alive */
export function SuccessMark({ size = 16, className }: { size?: number; className?: string }) {
const ref = useRef<SVGPathElement>(null);
useEffect(() => {
const el = ref.current;
if (sinceAlive() < 400 || !el || prefersReducedMotion() || typeof el.animate !== 'function') return;
el.animate([{ strokeDashoffset: 1 }, { strokeDashoffset: 1, offset: 0.15 }, { strokeDashoffset: 0 }], { duration: 480, easing: EASE });
}, []);
return (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" aria-hidden className={cn('shrink-0 text-success', className)}>
<path ref={ref} d="m5 12.5 4.5 4.5L19 7.5" pathLength={1} strokeDasharray="1 1" strokeDashoffset={0} stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/* ------------------------------------------------------------------ */
/* Field */
/* ------------------------------------------------------------------ */
interface FieldState {
id: string;
describedBy?: string;
invalid: boolean;
disabled?: boolean;
required?: boolean;
}
const FieldContext = createContext<FieldState | null>(null);
/** The props a control needs to be wired to its field. Works without a field too. */
export function useControl(props: { id?: string; invalid?: boolean; disabled?: boolean; required?: boolean; 'aria-describedby'?: string }) {
const field = useContext(FieldContext);
const auto = useId();
const invalid = props.invalid ?? field?.invalid ?? false;
return {
id: props.id ?? field?.id ?? auto,
'aria-describedby': cn(field?.describedBy, props['aria-describedby']) || undefined,
'aria-invalid': invalid || undefined,
disabled: props.disabled ?? field?.disabled,
required: props.required ?? field?.required,
invalid,
};
}
export interface FieldOwnProps {
label?: React.ReactNode;
/** Help text under the control */
hint?: React.ReactNode;
/** Error as text. Replaces the hint and marks the control invalid. */
error?: React.ReactNode;
/** Shows "Optional" next to the label */
optional?: boolean;
/** Something at the right end of the label row, e.g. a "Forgot password?" link */
labelAction?: React.ReactNode;
}
export interface FieldProps extends FieldOwnProps {
id?: string;
required?: boolean;
disabled?: boolean;
className?: string;
children: React.ReactNode;
}
export function Label({ htmlFor, required, optional, className, children, id }: { htmlFor?: string; required?: boolean; optional?: boolean; className?: string; children: React.ReactNode; id?: string }) {
const t = useTexts(TEXTS);
return (
<label id={id} htmlFor={htmlFor} className={cn('inline-flex items-baseline gap-1.5 text-sm font-medium leading-5 text-foreground', className)}>
<span>
{children}
{required && (
<span aria-hidden className="ms-0.5 text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]">
*
</span>
)}
</span>
{optional && <span className="text-xs font-normal text-subtle-foreground">{t.optional}</span>}
</label>
);
}
export function FieldHint({ id, className, children }: { id?: string; className?: string; children: React.ReactNode }) {
return (
<p id={id} className={cn('text-[13px] leading-5 text-muted-foreground', className)}>
{children}
</p>
);
}
export function FieldError({ id, className, children }: { id?: string; className?: string; children: React.ReactNode }) {
const ref = useArrival<HTMLParagraphElement>();
return (
<p ref={ref} id={id} className={cn('flex items-start gap-1.5 text-[13px] font-medium leading-5 text-destructive', className)}>
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px]" />
<span>{children}</span>
</p>
);
}
/** Label, control, hint or error, wired together. */
export function Field({ id, label, hint, error, optional, labelAction, required, disabled, className, children }: FieldProps) {
const auto = useId();
const fid = id ?? auto;
const messageId = `${fid}-message`;
const hasMessage = Boolean(error || hint);
return (
<FieldContext.Provider value={{ id: fid, describedBy: hasMessage ? messageId : undefined, invalid: Boolean(error), disabled, required }}>
<div className={cn('grid w-full min-w-0 grid-cols-[minmax(0,1fr)] content-start gap-2', disabled && 'opacity-80', className)}>
{(label || labelAction) && (
<div className="flex min-h-5 items-baseline justify-between gap-3">
{label && (
<Label htmlFor={fid} required={required} optional={optional}>
{label}
</Label>
)}
{labelAction && <div className="text-[13px]">{labelAction}</div>}
</div>
)}
{children}
<div aria-live="polite" className="empty:hidden">
{error ? <FieldError id={messageId}>{error}</FieldError> : hint ? <FieldHint id={messageId}>{hint}</FieldHint> : null}
</div>
</div>
</FieldContext.Provider>
);
}
/** Wraps a control in a Field when label, hint or error are given */
export function withField(props: FieldOwnProps & { id?: string; required?: boolean; disabled?: boolean; className?: string }, control: React.ReactNode) {
const { label, hint, error, optional, labelAction, id, required, disabled, className } = props;
if (!label && !hint && !error && !labelAction) return control;
return (
<Field {...{ label, hint, error, optional, labelAction, id, required, disabled, className }}>
{control}
</Field>
);
}
/* ------------------------------------------------------------------ */
/* Input */
/* ------------------------------------------------------------------ */
export interface InputProps extends FieldOwnProps, Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'prefix'> {
size?: ControlSize;
/** Icon at the start */
icon?: IconName;
/** Quiet text inside the box, before the value, e.g. "https://" */
prefix?: React.ReactNode;
/** Quiet text inside the box, after the value, e.g. "€" or "kg" */
suffix?: React.ReactNode;
/** A boxed segment attached at the start, e.g. a protocol or a country code */
startAddon?: React.ReactNode;
/** A boxed segment attached at the end, e.g. a domain */
endAddon?: React.ReactNode;
/** Any element at the end inside the box, e.g. a button, a key hint or a spinner */
trailing?: React.ReactNode;
/** Marks the control invalid without an error text (use error for text). The box shakes once when it turns invalid. */
invalid?: boolean;
/** Marks the value as accepted: a green edge and a check that draws itself */
success?: boolean;
/**
* The label sits inside the box like a placeholder and floats up when the
* field has focus or a value. The box is 56 px tall. Off by default.
*/
floatingLabel?: boolean;
ref?: React.Ref<HTMLInputElement>;
/** Class of the outer element (field or box) */
className?: string;
inputClassName?: string;
}
function Addon({ side, children }: { side: 'start' | 'end'; children: React.ReactNode }) {
return (
<span className={cn('flex h-full shrink-0 items-center bg-surface-2 px-3 text-muted-foreground', side === 'start' ? 'rounded-s-[inherit] border-e border-input' : 'rounded-e-[inherit] border-s border-input')}>
{children}
</span>
);
}
const SUCCESS_BOX =
'border-[color-mix(in_oklab,var(--success)_55%,var(--border))] hover:border-[color-mix(in_oklab,var(--success)_65%,var(--border))] focus-within:border-[color-mix(in_oklab,var(--success)_70%,var(--border-strong))] focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--success)_16%,transparent),var(--shadow-sm)]';
const MARGIN_END: Record<ControlSize, string> = { sm: 'me-3', md: 'me-3.5', lg: 'me-4' };
function InputBox({
size = 'md',
icon,
prefix,
suffix,
startAddon,
endAddon,
trailing,
invalid,
success,
floating,
className,
inputClassName,
ref,
...rest
}: Omit<InputProps, keyof FieldOwnProps | 'floatingLabel'> & { floating?: { label: React.ReactNode } }) {
const c = useControl({ ...rest, invalid });
const boxRef = useRef<HTMLDivElement>(null);
useShake(boxRef, c.invalid);
const ok = Boolean(success) && !c.invalid;
const lead = Boolean(icon || prefix) && !startAddon;
const tail = Boolean(suffix || trailing || ok) && !endAddon;
const padStart = lead ? (prefix ? 'ps-1' : 'ps-2.5') : startAddon ? 'ps-3' : PAD_START[size];
const input = (
<input
{...rest}
ref={ref}
id={c.id}
disabled={c.disabled}
required={c.required}
aria-invalid={c['aria-invalid']}
aria-describedby={c['aria-describedby']}
placeholder={floating ? rest.placeholder ?? ' ' : rest.placeholder}
className={cn(
'h-full w-0 min-w-0 flex-1 bg-transparent text-inherit outline-none placeholder:text-subtle-foreground disabled:cursor-not-allowed',
'[&::-webkit-search-cancel-button]:appearance-none',
padStart,
tail ? 'pe-2' : endAddon ? 'pe-3' : PAD_END[size],
floating && 'peer pt-[18px] placeholder:text-transparent focus:placeholder:text-subtle-foreground',
inputClassName,
)}
/>
);
return (
<div
ref={boxRef}
data-invalid={c.invalid || undefined}
data-disabled={c.disabled || undefined}
data-success={ok || undefined}
className={cn(
// No overflow-hidden: it would clip the 44 px hit area of a button inside (password eye). The addons round their own corners.
'group/input relative flex w-full min-w-0 items-center',
floating ? 'h-14 rounded-lg text-base sm:text-[15px]' : CONTROL_HEIGHT[size],
controlBox,
ok && SUCCESS_BOX,
className,
)}
>
{startAddon && <Addon side="start">{startAddon}</Addon>}
{icon && (
<Icon
name={icon}
size={ICON_SIZE[size]}
className={cn('shrink-0 text-subtle-foreground transition-colors duration-300 group-focus-within/input:text-muted-foreground', startAddon ? 'ms-3' : MARGIN_START[size])}
/>
)}
{prefix && <span className={cn('shrink-0 select-none text-subtle-foreground', icon ? 'ps-2' : startAddon ? 'ps-3' : PAD_START[size], floating && 'pt-[18px]')}>{prefix}</span>}
{floating ? (
<span className="relative flex h-full min-w-0 flex-1">
{input}
<label
htmlFor={c.id}
className={cn(
'pointer-events-none absolute inset-y-0 start-0 flex max-w-full origin-[0_50%] items-center truncate text-subtle-foreground transition-[translate,scale,color] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none',
padStart,
'peer-focus:-translate-y-[11px] peer-focus:scale-[0.8] peer-focus:text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]',
'peer-[:not(:placeholder-shown)]:-translate-y-[11px] peer-[:not(:placeholder-shown)]:scale-[0.8] peer-[:not(:placeholder-shown)]:text-muted-foreground',
)}
>
{floating.label}
{c.required && (
<span aria-hidden className="ms-0.5 text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]">
*
</span>
)}
</label>
</span>
) : (
input
)}
{ok && !endAddon && <SuccessMark size={ICON_SIZE[size] - 1} className={suffix || trailing ? 'me-2' : MARGIN_END[size]} />}
{suffix && <span className={cn('shrink-0 select-none text-subtle-foreground', trailing ? 'pe-2' : PAD_END[size], floating && 'pt-[18px]')}>{suffix}</span>}
{trailing && <span className={cn('flex shrink-0 items-center', size === 'sm' ? 'pe-1' : 'pe-1.5')}>{trailing}</span>}
{endAddon && <Addon side="end">{endAddon}</Addon>}
</div>
);
}
/**
* Text input with label, hint, error, icon, inline prefix/suffix and attached addons.
* The focus ring fades in, an error shakes the box once, success draws a check.
* <Input label="Email" type="email" icon="mail" hint="We never share it." />
* <Input label="Website" startAddon="https://" placeholder="northwind.io" />
* <Input label="Price" suffix="€" error="Enter a price above 0." />
* <Input label="Email" floatingLabel />
*/
export function Input({ label, hint, error, optional, labelAction, floatingLabel, className, ...rest }: InputProps) {
if (floatingLabel && label) {
const wrapped = Boolean(hint || error || labelAction);
return withField(
{ hint, error, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className },
<InputBox {...rest} floating={{ label }} className={wrapped ? undefined : className} />,
);
}
return withField({ label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className }, <InputBox {...rest} className={label || hint || error ? undefined : className} />);
}
/** A small square button inside a control, e.g. clear or reveal. A new icon turns in with a short spin. */
export function InlineButton({ label, icon, onClick, className, pressed }: { label: string; icon: IconName; onClick?: () => void; className?: string; pressed?: boolean }) {
const ref = useRef<HTMLButtonElement>(null);
const last = useRef(icon);
useEffect(() => {
if (last.current === icon) return;
last.current = icon;
const svg = ref.current?.querySelector('svg');
if (!svg || prefersReducedMotion() || typeof svg.animate !== 'function') return;
svg.animate([{ transform: 'rotate(-60deg) scale(0.5)', opacity: 0 }, { transform: 'rotate(0) scale(1)', opacity: 1 }], { duration: 320, easing: EASE });
}, [icon]);
return (
<button
ref={ref}
type="button"
aria-label={label}
aria-pressed={pressed}
onClick={onClick}
className={cn(
'relative grid size-8 place-items-center rounded-md text-muted-foreground outline-none transition-[background-color,color,scale] duration-200 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-90 active:duration-100 motion-reduce:active:scale-100',
'after:absolute after:-inset-1.5 after:content-[""]',
className,
)}
>
<Icon name={icon} size={16} />
</button>
);
}
/** Password input with a show/hide button */
export function PasswordInput(props: Omit<InputProps, 'type' | 'trailing'>) {
const t = useTexts(TEXTS);
const [visible, setVisible] = useState(false);
return (
<Input
autoComplete="current-password"
{...props}
type={visible ? 'text' : 'password'}
trailing={<InlineButton label={visible ? t.hide : t.show} icon={visible ? 'eye-off' : 'eye'} onClick={() => setVisible((v) => !v)} />}
/>
);
}Frequent questions
How do I install Input?
With aiw add @ai-website/input. The command puts components/ui/input.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Input have?
4 variants, States, Icons and addons, Sizes and Password.
Can I translate the texts of Input?
Yes. Input carries 3 texts of its own in English and German, such as “Optional”, “Show password” and “Hide password”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Input?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Input with aiw_components and install it with aiw_add @ai-website/input.