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.
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | — | Explicit element id; auto-generated when omitted and wired to the label. |
| label | string | — | Visible field label shown above the input. |
| hint | string | — | Helper text shown below the field when there is no error. |
| error | string | — | Error message; renders the field in the error state and sets aria-invalid. |
| required | boolean | — | Marks the field required with a visual asterisk. |
| disabled | boolean | — | Disables input and dims the field. |
| readOnly | boolean | — | Makes the field read-only while keeping the value visible. |
| prefix | string | — | Static text shown inside the field on the left (e.g. a currency code). |
| suffix | string | — | Static text shown inside the field on the right (e.g. a unit). |
| type | string | "text" | Native input type; "password" adds a reveal toggle. |
| numeric | boolean | — | Right-aligns the text with tabular figures for numeric entry. |
| placeholder | string | — | Example input shown when empty — not a substitute for the label. |
| value | string | — | 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. |
| autoComplete | string | — | Native autocomplete hint for common fields (email, name, etc.). |
| rule | InputRule | — | 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 };
}