Input
A form field for capturing user text input, supporting labels, validation states, and various input types.
Use Cases
- Collect user information on sign-up and profile forms
- Search bars with icon and clear button
- Login forms with email and password fields
- Inline editing of table cells or list items
- Chat message composition fields
- Filter and autocomplete inputs in dashboards
- Multi-line feedback or comment forms using textarea
Simplest Implementation
"use client";
interface InputProps {
label: string;
value: string;
onChange: (value: string) => void;
}
export function Input({ label, value, onChange }: InputProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</label>
);
}A minimal labeled input. The onChange callback returns the string value directly so the parent does not need to unwrap e.target.value. Wrapping the input inside a <label> element associates the label with the field without needing htmlFor and id attributes.
Variations
With Error State
"use client";
interface InputProps {
label: string;
value: string;
onChange: (value: string) => void;
error?: string;
}
export function Input({ label, value, onChange, error }: InputProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
aria-invalid={!!error}
className={`mt-1 block w-full rounded-lg border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 ${
error
? "border-red-500 focus:border-red-500 focus:ring-red-500"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
}`}
/>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</label>
);
}Toggles border and ring colors to red when an error message is present. The aria-invalid attribute tells screen readers the field has a validation problem.
With Helper Text
"use client";
interface InputProps {
label: string;
value: string;
onChange: (value: string) => void;
helperText?: string;
error?: string;
}
export function Input({ label, value, onChange, helperText, error }: InputProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
aria-invalid={!!error}
className={`mt-1 block w-full rounded-lg border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 ${
error
? "border-red-500 focus:border-red-500 focus:ring-red-500"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
}`}
/>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
{!error && helperText && <p className="mt-1 text-sm text-gray-500">{helperText}</p>}
</label>
);
}Helper text appears below the input when there is no error. When an error is present it takes priority, preventing conflicting messages from stacking.
Password Toggle
"use client";
import { useState } from "react";
interface PasswordInputProps {
label: string;
value: string;
onChange: (value: string) => void;
}
export function PasswordInput({ label, value, onChange }: PasswordInputProps) {
const [visible, setVisible] = useState(false);
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<div className="relative mt-1">
<input
type={visible ? "text" : "password"}
value={value}
onChange={(e) => onChange(e.target.value)}
className="block w-full rounded-lg border border-gray-300 px-3 py-2 pr-10 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
type="button"
onClick={() => setVisible((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-gray-400 hover:text-gray-600"
aria-label={visible ? "Hide password" : "Show password"}
>
{visible ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-5 0-9.27-3.11-11-7.5a11.72 11.72 0 013.168-4.477M6.343 6.343A9.97 9.97 0 0112 5c5 0 9.27 3.11 11 7.5a11.7 11.7 0 01-4.373 5.157M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3l18 18" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-.274.857-.642 1.68-1.1 2.453M12 19c-1.39 0-2.72-.285-3.927-.8" />
</svg>
)}
</button>
</div>
</label>
);
}Toggles between type="text" and type="password" with a visibility button. The toggle button uses type="button" to prevent form submission and aria-label to communicate the current state.
Search Input with Icon
"use client";
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function SearchInput({ value, onChange, placeholder = "Search..." }: SearchInputProps) {
return (
<div className="relative">
<svg
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="block w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm shadow-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
);
}The search icon is absolutely positioned inside the input container. Left padding (pl-10) prevents text from overlapping the icon. Using type="search" gives browsers native clear buttons on some platforms.
Textarea Variant
"use client";
interface TextareaProps {
label: string;
value: string;
onChange: (value: string) => void;
rows?: number;
maxLength?: number;
}
export function Textarea({ label, value, onChange, rows = 4, maxLength }: TextareaProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={rows}
maxLength={maxLength}
className="mt-1 block w-full resize-y rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{maxLength && (
<p className="mt-1 text-right text-xs text-gray-400">
{value.length}/{maxLength}
</p>
)}
</label>
);
}A multi-line input with optional character count. The resize-y class allows vertical resizing only, preventing horizontal overflow issues. The counter updates live as the user types.
Complex Implementation
"use client";
import { forwardRef, useId, useState, useCallback } from "react";
type InputSize = "sm" | "md" | "lg";
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size" | "onChange"> {
label?: string;
helperText?: string;
error?: string;
size?: InputSize;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
onValueChange?: (value: string) => void;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
fullWidth?: boolean;
}
const sizeClasses: Record<InputSize, { input: string; text: string }> = {
sm: { input: "h-8 px-2.5 text-xs", text: "text-xs" },
md: { input: "h-10 px-3 text-sm", text: "text-sm" },
lg: { input: "h-12 px-4 text-base", text: "text-base" },
};
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{
label,
helperText,
error,
size = "md",
leftIcon,
rightIcon,
onValueChange,
onChange,
fullWidth = true,
disabled,
className,
id: externalId,
...rest
},
ref
) {
const generatedId = useId();
const inputId = externalId ?? generatedId;
const errorId = `${inputId}-error`;
const helperId = `${inputId}-helper`;
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e);
onValueChange?.(e.target.value);
},
[onChange, onValueChange]
);
const hasError = !!error;
const sizes = sizeClasses[size];
return (
<div className={fullWidth ? "w-full" : "inline-flex flex-col"}>
{label && (
<label htmlFor={inputId} className={`mb-1 block font-medium text-gray-700 ${sizes.text}`}>
{label}
</label>
)}
<div className="relative">
{leftIcon && (
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
{leftIcon}
</span>
)}
<input
ref={ref}
id={inputId}
disabled={disabled}
onChange={handleChange}
aria-invalid={hasError}
aria-describedby={
[hasError ? errorId : null, helperText ? helperId : null].filter(Boolean).join(" ") || undefined
}
className={[
"block rounded-lg border shadow-sm transition-colors",
"focus:outline-none focus:ring-1",
"disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500",
"placeholder:text-gray-400",
sizes.input,
fullWidth ? "w-full" : "",
leftIcon ? "pl-10" : "",
rightIcon ? "pr-10" : "",
hasError
? "border-red-500 focus:border-red-500 focus:ring-red-500"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500",
className ?? "",
]
.filter(Boolean)
.join(" ")}
{...rest}
/>
{rightIcon && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
{rightIcon}
</span>
)}
</div>
{hasError && (
<p id={errorId} className={`mt-1 text-red-600 ${sizes.text}`} role="alert">
{error}
</p>
)}
{!hasError && helperText && (
<p id={helperId} className={`mt-1 text-gray-500 ${sizes.text}`}>
{helperText}
</p>
)}
</div>
);
});Key aspects:
- forwardRef -- allows parent components to attach refs for focus management, form libraries, or imperative validation.
- useId for accessibility -- generates a stable unique ID to link
<label>,aria-describedby, and error/helper text without requiring the consumer to provide IDs. - Dual onChange API -- supports both the native
onChangeevent handler and a simplifiedonValueChangestring callback, making it compatible with form libraries and simple state alike. - aria-describedby chaining -- links the input to both error and helper text elements so screen readers announce contextual information in the correct order.
- Icon slots with pointer-events-none -- the left icon is purely decorative and passes clicks through to the input. The right icon slot allows interactive elements like clear buttons.
- Disabled styling -- uses
disabled:cursor-not-allowedand a muted background so the disabled state is visually obvious without relying on opacity alone.
Gotchas
-
Uncontrolled to controlled warning -- Starting with
value={undefined}then switching to a string triggers a React warning. Always initialize state as an empty string, notundefined. -
Missing
id/htmlForassociation -- Using a<label>that is not wrapping the input and not connected viahtmlFormeans clicking the label does not focus the input. Either wrap the input in the label or use matchingidandhtmlForattributes. -
onChange returns event, not value -- Unlike some component libraries, the native
onChangegives you an event object. Forgettinge.target.valueis a common source of[object Object]appearing in inputs. -
type="number" quirks --
onChangestill returns a string withtype="number". UseparseFloat(e.target.value)and handleNaN. Also, number inputs allowe,+,-, and.characters thatparseIntsilently ignores. -
Mobile keyboard mismatch -- Using
type="text"for email or phone fields shows a generic keyboard on mobile. Usetype="email",type="tel", orinputMode="numeric"to get the correct keyboard layout. -
Autofill styling conflicts -- Browser autofill applies its own background color (usually yellow or blue). Override with
autofill:bg-white autofill:shadow-[inset_0_0_0px_1000px_white]in Tailwind if needed.
Related
- Button -- Submit buttons used alongside inputs
- Forms -- Form patterns and validation strategies
- Event Handling -- onChange and event typing