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.
Two libraries, one rendered tag. Drivn's Label and shadcn/ui's Label both put a native HTML <label> on the page carrying text-sm font-medium and a cn() merge, and both leave click-to-focus to the browser rather than to library code. What separates them sits between your import statement and that tag.
Drivn's file is eighteen lines end to end: an import * as React, the cn helper, and one function typed as React.LabelHTMLAttributes<HTMLLabelElement> that merges text-sm font-medium text-foreground and spreads everything else onto the element. No "use client" directive, no data-slot attribute, no state selectors, and no new entry in package.json after npx drivn add label. shadcn/ui's Label wraps @radix-ui/react-label, so the install adds a runtime dependency and the file opens with "use client" — every route rendering a labeled field pulls the Radix runtime into the client bundle even when the label is static text.
What that dependency buys is baked-in state styling: peer-disabled:opacity-50 and group-data-[disabled=true]:opacity-50 fade the label automatically when a marked sibling or ancestor is disabled. The sections below cover the dependency footprint, the server-rendering story, those state selectors, and how each call site reads with the Label above an Input or beside a Checkbox.
Side-by-side comparison
| Feature | Drivn | shadcn/ui |
|---|---|---|
| Underlying primitive | Native <label> + cn() | @radix-ui/react-label wrapper |
| Runtime UI dependencies | Zero | @radix-ui/react-label |
| "use client" directive | ||
| Rendered DOM element | <label> | <label> |
| htmlFor click forwarding | Native browser behavior | Native browser behavior |
| Default classes | text-sm font-medium text-foreground | flex items-center gap-2 text-sm font-medium leading-none select-none |
| peer-disabled styling | peer-disabled:cursor-not-allowed peer-disabled:opacity-50 | |
| group-data-[disabled] styling | group-data-[disabled=true]:opacity-50 | |
| data-slot attribute | data-slot="label" | |
| Total component lines | ~12 | ~15 |
| Renders in Server Components | Yes, but file marked "use client" | |
| License | MIT | MIT |
| 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) 2 import * as React from 'react' 3 import { cn } from '@/utils/cn' 4 5 export 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" 22 import * as LabelPrimitive from "@radix-ui/react-label" 23 24 function 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
The two state rules shadcn bakes into its Label className are conventions as much as styles. peer-disabled:cursor-not-allowed peer-disabled:opacity-50 only fires when the disabled input is a preceding sibling carrying Tailwind's peer class, and group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 only fires when an ancestor carries both group and data-disabled="true". Get the markup order or the marker class wrong and nothing happens — the label sits at full opacity next to a disabled field, which is a silent miss rather than a type error.
Drivn's Label carries text-sm font-medium text-foreground and nothing else, so the disabled state is something you state outright. At the call site that reads <Label className={cn(disabled && "opacity-50 cursor-not-allowed")} htmlFor="email">. If the pattern repeats across a large form stack, add a disabled?: boolean prop to your local @/components/ui/label.tsx — the file lives in your repo after install, so it is a few lines in one place and the type flows to every call site immediately. Either way the rule is visible where the field is written, and it never depends on where the Input or Checkbox happens to sit in the DOM.
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 2 import { Label } from '@/components/ui/label' 3 import { Checkbox } from '@/components/ui/checkbox' 4 5 export 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.
Install Drivn in one command
Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.
npx drivn@latest createRequires Node 18+. Works with npm, pnpm, and yarn.
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.