Input

Text field with label, hint, prefix/suffix, password reveal, numeric and error states.

import { Input }from "@/components/ui"

Specification

Collects a single line of text. Always paired with a visible label — never use the placeholder as the label.

Anatomy

  • Label: 13–14px medium, above the field (never inside)
  • Input container: 1px border, 8px radius
  • Placeholder: tertiary color (example input, not instructions)
  • Helper text: 12px below field, secondary color
  • Error message: 12px below field, error color

States

  • Default: border-border
  • Focus: accent border + focus ring
  • Filled: content visible, same as default
  • Error: error border + message below, aria-invalid
  • Disabled: reduced opacity, secondary background
  • Read-only: secondary background, default cursor

Dimensions & tokens

Height40px
Padding X12px
Radius8px
Border width1px
Label gap6px

Do

  • Always show the label above the field
  • Show validation errors inline, below the field
  • Use placeholder for example input, not instructions
  • Set autocomplete for common fields (email, name)

Don't

  • Use placeholder text as the only label
  • Show all errors only on submit
  • Use a red border with no explanatory message
  • Stack multiple instructions inside the field

Accessibility

  • Label associated via htmlFor / id
  • Error linked with aria-describedby
  • aria-invalid="true" when in error state
  • autocomplete attribute for common fields

Spec sourced from mathesis ui-component/text-input

Props (17)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
idstring—Explicit element id; auto-generated when omitted and wired to the label.
labelstring—Visible field label shown above the input.
hintstring—Helper text shown below the field when there is no error.
errorstring—Error message; renders the field in the error state and sets aria-invalid.
requiredboolean—Marks the field required with a visual asterisk.
disabledboolean—Disables input and dims the field.
readOnlyboolean—Makes the field read-only while keeping the value visible.
prefixstring—Static text shown inside the field on the left (e.g. a currency code).
suffixstring—Static text shown inside the field on the right (e.g. a unit).
typestring"text"Native input type; "password" adds a reveal toggle.
numericboolean—Right-aligns the text with tabular figures for numeric entry.
placeholderstring—Example input shown when empty — not a substitute for the label.
valuestring—Controlled input value.
onChange(e: ChangeEvent<HTMLInputElement>) => void—Change handler; fires after any rule enforcement is applied.
onBlur(e: FocusEvent<HTMLInputElement>) => void—Blur handler; fires after rule-based formatting on blur.
autoCompletestring—Native autocomplete hint for common fields (email, name, etc.).
ruleInputRule—Keystroke/blur validation rule that strips disallowed characters, caps length, and pretty-prints on blur.

Field types

 

We'll never share it.

 

KES

 

That username is taken.

Full source
The complete component, read from src/components/ui/input.tsx.
input.tsx
"use client";

import { useState, useId, useCallback, type ChangeEvent, type FocusEvent } from "react";
import { Eye, EyeOff } from "lucide-react";
import { cn } from "@/lib/utils";
import { Field, controlBase, controlClasses } from "./field";
import { enforceInputRule, formatOnBlur, type InputRule } from "@/lib/validation";

interface InputProps {
  id?: string;
  label?: string;
  hint?: string;
  error?: string;
  required?: boolean;
  disabled?: boolean;
  readOnly?: boolean;
  prefix?: string;
  suffix?: string;
  type?: string;
  numeric?: boolean;
  placeholder?: string;
  value?: string;
  onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
  onBlur?: (e: FocusEvent<HTMLInputElement>) => void;
  autoComplete?: string;
  /**
   * Keystroke/blur validation rule. When set, the input strips disallowed
   * characters and caps length on every change (before `onChange` fires), and
   * pretty-prints via `rule.format` on blur. Also drives `inputMode` and the
   * native `maxLength`. See `@/lib/validation`.
   */
  rule?: InputRule;
}

