Building a Label Component That Actually Connects to Its Field

We've referenced a Label component in every form field article so far without building it. That's intentional — you need to understand what the Label connects to before you understand what it needs to do. Now that we have Input, Textarea, Checkbox, RadioGroup, Select, and Toggle, the Label's job is clear: it's not just styled text. It's the accessible name of a form field, and it carries required indicators, optional markers, and disabled state as part of that responsibility.
Building a Label Component That Actually Connects to Its Field
Every form field we've built so far has had a label connection — htmlFor pointing to an id, generating a stable ID with useId, connecting error messages with aria-describedby. The Label component is the missing piece that completes that connection on the visible side.
A plain <label> gets you 80% of the way there. The remaining 20% is what this component handles: communicating whether a field is required or optional, reflecting the disabled state of the field it's connected to, and doing all of this in a way that works for sighted users and screen reader users alike.
The four files
Label.variants.ts
Two CVA definitions: labelVariants for the label itself, and labelIndicatorVariants for the required asterisk or optional marker. Keeping the indicator as its own variant lets us style it independently without conditional className logic in JSX.
Label.types.ts
required and optional are mutually exclusive in practice — a field is either required, optional, or neither — but we expose both as separate boolean props and let the component decide what to render. If both are passed, required wins.
We extend React.LabelHTMLAttributes<HTMLLabelElement> directly — no Omit needed because HTMLLabelElement has no conflicting attributes with our custom props.
Label.tsx
What each decision is doing
aria-hidden="true" on the visual indicator — the asterisk and "(optional)" text are visual shorthands. They're hidden from screen readers because the semantic meaning is communicated separately. A screen reader user doesn't need to hear "asterisk" — they need to know the field is required, which is communicated through aria-required on the input itself.
<span className="sr-only">required</span> — this is the screen-reader-only counterpart to the visual asterisk. When a screen reader reads the label, it reads the visible text plus this hidden span — "Email address required." The sr-only Tailwind class positions the element off-screen without hiding it from the accessibility tree. This gives screen reader users the same information sighted users get from the asterisk, without the redundancy of reading "asterisk."
required wins over optional — !required && optional in the JSX means the optional marker only appears when the field is explicitly not required. A field cannot be both. This prevents the accidental "(optional)" label appearing on a required field if a consumer passes both props.
disabled reflects the field state — the Label doesn't know if its connected field is disabled. The consumer passes disabled explicitly. This is intentional — the Label has no way to observe the state of another element. Consumers who use Label alongside Input should pass the same disabled value to both:
forwardRef — form libraries and test utilities sometimes need a ref to the label element. We follow the library convention of always forwarding refs on elements that wrap a single DOM node.
htmlFor comes from LabelHTMLAttributes — we don't declare htmlFor explicitly. It's inherited from React.LabelHTMLAttributes<HTMLLabelElement>. Consumers pass it the same way they would on a plain <label>.
Composing Label with form fields
Here's how Label composes with every form field we've built so far:
The FormField wrapper — a preview
In article 32 we'll build a Form component that wraps Label, the field, error messages, and hint text into a single FormField compound component. The manual div + gap-1.5 pattern above works perfectly for now — the FormField component will simply formalise that pattern and wire it to react-hook-form automatically.
The component index
src/components/Label/index.ts:
Update the root entry point
Add to src/index.ts:
Usage
The decision rule
Always use a Label — or at minimum an aria-label — on every form field. A field without a label is inaccessible to screen reader users and fails basic WCAG compliance. If the design calls for a field with no visible label, use <Label className="sr-only"> to keep the label in the DOM and accessible without showing it visually. Never use placeholder as a substitute for a label — placeholders disappear when the user starts typing and are not reliably announced by screen readers.
Up next: Building a Badge component — variants, sizes, and dot indicators — a break from form fields. Badge is the first purely presentational primitive in the series — no form integration, no event handling, just a small but surprisingly nuanced display component.
Related: Building a Form component — field layout, error display, and submission state — the Form component in article 32 formalises the Label + field + error pattern we've been composing manually throughout this series. Worth looking ahead to understand where this is going.
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 Andrzej Gdula on Unsplash

