Skip to content
Drivn
5 min read

Next.js Label for Accessible Form Fields

Add a Next.js form label that renders in Server Components with zero client JS. Drivn ships one Label file, htmlFor association, and no runtime deps.

A form label is the smallest piece of accessibility infrastructure most apps ship — the word above an input that tells a screen reader what the field is for and gives every user a larger click target that focuses the control. In the Next.js App Router a label is pure markup: it holds no state and runs no effect, so it renders entirely on the server and ships zero client JavaScript. The one thing that matters is the htmlFor connection to the input's id, and that is plain HTML that behaves identically on the server and the client.

Drivn's Label is built for exactly that. After install it lives in src/components/ui/label.tsx as a single function around a native <label>, with one cn() call holding its text-sm font-medium text-foreground classes. It is not marked 'use client' — there are no hooks, no effects, no state — so you can render it directly inside a server-rendered page or a <form> that posts to a Server Action. Every native label attribute passes straight through {...props} to the DOM element, htmlFor included.

This guide installs Drivn in a Next.js 16 project, renders a label wired to an input in a Server Component, drops the pair into a Server Action form, associates a label with a checkbox, and restyles the one className every label reads from. Every snippet comes from the component's real API. For the full reference see the Label docs; for the shadcn/ui comparison see Drivn vs shadcn/ui Label.

Install in a Next.js 16 project

Drivn installs through a small CLI that writes the component source directly into your repository — there is no runtime npm package to version-lock. From the root of your Next.js 16 project run npx drivn add label. The Label has no component dependencies and imports no icons, so the CLI writes a single label.tsx file and nothing else. The CLI reference documents every flag, including targeting a custom directory or installing several components at once. After install you own the file; future Drivn releases will not overwrite it. Commit the change for a clean baseline before you wire it into forms, and confirm the file landed under your UI directory in the editor.

1# from the root of your Next.js 16 project
2npx drivn add label

Render a labeled field in a Server Component

Because the Label holds no state and runs no effect, its source has no 'use client' directive — you can import and render it inside a server component with zero client JavaScript shipped for the field. The component wraps your text in a native <label> styled to text-sm font-medium text-foreground, so it reads as a small, medium-weight caption above the control. The attribute that does the accessibility work is htmlFor: set it to the id of the input it describes and the browser links the two, so a screen reader announces the label when the field is focused and a click on the label word focuses the input. Because both the Label and Drivn's Input render on the server, an uncontrolled labeled field ships no client JavaScript at all. See the installation guide for project setup.

1// app/settings/page.tsx — a Server Component, no 'use client'
2import { Label } from '@/components/ui/label'
3import { Input } from '@/components/ui/input'
4
5export default function SettingsPage() {
6 return (
7 <div className="space-y-2">
8 <Label htmlFor="email">Email address</Label>
9 <Input id="email" type="email" placeholder="you@example.com" />
10 </div>
11 )
12}

Drop the pair into a Server Action form

Because the Label renders on the server and forwards every native attribute, it drops straight into the App Router's Server Action form pattern with no client boundary. Give each input a name and an id, point each Label's htmlFor at the matching id, attach a server function to the form's action, and read the values off FormData on the server — no onChange, no useState, no client component. The label is doing accessibility work, not state work, so it never needs to hydrate: the browser posts the form natively and the action runs on the server. The htmlFor/id pairing keeps the form usable for keyboard and screen-reader users while shipping zero client JavaScript for the field. For copy-paste labeled-field patterns see the Label examples.

1// app/subscribe/page.tsx
2import { Label } from '@/components/ui/label'
3import { Input } from '@/components/ui/input'
4
5async function subscribe(formData: FormData) {
6 'use server'
7 const email = formData.get('email')
8 // persist the email...
9}
10
11export default function Subscribe() {
12 return (
13 <form action={subscribe} className="space-y-2">
14 <Label htmlFor="email">Email address</Label>
15 <Input id="email" type="email" name="email" required />
16 </form>
17 )
18}

Associate a Label with a checkbox

A label is not only for text inputs — the same htmlFor/id pairing associates it with a Checkbox, a radio, or a Select. Point the Label's htmlFor at the control's id and clicking the label toggles the checkbox or focuses the select, which enlarges the hit target and matches native form behavior. For a checkbox the convention is to place the Label beside the control in a horizontal row rather than above it, so the two read as a single line — a flex items-center gap-2 wrapper handles the layout. The Label's styling stays the same in either arrangement because it carries no positional classes of its own; the wrapper owns the layout and the Label owns only its text-sm font-medium typography. This composability is why the Label ships one className and no variants.

1import { Label } from '@/components/ui/label'
2import { Checkbox } from '@/components/ui/checkbox'
3
4export function TermsRow() {
5 return (
6 <div className="flex items-center gap-2">
7 <Checkbox id="terms" />
8 <Label htmlFor="terms">Accept terms and conditions</Label>
9 </div>
10 )
11}

Customize the one className

Every class the Label renders lives in a single cn() call inside the file you own after install — there are no size or variant maps, because a label is one consistent style across a form. It sets text-sm font-medium text-foreground: a small, medium-weight caption that reads its color from your Tailwind tokens. Edit that one string and every label in your app updates at once, and because text-foreground is a token, changing it in theming re-themes every label for dark and light mode automatically. The className prop merges through cn() for per-instance overrides — pass className="text-base" to enlarge one label or className="text-destructive" to flag an error field without touching the base. The Label source below is copied verbatim from label.ts.

1import * as React from 'react'
2import { cn } from '@/utils/cn'
3
4export function Label({
5 className,
6 ...props
7}: React.LabelHTMLAttributes<HTMLLabelElement>) {
8 return (
9 <label
10 className={cn(
11 'text-sm font-medium text-foreground',
12 className
13 )}
14 {...props}
15 />
16 )
17}
Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

npx drivn@latest create

Requires Node 18+. Works with npm, pnpm, and yarn.

Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

Frequently asked questions

Yes — the Label is one of the Drivn components that is not a client component. Its source has no 'use client' directive because it holds no state and runs no effect; it is a plain function around a native <label>. You can render it directly inside a server-rendered page or a <form> that posts to a Server Action, and it ships zero client JavaScript. Nothing about a label needs the browser — the htmlFor association is plain HTML that the server renders once and never hydrates.

Set the Label's htmlFor prop to the same string as the Input's id. That pairing is standard HTML: the browser links the label to the control, so a screen reader announces the label text when the field is focused, and clicking anywhere on the label focuses the input. Drivn passes htmlFor straight through {...props} to the native <label>, so it works exactly as it would on a raw element. Use React's useId hook to generate a stable, collision-free id when you render the field in a reusable component.

Yes. The same htmlFor/id pairing works with any form control — a checkbox, a radio input, a Select, or a textarea. Point htmlFor at the control's id and clicking the label toggles or focuses it. For a checkbox or radio, place the Label beside the control in a flex items-center gap-2 row rather than above it, so the label and box read as one line. The Label carries no positional classes, so the surrounding wrapper owns the layout in every arrangement.

All of it lives in the single cn() call at the top of label.tsx: text-sm font-medium text-foreground. Edit that string and every label updates at once — bump text-sm to text-base, font-medium to font-semibold, or swap the color token. For a one-off change, pass a className like text-base or text-destructive; it merges through cn() so your override wins over the base. Because the color reads from a Tailwind token, changing it in theming re-themes every label for dark and light mode.