export function Input({
  id,
  label,
  hint,
  error,
  required,
  disabled,
  readOnly,
  prefix,
  suffix,
  type = "text",
  numeric,
  placeholder,
  value,
  onChange,
  onBlur,
  autoComplete,
  rule,
}: InputProps) {
  const [reveal, setReveal] = useState(false);
  const isPw = type === "password";
  const reactId = useId();
  const inputId = id ?? reactId;
  const messageId = `${inputId}-message`;
  const describedBy = error || hint ? messageId : undefined;

  const handleChange = useCallback(
    (event: ChangeEvent<HTMLInputElement>) => {
      if (rule) {
        const enforced = enforceInputRule(event.target.value, rule);
        if (enforced !== event.target.value) event.target.value = enforced;
      }
      onChange?.(event);
    },
    [rule, onChange]
  );

  const handleBlur = useCallback(
    (event: FocusEvent<HTMLInputElement>) => {
      if (rule?.format) {
        const formatted = formatOnBlur(rule.format, event.target.value);
        if (formatted !== event.target.value) {
          event.target.value = formatted;
          onChange?.(event as unknown as ChangeEvent<HTMLInputElement>);
        }
      }
      onBlur?.(event);
    },
    [rule, onChange, onBlur]
  );

  return (
    <Field
      label={label}
      hint={hint}
      error={error}
      required={required}
      htmlFor={inputId}
      messageId={messageId}
    >
      <div className="relative flex items-center">
        {prefix && (
          <span className="pointer-events-none absolute left-2.5 text-sm text-text-tertiary">
            {prefix}
          </span>
        )}
        <input
          id={inputId}
          type={isPw && reveal ? "text" : type}
          disabled={disabled}
          readOnly={readOnly}
          placeholder={placeholder}
          value={value}
          onChange={handleChange}
          onBlur={handleBlur}
          inputMode={rule?.inputMode}
          maxLength={rule?.maxLength}
          autoComplete={autoComplete}
          aria-invalid={Boolean(error) || undefined}
          aria-describedby={describedBy}
          className={cn(
            controlBase,
            controlClasses(error, disabled, readOnly),
            "h-10",
            prefix ? "pl-7" : "pl-3",
            suffix || isPw ? "pr-9" : "pr-3",
            numeric && "text-right tabular-nums"
          )}
        />
        {isPw ? (
          <button
            type="button"
            onClick={() => setReveal((r) => !r)}
            aria-label={reveal ? "Hide password" : "Show password"}
            className="absolute right-2 rounded p-1 text-text-tertiary hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
          >
            {reveal ? <EyeOff size={15} aria-hidden /> : <Eye size={15} aria-hidden />}
          </button>
        ) : suffix ? (
          <span className="pointer-events-none absolute right-3 text-sm text-text-tertiary">
            {suffix}
          </span>
        ) : null}
      </div>
    </Field>
  );
}

Dependencies

To use Input you also need the following. Copy these into your project alongside it.

Recommended folder structure

project structure
src/components/ui/
  ├─ field.tsx
  └─ input.tsx
src/lib/
  └─ utils.ts

Dependency files

These supporting files aren’t components — copy each one into the matching path above.

src/components/ui/field.tsx
field.tsx
"use client";

import { type ReactNode } from "react";
import { cn } from "@/lib/utils";

interface FieldCounter {
  text: string;
  warn?: boolean;
}

interface FieldProps {
  label?: string;
  hint?: string;
  error?: string;
  required?: boolean;
  htmlFor?: string;
  counter?: FieldCounter;

  messageId?: string;
  children: ReactNode;
}

export function Field({
  label,
  hint,
  error,
  required,
  htmlFor,
  counter,
  messageId,
  children,
}: FieldProps) {
  const message = error || hint;
  return (
    <div className="flex w-full flex-col gap-1.5">
      {label && (
        <label
          htmlFor={htmlFor}
          className="flex items-center gap-1 text-[13px] font-medium text-text"
        >
          {label}
          {required && (
            <span className="text-error" aria-hidden>
              *
            </span>
          )}
        </label>
      )}
      {children}
      <div className="flex items-start justify-between gap-3">
        <p
          id={messageId}
          className={cn("text-xs leading-snug", error ? "text-error" : "text-text-secondary")}
        >
          {message || "\u00A0"}
        </p>
        {counter && (
          <span
            className={cn(
              "shrink-0 text-xs tabular-nums",
              counter.warn ? "text-error" : "text-text-tertiary"
            )}
          >
            {counter.text}
          </span>
        )}
      </div>
    </div>
  );
}

export const controlBase = cn(
  "w-full rounded-lg border text-sm bg-surface text-text transition-colors",
  "placeholder:text-text-tertiary",
  "focus:outline-none focus-visible:outline-none focus-visible:border-accent",
  "disabled:cursor-not-allowed disabled:bg-bg-secondary disabled:opacity-60",
  "read-only:bg-bg-secondary"
);

export function controlClasses(error?: string, disabled?: boolean, readOnly?: boolean) {
  return cn(
    error ? "border-error focus-visible:border-error" : "border-border",
    readOnly && "read-only:bg-bg-secondary",
    disabled && "text-text-tertiary"
  );
}
src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * Merge class names with clsx semantics + Tailwind conflict resolution.
 * Prefer this in all components.
 */
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}

/**
 * Concatenate class names, filtering out falsy values.
 * @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
 */
export function cx(...args: (string | false | null | undefined)[]): string {
  return args.filter(Boolean).join(" ");
}

/**
 * Focus ring style helper.
 * @deprecated Focus rings are now handled globally via the
 * `:focus-visible` rule in `globals.css` and the `focus-visible:*`
 * utilities. This remains only for backwards compatibility.
 */
export function ring(color: string = "var(--color-accent)") {
  return { outlineColor: color };
}