Skip to content
Drivn
6 min read

Drivn vs shadcn/ui — Label Component Compared

Compare Drivn Label vs shadcn/ui — a twelve-line native label with zero runtime deps versus a Radix Label primitive wrapper with peer-disabled state styles.

Drivn's Label and shadcn/ui's Label both render a native HTML <label> element styled with text-sm font-medium and a cn() className merge. The end result is the same DOM node — a single <label htmlFor="..."> that the browser uses to forward clicks to the associated form control. The two components split on one runtime decision and three styling choices, and that decision drives the rest of the differences you feel when wiring up a form.

Drivn ships about twelve lines of TypeScript over the native <label> element. There is no "use client" directive, no external dependency, no data-slot attribute, no peer-state selectors — just text-sm font-medium text-foreground merged through cn() and a spread of the standard HTML label props. shadcn/ui wraps Radix UI's @radix-ui/react-label primitive, which adds one runtime dependency to your bundle and one "use client" boundary to the component file. The Radix primitive renders the same <label> element underneath, so the rendered HTML matches, but the install footprint and the surrounding API surface differ.

This page walks through every difference: the dependency footprint, the "use client" boundary, the peer-disabled and group-disabled styling shadcn adds, and the call-site shape when you pair Label with an Input or Checkbox. Both ship as MIT, copy-paste friendly source files — the question is which surface area fits your form stack better.

Side-by-side comparison

FeatureDrivnshadcn/ui
Underlying primitiveNative <label> + cn()@radix-ui/react-label wrapper
Runtime UI dependenciesZero@radix-ui/react-label
"use client" directive
Rendered DOM element<label><label>
htmlFor click forwardingNative browser behaviorNative browser behavior
Default classestext-sm font-medium text-foregroundflex items-center gap-2 text-sm font-medium leading-none select-none
peer-disabled stylingpeer-disabled:cursor-not-allowed peer-disabled:opacity-50
group-data-[disabled] stylinggroup-data-[disabled=true]:opacity-50
data-slot attributedata-slot="label"
Total component lines~12~15
Renders in Server ComponentsYes, but file marked "use client"
LicenseMITMIT
Copy-paste install

The dependency footprint

Drivn's Label is a function over the native <label> element. The file imports React and the cn utility and ships zero runtime UI dependencies. After npx drivn add label your package manifest is unchanged — no new entry in dependencies, no new resolution in the lockfile. The component reads as twelve lines of TypeScript and stays that way through every upgrade because there is nothing under it to upgrade.

shadcn/ui's Label wraps @radix-ui/react-label, which adds one runtime entry to your dependencies and one transitive resolution to the lockfile. The Radix package is small — roughly two kilobytes minified — but it is still one more semver-tracked surface to upgrade and one more name in your dependency graph. For a component this thin, the dependency reads as overhead: the only behavior Radix adds over the native <label> is a polyfill for click forwarding in environments that already work, and the click forwarding in modern browsers works without it. Compare this with the Drivn vs shadcn Input pattern where the trade-offs around state libraries are larger.

1// Drivn — native <label> wrapper, no runtime UI deps (verbatim)
2import * as React from 'react'
3import { cn } from '@/utils/cn'
4
5export function Label({
6 className,
7 ...props
8}: React.LabelHTMLAttributes<HTMLLabelElement>) {
9 return (
10 <label
11 className={cn(
12 'text-sm font-medium text-foreground',
13 className
14 )}
15 {...props}
16 />
17 )
18}
19
20// shadcn/ui — Radix primitive wrapper, "use client" boundary
21"use client"
22import * as LabelPrimitive from "@radix-ui/react-label"
23
24function Label({ className, ...props }) {
25 return (
26 <LabelPrimitive.Root
27 data-slot="label"
28 className={cn(
29 "flex items-center gap-2 text-sm font-medium leading-none select-none",
30 "peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
31 "group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
32 className
33 )}
34 {...props}
35 />
36 )
37}

The "use client" boundary

Drivn's Label has no "use client" directive because the component uses no client-only APIs. There is no useState, no useEffect, no event handlers in the component body. Render the Label inside a Next.js Server Component and it streams as part of the static HTML — no client bundle weight, no hydration cost, no boundary the bundler has to draw around it. Pair it with a Client Component Input and the boundary lives on the Input, not on the Label.

shadcn/ui's Label file starts with "use client" because Radix's Label primitive carries hooks under the hood. The directive forces the file into the client bundle even when every call site renders inside a Server Component, which means the Radix runtime ships down the wire on every page that imports a Label. For a form-heavy app the cost is small in absolute terms but consistent — every page that renders a labeled field pays the Radix tax. Drivn skips this by not depending on Radix in the first place.

Peer-disabled and group-disabled styling

