Input

A text input with label, description, error state, leading/trailing icons, three sizes, and three visual variants.

AccessibleDark Mode3 Sizes3 VariantsIconsError State

Install

$npx react-principles add input
01

Theme Preview

Normal, focused, error, and disabled states — rendered with forced light and dark styling.

Light

Email address

you@example.com

Username

johndoe_xyz

Password

••••••••

Password must be at least 8 characters.

Display name

John Doe
Dark

Email address

you@example.com

Username

johndoe_xyz

Password

••••••••

Password must be at least 8 characters.

Display name

John Doe
02

Live Demo

Explore all variants and interactive states in Storybook.

Open Storybookopen_in_new
Size
Variant

We'll never share your email.

Username is already taken.

03

Code Snippet

src/ui/Input.tsx
import { Input } from "@/ui/Input";

// Basic
<Input placeholder="Enter your email" />

// With label + description
<Input
  label="Email address"
  description="We'll never share your email."
  placeholder="you@example.com"
  type="email"
/>

// Error state
<Input
  label="Username"
  error="Username is already taken."
  defaultValue="johndoe"
/>

// With icons
<Input
  label="Search"
  leadingIcon={<SearchIcon />}
  trailingIcon={<ClearIcon />}
  placeholder="Search..."
/>

// Sizes: "sm" | "md" | "lg"
// Variants: "default" | "filled" | "ghost"
<Input size="lg" variant="filled" label="Display name" />

Backward compatible: API lama <Input /> tetap jalan, canonical style pakai <Input />.

04

Copy-Paste (Single File)

Input.tsx
import { forwardRef, InputHTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/utils";

// ─── Types ────────────────────────────────────────────────────────────────────

export type InputSize = "sm" | "md" | "lg";
export type InputVariant = "default" | "filled" | "ghost";

export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
  label?: string;
  description?: string;
  error?: string;
  size?: InputSize;
  variant?: InputVariant;
  leadingIcon?: ReactNode;
  trailingIcon?: ReactNode;
}

// ─── Constants ────────────────────────────────────────────────────────────────

const SIZE_CLASSES: Record<InputSize, { input: string; label: string; icon: string }> = {
  sm: { input: "h-8 px-3 text-xs", label: "text-xs", icon: "h-3.5 w-3.5" },
  md: { input: "h-10 px-3.5 text-sm", label: "text-sm", icon: "h-4 w-4" },
  lg: { input: "h-12 px-4 text-base", label: "text-sm", icon: "h-4.5 w-4.5" },
};

const VARIANT_BASE: Record<InputVariant, string> = {
  default:
    "border border-slate-200 dark:border-[#1f2937] bg-white dark:bg-[#0d1117] " +
    "hover:border-slate-300 dark:hover:border-slate-600 " +
    "focus-within:border-primary dark:focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20",
  filled:
    "border border-transparent bg-slate-100 dark:bg-[#161b22] " +
    "hover:bg-slate-150 dark:hover:bg-[#1f2937] " +
    "focus-within:border-primary dark:focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20 focus-within:bg-white dark:focus-within:bg-[#0d1117]",
  ghost:
    "border border-transparent bg-transparent " +
    "hover:bg-slate-50 dark:hover:bg-[#161b22] " +
    "focus-within:border-primary dark:focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20",
};

const ERROR_OVERRIDE =
  "border-red-400 dark:border-red-500 focus-within:border-red-400 dark:focus-within:border-red-500 focus-within:ring-red-400/20";

// ─── Component ────────────────────────────────────────────────────────────────

export const Input = forwardRef<HTMLInputElement, InputProps>(function InputRoot(
  {
    label,
    description,
    error,
    size = "md",
    variant = "default",
    leadingIcon,
    trailingIcon,
    disabled,
    className,
    id,
    ...rest
  },
  ref
) {
  const s = SIZE_CLASSES[size];
  const inputId = id ?? (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined);

  return (
    <div className={cn("flex flex-col gap-1.5", className)}>
      {label && (
        <label
          htmlFor={inputId}
          className={cn(
            "font-medium text-slate-700 dark:text-slate-300",
            s.label,
            disabled && "opacity-50"
          )}
        >
          {label}
        </label>
      )}

      <div
        className={cn(
          "relative flex items-center rounded-lg transition-all",
          VARIANT_BASE[variant],
          error && ERROR_OVERRIDE,
          disabled && "opacity-50 cursor-not-allowed pointer-events-none"
        )}
      >
        {leadingIcon && (
          <span className={cn("absolute left-3 flex shrink-0 items-center text-slate-400", s.icon)}>
            {leadingIcon}
          </span>
        )}

        <input
          ref={ref}
          id={inputId}
          disabled={disabled}
          className={cn(
            "w-full bg-transparent outline-hidden text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500",
            s.input,
            leadingIcon && (size === "sm" ? "pl-8" : size === "lg" ? "pl-10" : "pl-9"),
            trailingIcon && (size === "sm" ? "pr-8" : size === "lg" ? "pr-10" : "pr-9")
          )}
          {...rest}
        />

        {trailingIcon && (
          <span className={cn("absolute right-3 flex shrink-0 items-center text-slate-400", s.icon)}>
            {trailingIcon}
          </span>
        )}
      </div>

      {description && !error && (
        <p className="text-xs text-slate-500 dark:text-slate-400">{description}</p>
      )}
      {error && (
        <p className="text-xs text-red-500 dark:text-red-400">{error}</p>
      )}
    </div>
  );
});
05

Props

Extends all native HTMLInputElement attributes.

PropTypeDefaultDescription
labelstringLabel rendered above the input.
descriptionstringHelper text shown below the input (hidden when error is present).
errorstringError message — turns border red and replaces description.
size"sm" | "md" | "lg""md"Controls input height and text size.
variant"default" | "filled" | "ghost""default"Visual style of the input wrapper.
leadingIconReactNodeIcon placed at the left edge inside the input.
trailingIconReactNodeIcon placed at the right edge inside the input.
disabledbooleanfalseDisables interaction and reduces opacity.
React Principles