Skip to content
Drivn
8 min read

Drivn vs shadcn/ui — Progress Compared

Compare Drivn Progress vs shadcn/ui — a forty-line presentational bar versus a Radix Progress wrapper. Both copy-paste, one ships zero runtime deps.

Put Drivn's Progress next to shadcn/ui's and the rendered result is the same: a rounded track with a filled bar sized to value / max. The code underneath is not. shadcn/ui re-exports ProgressPrimitive.Root and ProgressPrimitive.Indicator from @radix-ui/react-progress, styles the root, and pushes the indicator into place with a transform: translateX(-${100 - value}%) inline style. Drivn ships the bar itself — forty lines at @/components/ui/progress.tsx, a two-key styles object, one clamped percentage, and an inline width on the inner <div>. Its imports stop at React for the type namespace and the local cn() helper.

Two consequences follow, and both matter more than the line count. The Drivn file never calls a hook, so it carries no 'use client' directive and renders inside a Server Component untouched. And the entire percentage path — clamp, inline style, ARIA attributes — sits on one screen you can edit without opening a Radix API reference first.

What you give up is narrow but real: no indeterminate value={null} mode, no getValueLabel formatter for custom screen-reader strings, and a width transition rather than a compositor-friendly transform. The sections below trace the split through the dependency graph, the percentage math, the ARIA contract, the animation model, and the call site, so you can tell which column your progress bar actually belongs in.

Side-by-side comparison

FeatureDrivnshadcn/ui
Underlying primitivePlain JSX + inline percentage math@radix-ui/react-progress
Runtime UI dependenciescn() utility only@radix-ui/react-progress + cn() utility
"use client" directive
Indicator transformInline `width: ${pct}%` on inner divtranslateX(-${100 - value}%) on indicator
Indeterminate stateYes — pass `value={null}`
Value clampingMath.min(100, Math.max(0, value / max * 100))Radix clamps internally
ARIA attributesaria-valuenow, aria-valuemin, aria-valuemax wired manuallyWired by Radix
Custom max valueYes — `max` prop, default 100Yes — `max` prop
Total component lines~40~25 (plus Radix package)
LicenseMITMIT
Copy-paste install

The runtime footprint

shadcn/ui's Progress is a thin re-export layer over @radix-ui/react-progress. The component file is roughly twenty-five lines: it imports ProgressPrimitive from the Radix package, wraps ProgressPrimitive.Root with the project's bg-primary/20 relative h-2 w-full overflow-hidden rounded-full className, and renders a ProgressPrimitive.Indicator child whose inline style sets transform: translateX(-${100 - (value || 0)}%). The Radix package itself implements the ARIA progressbar pattern, the indeterminate state, the value clamping, and the controlled-vs-uncontrolled semantics.

Drivn's Progress ships forty lines that import React for the type namespace and cn for className merging. There is no 'use client' directive because the file does not use useState, useEffect, or any other client-only API — the percentage is computed inline during render and written into the inner <div> width. The styles object holds two keys and is reproduced verbatim from the registry below. The same dependency-shape trade-off appears in Drivn vs shadcn Card and Drivn vs shadcn Popover — Drivn keeps the primitive local, shadcn delegates to Radix.

1// Drivn — styles object, no Radix (verbatim from registry)
2const styles = {
3 track: 'w-full h-2 bg-accent rounded-full overflow-hidden',
4 bar: cn(
5 'h-full bg-primary rounded-full',
6 'transition-all duration-300 ease-out'
7 ),
8}

How the percentage is computed

Drivn computes the fill percentage inline during render. The component reads value (default 0) and max (default 100) from props, then runs Math.min(100, Math.max(0, (value / max) * 100)) to clamp the result into the [0, 100] range. The clamp covers the two edge cases that matter — a value greater than max (the bar still caps at 100% rather than overflowing) and a negative value (the bar caps at 0% rather than rendering a negative width). The clamped number is written into the inner <div> width via the style prop, which is the most performant path because the browser updates the inline width on each re-render without invalidating the surrounding layout.

