React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

inputformtext-fieldcomponenttailwind

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 onChange event handler and a simplified onValueChange string 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-allowed and 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, not undefined.

  • Missing id/htmlFor association -- Using a <label> that is not wrapping the input and not connected via htmlFor means clicking the label does not focus the input. Either wrap the input in the label or use matching id and htmlFor attributes.

  • onChange returns event, not value -- Unlike some component libraries, the native onChange gives you an event object. Forgetting e.target.value is a common source of [object Object] appearing in inputs.

  • type="number" quirks -- onChange still returns a string with type="number". Use parseFloat(e.target.value) and handle NaN. Also, number inputs allow e, +, -, and . characters that parseInt silently ignores.

  • Mobile keyboard mismatch -- Using type="text" for email or phone fields shows a generic keyboard on mobile. Use type="email", type="tel", or inputMode="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.

  • Button -- Submit buttons used alongside inputs
  • Forms -- Form patterns and validation strategies
  • Event Handling -- onChange and event typing