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