Building an Input Component with Validation States and Accessibility Built In

A plain HTML input gets you 20% of the way there. The other 80% is validation states, error messages, label connections, ref forwarding, and accessibility attributes that most implementations bolt on as an afterthought. We're building all of it into the component from the start.
The Input is the component your consumers will use more than any other. It lives inside every form, every search bar, every settings page. And because it's so common, the cost of getting it wrong is high — inconsistent error states, broken label associations, missing ref forwarding, and accessibility gaps that only surface when someone tries to use your library with a screen reader.
We're building this one right the first time.
What a production Input actually needs
Before writing a line of code, let's list every state a real Input needs to handle:
- Default — the base appearance, unfocused
- Focus — a visible focus ring that doesn't look like a browser default
- Error — red border, error message below the field
- Success — optional green border when validation passes
- Disabled — greyed out, not interactive, not submitted with the form
- Read-only — looks active but cannot be edited
And every structural concern:
- A label that's semantically connected to the input via
htmlForandid - An error message that's announced by screen readers via
aria-describedby - A ref that form libraries can access via
forwardRef - Full pass-through of native HTML input attributes
None of these are edge cases. They're the baseline.
Defining the types
Create src/components/Input/Input.types.ts:
We Omit the native size attribute because HTML's size (character width) conflicts with our design system's size variant (sm / md / lg). Our variant wins — consumers who need the HTML size attribute can pass it via a data- attribute or use the underlying element directly.
error is the validation message displayed below the field. hint is optional helper text shown when there's no error — "Must be at least 8 characters."
Defining the variants with CVA
In src/components/Input/Input.variants.ts:
The state variant drives the border and ring colour. It's separate from the disabled and read-only states because those are handled by CSS pseudo-classes — they don't need a JavaScript variant.
The full component
What each decision is doing
useId — React 18's built-in hook for generating stable, unique IDs. We use it to connect the input to its error and hint messages via aria-describedby. If the consumer passes their own id, we use that instead — the generated ID is a fallback, not a requirement.
aria-describedby — tells screen readers which elements describe this input. When a user focuses the field, their screen reader reads the label first, then the input value, then the description. If there's an error message connected via aria-describedby, they hear it automatically. Without this connection, the error text is visually present but invisible to assistive technology.
aria-invalid — set to true when there's an error. Screen readers use this to announce "invalid" when the field is focused, alerting the user that something needs attention before the form can be submitted.
role="alert" on the error paragraph — when the error appears dynamically (after form submission or on blur), role="alert" causes screen readers to announce it immediately, without the user having to navigate to it. Use this only on error messages — not hint text, which is informational rather than urgent.
Error overrides state — if both state="success" and error="..." are passed, error wins. We handle this with state: error ? 'error' : state in the CVA call. A consumer should never see a green border with an error message below it.
Hint hidden when error is present — when there's an error, we hide the hint text. Showing both creates noise. The error message is more important and takes the space.
The component index
src/components/Input/index.ts:
Update the root entry point
Add to src/index.ts:
The Label component is built in the next few articles. For now, a plain <label> with htmlFor matching the Input's id gives you the semantic connection.
The decision rule
If the Input needs to display text the user cannot change, use readOnly — not disabled. A disabled input is excluded from form submission and signals "this field doesn't apply." A read-only input is included in form submission and signals "this field applies but you can't change it right now." They're different states with different semantics — pick the right one.
Up next: Building a Design Token System with CSS Custom Properties and Tailwind v4 — Hardcoded colors feel fine when you're building for yourself. The moment someone else installs your library, they become a problem.
Related: Building a Label component that actually connects to its field — the Label and Input are designed to work together. The Label component handles the required indicator, optional marker, and disabled state that complete the field pattern.
Muhammad Athar is the founder of DesignDev.io and the engineer behind everything you read here. He writes about the decisions behind building real products — the components, the architecture, and the tradeoffs that don't make it into the tutorial.
Photo by Zulfugar Karimov on Unsplash

