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

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, type ChangeEvent } from "react";
import { Eye, EyeOff } from "lucide-react";
import { cn } from "@/lib/utils";
import { Field, controlBase, controlClasses } from "./field";

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;

  autoComplete?: string;
}

export function Input({
  id,
  label,
  hint,
  error,
  required,
  disabled,
  readOnly,
  prefix,
  suffix,
  type = "text",
  numeric,
  placeholder,
  value,
  onChange,
  autoComplete,
}: 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;

  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={onChange}
          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