Skip to content
Drivn
9 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.

Drivn's Progress and shadcn/ui's Progress both render the same affordance — a horizontal bar that fills from left to right in proportion to a value between zero and max — but they reach that result through very different code. shadcn/ui wraps @radix-ui/react-progress and re-exports ProgressPrimitive.Root and ProgressPrimitive.Indicator with a transform: translateX(-${100 - value}%) style on the indicator. Drivn ships a forty-line presentational component that lives at @/components/ui/progress.tsx, computes a clamped percentage inline, and writes the result into an inner <div> width via the style prop. There is no Radix package, no 'use client' directive, and no hooks — the file imports React only for the type namespace and cn() only to merge the optional className.

The practical consequence of dropping Radix is a Progress component that renders on the server out of the box and edits in place without learning a Radix API surface. The styles object holds two keys, track and bar, and the JSX is a role="progressbar" outer <div> with the three required ARIA attributes (aria-valuenow, aria-valuemin, aria-valuemax) plus an inner <div> whose width is set to the computed percentage. The cost is that Drivn's flavor does not ship an indeterminate state, does not animate the indicator entry through a transform, and does not expose a getValueLabel formatter — the Radix wrapper covers those three cases out of the box.

This page walks through the runtime footprint, the percentage math, the ARIA wiring, the animation model, and the call-site shape so you can decide which flavor to copy into a new project. Read it before you reach for either component, because the trade-off is real and depends on whether the project already standardizes on Radix elsewhere.

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 animates the bar through a CSS transition on the inner <div>. The bar className includes transition-all duration-300 ease-out, so when the value prop changes the inner <div> width transitions from the old percentage to the new percentage over 300ms with an ease-out curve. The behaviour feels right for step-driven progress (advancing through a multi-step form, completing a task in a checklist) and for slower update cadences. For a fast-updating value — an upload progress bar that ticks every 100ms — the 300ms transition can lag visibly behind the actual value, in which case overriding the transition duration through the optional className prop is the small fix.

shadcn/ui animates the indicator through a CSS transform transition. The Radix indicator uses transition: all .5s ease-out (or whatever the project sets on the wrapper), and the translateX transform composites on the GPU rather than triggering a layout. The two approaches end up visually similar at typical progress cadences, with the transform-based animation pulling ahead at high-frequency updates because the browser does not have to re-measure the layout on each frame. The Drivn approach trades a little smoothness at high frequencies for a code path that reads top-to-bottom in twenty lines and has no Radix surface to learn.

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.