shadcn/ui delegates the same problem to Radix. The Radix root accepts value and max and computes the indicator transform as translateX(-${100 - (value || 0)}%) — at zero the indicator sits fully off-screen to the left, at 100 the indicator sits flush at the left edge. The transform approach uses the GPU compositor rather than layout, which animates more smoothly for high-frequency value updates (an upload progress bar that updates every few hundred milliseconds, for instance). Drivn's width approach is fine for the steadier update cadences that ship in practice — multi-step form progress, page-scroll progress, a passwordstrength meter that updates on keystroke.

1// Drivn — clamped percentage written to inline width (verbatim)
2export function Progress({
3 value = 0,
4 max = 100,
5 className,
6}: ProgressProps) {
7 const pct = Math.min(100, Math.max(0, (value / max) * 100))
8 return (
9 <div
10 role="progressbar"
11 aria-valuenow={value}
12 aria-valuemin={0}
13 aria-valuemax={max}
14 className={cn(styles.track, className)}
15 >
16 <div
17 className={styles.bar}
18 style={{ width: `${pct}%` }}
19 />
20 </div>
21 )
22}

ARIA wiring and the indeterminate gap

Drivn wires the ARIA progressbar pattern by hand. The outer <div> carries role="progressbar", aria-valuenow={value}, aria-valuemin={0}, and aria-valuemax={max}. Screen readers read the four attributes together to announce the current value and the range — "Progress, fifty percent" or "Progress, fifty out of one hundred" depending on the assistive tech. The four attributes ship with the registry source so a copy-paste install lands on an accessible bar without further work. The clear omission is the aria-busy and indeterminate-state pair: Drivn does not support value={null} for an indeterminate spinner-style bar, so a file upload whose total size is unknown does not have a built-in shape.

shadcn/ui inherits the full Radix progressbar contract: passing value={null} (or omitting value) flips the indicator into indeterminate mode and drops aria-valuenow, which is the correct ARIA pattern for indeterminate progressbars. Radix also supports the WAI-ARIA getValueLabel callback for custom announcement strings — useful for the "step three of five" framing that benefits from a human label rather than a raw percentage. For projects that need indeterminate state or custom labels, the shadcn flavor is the right call. For projects where the value is always known (form progress, scroll progress, upload progress with a known total), the Drivn wiring is enough. The same trade-off framing applies to Drivn Slider — manual ARIA on a thin file, Radix wiring on the shadcn equivalent.

Animation and the transition class

Drivn's animation is one line of Tailwind. The bar key merges h-full bg-primary rounded-full with transition-all duration-300 ease-out, so every time value changes the inner <div> eases its width from the old percentage to the new one over 300ms. Nothing in the component schedules that — the browser does, because the inline style write is an ordinary property change on an element that already carries a transition. It is the reason the file needs no hook, no effect, and no animation library to move.

The cadence suits step-driven progress well: a five-step form advancing a fifth of the track, a checklist ticking off a task, a scroll indicator sampling on a throttled listener. It suits fast progress less well. A download reporting every 100ms queues a new 300ms transition before the previous one lands, and the bar reads as a smooth lag behind the true number rather than the number itself. Passing a shorter duration through className does not fix it either — className merges onto the outer track, while the transition lives on the inner bar, so the honest fix is editing styles.bar in the component file.

shadcn/ui animates the Radix indicator's translateX instead. Transforms composite on the GPU and skip layout entirely, which is why that approach holds up at animation-frame cadence where a width transition starts to cost. At the update rates most progress bars actually run at, the two are indistinguishable. Drivn Skeleton makes the same bet — a CSS-only animation with no runtime scheduling behind it.

Call-site shape and props

Drivn uses a single component. The call site reads <Progress value={65} /> for a fixed value, or <Progress value={progress} max={steps} /> when the bar tracks a step counter. One import — import { Progress } from "@/components/ui/progress" — covers the whole component. There is no Progress.Indicator, no Progress.Root, no compound API — the registry ships one named export. The TypeScript signature accepts value, max, and className, and the component clamps any out-of-range value to [0, 100] without throwing.

