Next.js Progress Bar with the App Router
Add a progress bar to a Next.js App Router app. Drivn ships a server-safe component that maps value and max to a CSS width — install and render in minutes.
A progress bar is the plainest status control there is — a track with a filled portion that tells someone how far along an upload, an install, or a multi-step form has moved. In a Next.js App Router project the useful question is where it renders, and Drivn's Progress answers it cleanly: the component holds no state and touches no browser API, so it renders on the server with no 'use client' boundary at all.
After install the component sits in src/components/ui/progress.tsx as a single function. It takes a value and a max, clamps the ratio into a percentage with Math.min(100, Math.max(0, (value / max) * 100)), and writes that percentage to the fill's inline width. The track is a <div role="progressbar"> carrying aria-valuenow, aria-valuemin, and aria-valuemax, so assistive technology reads the position without any extra wiring. A CSS transition on the fill smooths every width change.
This guide installs Drivn in a Next.js 16 project, renders the bar from server data, drives a live value from a client component when you need motion, and restyles the two-line styles object it reads. Every snippet comes from the component's real API. For the full reference see the Progress docs; for the shadcn/ui comparison see Drivn vs shadcn/ui Progress.
Install in a Next.js 16 project
Drivn installs through a small CLI that writes component source directly into your repository — there is no runtime package to version-lock. From the root of your Next.js 16 project run npx drivn add progress. The Progress component imports nothing past React and the local cn utility, so the CLI writes a single progress.tsx file and leaves the rest of your tree untouched. The CLI reference documents every flag, including targeting a custom directory or installing several components at once. After install you own the file, and future Drivn releases will not overwrite it — commit it for a clean baseline before wiring it into a page. If the same screen also shows a loading placeholder while data resolves, add the Drivn Skeleton with the same command.
1 # from the root of your Next.js 16 project 2 npx drivn add progress
Render on the server with no client boundary
Because Progress holds no state and calls no browser API, progress.tsx carries no 'use client' directive — it renders as a Server Component. In the App Router that means you can compute the value on the server and pass it straight in: read a job's percentage from a database or an API inside an async page, then render <Progress value={pct} /> inline with the rest of your server-rendered markup. Nothing about the bar ships to the client except the HTML and the inline width style, so the browser paints the filled track on first load with zero JavaScript. Pass a max other than the default 100 when your value is a raw count — value={done} max={total} — and the component clamps the ratio for you. Reach for a client wrapper only when the number needs to change after paint.
1 // app/status/page.tsx — a Server Component, no 'use client' 2 import { Progress } from '@/components/ui/progress' 3 4 export default async function StatusPage() { 5 const { done, total } = await getUploadProgress() 6 return <Progress value={done} max={total} /> 7 }
Drive a live value from a client component
When the value changes after the page paints — a real upload, a timed sequence, a form the user is filling in — the number lives in client state, so the parent that owns it becomes the client component while Progress itself stays untouched. Mark that parent with 'use client', hold the value in useState, and pass it down as the value prop; each state change re-renders the bar and the fill eases to its new width. The Progress file needs no directive of its own because the client boundary belongs to whoever owns the state, not to the presentational bar. This split keeps the one component reusable in both server and client trees without a second version. Pair the control with a Drivn Button to step the value, or drive it from an upload's progress event.
1 'use client' 2 import * as React from 'react' 3 import { Progress } from '@/components/ui/progress' 4 5 export function UploadBar() { 6 const [value, setValue] = React.useState(0) 7 return ( 8 <div> 9 <Progress value={value} /> 10 <button onClick={() => setValue((v) => Math.min(100, v + 10))}> 11 Advance 12 </button> 13 </div> 14 ) 15 }
How the fill and transition work
The bar is two nested <div>s. The outer track is styles.track — w-full h-2 bg-accent rounded-full overflow-hidden — a full-width two-pixel rail with clipped corners so the fill never spills past the radius. The inner fill is styles.bar — h-full bg-primary rounded-full plus transition-all duration-300 ease-out — and its width is the only thing that changes. The component derives that width by clamping the ratio with Math.min(100, Math.max(0, (value / max) * 100)) and writing that percentage to the fill's inline width style. Because the width sits behind a 300ms transition-all, every value change eases to its new position instead of jumping, whether the new number arrives from server props or client state. The overflow-hidden on the track is what keeps the rounded fill tucked inside the rail at every width.
1 // verbatim from progress.tsx 2 const 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 } 9 10 const pct = Math.min(100, Math.max(0, (value / max) * 100))
Restyle the two-line styles object
Every class the bar renders lives in one styles object at the top of the file you own, and it is only two entries. Make the rail thicker by bumping h-2 to h-3 or h-4 on styles.track; square the ends by dropping rounded-full; or tint the fill by swapping bg-primary on styles.bar for bg-success or bg-destructive to signal state. Because both surfaces read Tailwind tokens — bg-accent for the track and bg-primary for the fill — changing those tokens in theming re-themes every progress bar for dark and light mode at once. The aria-valuenow, aria-valuemin, and aria-valuemax attributes are set from value and max on the root, so the control stays accessible no matter how you restyle it. For copy-paste layouts and stateful patterns see the Progress examples.
1 // tint the fill for a success state (edit styles.bar) 2 bar: cn( 3 'h-full bg-success rounded-full', 4 'transition-all duration-300 ease-out' 5 ),
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. Progress holds no state and calls no browser API, so its source carries no 'use client' directive and renders as a Server Component. You can compute the value on the server — from a database, an API, or route props — and render <Progress value={pct} /> inline with the rest of your server markup. Only the HTML and the inline width style ship to the client, so the filled track paints on first load with zero JavaScript.
You do not wire any animation yourself — the fill carries a transition-all duration-300 ease-out class, so whenever the value prop changes the width eases to its new position over 300 milliseconds. Change the value from client state or from new server props and the transition runs automatically. To speed it up or slow it down, edit the duration-300 token on styles.bar in the component file after install.
Pass both value and max. The component computes the fill from the ratio with Math.min(100, Math.max(0, (value / max) * 100)), so value={3} max={5} fills the bar to sixty percent. The max prop defaults to 100, which is why a bare value={65} reads as a percentage. Passing your own max lets the bar track any count — files uploaded, steps completed, items processed — without you doing the division.
Open progress.tsx after install and edit the two-entry styles object. Bump h-2 on styles.track to h-3 or h-4 for a taller rail, and swap bg-primary on styles.bar for bg-success or bg-destructive to signal state. Both surfaces read Tailwind tokens — bg-accent for the track, bg-primary for the fill — so changing those tokens in theming re-themes every progress bar for dark and light mode at once.
Yes. The component has no dependency on Next.js or any router — it is a plain function that maps value and max to an inline width, so it works anywhere React and Tailwind reach the DOM. In a Vite + React app everything already runs on the client, so there is no server boundary to think about. Run npx drivn add progress and render it the same way, driving the value from useState or your own store.