shadcn/ui's Label ships with two opinionated state selectors baked into the className. The peer-disabled:cursor-not-allowed peer-disabled:opacity-50 rule fades the Label when a sibling input marked with peer is disabled. The group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 rule does the same when an ancestor container carries data-disabled="true". Both selectors assume a specific markup convention — that you wrap the input with the peer class or wrap the form field with a group parent — and they fail open when the convention is missing.

Drivn's Label ships with none of this. The component is a styled native <label> with text-sm font-medium text-foreground and nothing else. To handle disabled state in a Drivn form, add the conditional class at the call site — <Label className={isDisabled && "opacity-50"} ...> — or extend the Label file in your repo with a disabled prop typed as boolean. Both options are explicit edits to the local source, which trades the shadcn baked-in convention for a more direct control surface. For form patterns at scale, see how the Drivn Input and Checkbox components pair without an enforced peer convention.

Call site shape with Input and Checkbox

Both libraries pair the Label with form controls the same way at the call site. Pass htmlFor on the Label and id on the control, and the browser handles click-to-focus natively. With a Drivn Input the wiring is a flex flex-col gap-2 container, a Label on top, an Input below, and the same htmlFor/id pair. With a Drivn Checkbox the wiring is a flex items-center gap-2 container, a Checkbox on the left, a Label on the right, same htmlFor/id pair.

shadcn's default Label adds flex items-center gap-2 directly to the Label className, which means a Label rendered next to a Checkbox already has the inline-flex layout built in. Drivn keeps the Label layout-neutral and asks the consumer to wrap it in the container of their choice, which adds one wrapping div to the Checkbox case but keeps the Label itself flexible for non-inline layouts (a stacked Input field above the Label, a Label as a section heading, a Label inside a Card header). The trade is a tiny amount of wrapping markup at the call site versus a tiny amount of baked-in layout on the component.

1// Drivn — explicit wrapper for inline layouts
2import { Label } from '@/components/ui/label'
3import { Checkbox } from '@/components/ui/checkbox'
4
5export default function Terms() {
6 return (
7 <div className="flex items-center gap-2">
8 <Checkbox id="terms" />
9 <Label htmlFor="terms">
10 Accept terms and conditions
11 </Label>
12 </div>
13 )
14}

When each wins

Pick shadcn/ui's Label when you want the disabled-state styling out of the box, you are already invested in Radix primitives across your form stack (Radix's Dialog, Select, and Popover all sit next to it), and the "use client" directive on every form field is acceptable for your routing model. The peer-disabled and group-disabled rules slot into shadcn's broader form conventions cleanly, and the Radix dependency is one entry in a project that already pulls Radix for other components.

Pick Drivn's Label when you want zero runtime deps on the smallest possible form primitive, you want the Label to render inside a Next.js Server Component without a client bundle hit, and you prefer explicit className composition at the call site over baked-in state selectors. The component is twelve lines, has no transitive dependencies, and pairs with Drivn's Input, Checkbox, and Select without any cross-component conventions. For the broader argument around Radix versus native primitives, see Drivn vs shadcn Button.

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. Both render a single native <label> element with a className applied via cn(). Drivn imports the native element directly; shadcn wraps Radix UI's LabelPrimitive.Root, which itself renders a <label>. The output HTML is a <label htmlFor="..."> tag in either library, so the browser handles click-to-focus natively in both cases and screen readers see the same accessibility tree. The visible difference at the DOM level is the data-slot="label" attribute shadcn adds and the larger default className shadcn ships, which carries peer-disabled and group-disabled rules.

Yes — the click forwarding is a native browser feature of the <label> element. Pass htmlFor="email" on the Label and id="email" on the Input, and the browser focuses the Input when the user clicks the Label text. No JavaScript runs, no React event handler fires, no library code mediates the focus. The behavior is identical between Drivn and shadcn because both libraries render the same native <label> tag underneath, so the focus forwarding ships as part of the platform rather than as a library feature.

Drivn keeps the Label layout-neutral, so add the disabled class at the call site. Pass className={isDisabled && "opacity-50 cursor-not-allowed"} to the Label when the associated input is disabled. If you want the same convention everywhere, extend the local @/components/ui/label.tsx file with a disabled prop typed as boolean that toggles the classes. The file lives in your repo after install, so the edit is a six-line change in one place rather than a runtime prop or a global override. shadcn bakes the rule into the className via the peer-disabled: selector, which assumes a peer class on the sibling input.

Yes. The Label file has no "use client" directive because the component uses no client-only APIs — no useState, no useEffect, no event handlers. You can render Label elements directly inside a Server Component for a static form layout, marketing page, or onboarding screen, and the component streams as part of the HTML response without a client bundle hit. shadcn's Label file is marked "use client" because Radix's primitive carries hooks, which means the Radix runtime ships down the wire even on pages that only render static labels.