shadcn/ui ships the same <Progress value={65} /> shape because the wrapper hides the Radix Root/Indicator split — the consumer never sees the two pieces. The differences appear when the project needs the indeterminate state (<Progress value={null} /> on shadcn, edit the component file on Drivn), when the project needs custom announcement strings (<Progress getValueLabel={(v, max) => "Step " + v + " of " + max} /> on shadcn, not exposed on Drivn), or when the project wants to use the Radix unstyled primitive elsewhere (already paid the dependency on shadcn, would re-introduce it on Drivn). For most progress bars — a determinate value from zero to a known max — both call sites read identically. See Progress examples for the call-site shapes Drivn ships out of the box.

1// Drivn — one import, one component
2import { Progress } from "@/components/ui/progress"
3
4export default function UploadBar({ percent }: { percent: number }) {
5 return <Progress value={percent} />
6}

When each wins

Pick shadcn/ui's Progress when the project already standardizes on Radix elsewhere (Dialog, Tooltip, Popover) and the progress bar needs indeterminate state, custom ARIA labels, or the transform-based animation curve. Upload bars with unknown total sizes, multi-step indicators that benefit from "Step N of M" announcements, and high-frequency progress (think audio playback scrubbers re-rendering every animation frame) all live in this column because the Radix machinery handles them without per-bar wiring. The cost is one Radix package in the bundle, which most projects already pay for through another component.

Pick Drivn's Progress when the priority is keeping the dependency tree small and the call site editable. Form progress, page-scroll progress, password-strength meters, and any determinate value known at render time all live in this column because the forty-line file reads top-to-bottom and the styles object lives in plain sight. The component does not need 'use client', so it renders on the server inside a Server Component out of the box — useful for content-driven pages where the progress reflects build-time data. Pair the Progress with the Drivn Card for the surrounding shell and the Drivn Badge for the matching label. See the Progress examples page for the call-site shapes that fit each scenario.

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

No. The component does not use React.useState, React.useEffect, or any other client-only API — the percentage is computed inline during render and written into the inner <div> width via the style prop. The registry source ships without a 'use client' directive, which means the component renders inside a Server Component without forcing the surrounding tree onto the client. The shadcn/ui flavor wraps a Radix client component, so it carries the directive (and the client-boundary cost) by default.

The component clamps the computed percentage with Math.min(100, Math.max(0, (value / max) * 100)) before writing it to the inner <div> width. A value of 150 with the default max of 100 caps at 100% rather than overflowing the track. A negative value caps at 0% rather than rendering a negative width. The clamp lives directly in the registry source, so any edit to the percentage math happens in one visible place rather than inside a Radix internal.

Not out of the box. The registry source assumes value is always a known number — there is no value={null} branch, no looping animation on the bar, and no aria-busy toggle. Adding indeterminate support is a small edit: branch on value === null, render the bar with a looping animate-pulse (or a custom keyframes animation) instead of the static width, and drop the aria-valuenow attribute when the state is indeterminate. The patch is about ten lines and keeps the file under fifty lines total. The shadcn/ui flavor inherits indeterminate support from Radix automatically.

The styles.bar className uses bg-primary so the bar matches the project's primary brand color through the Tailwind theme. To change the color for one call site, pass a className override through the optional className prop, which cn() merges into the outer track className — note this changes the track color, not the bar color. To change the bar color, either fork the registry source to accept a barClassName prop, or change the project-level --color-primary token in globals.scss. The same trade-off — one place to edit, no API surface for the override — applies across the Drivn registry.

The bar uses transition-all duration-300 ease-out on the inner <div>, and the inline style writes the new width directly. Width transitions are slightly less performant than transform transitions because the browser may invalidate layout, but for the typical progress cadences (step-driven, scroll-driven, slow-tick upload progress) the difference is invisible. The width approach reads more clearly in the registry source — one inline style, one transition class, no translateX(-${100 - value}%) inversion math. For a high-frequency progress bar where transform animation matters, the four-line edit is to swap the inline width for transform: scaleX(${pct / 100}) plus transformOrigin: left.