How to Add a Progress Bar to a React App
Step-by-step guide to adding a progress bar to any React project with the Drivn CLI — a copy-paste, server-safe component with zero runtime dependencies.
A progress bar answers one question — how far along is this? — for an upload, a multi-step form, or a page-scroll indicator. Building one by hand is a little arithmetic (clamp a ratio into a percentage) plus a little accessibility (wire the ARIA progressbar role and its value attributes) plus a track and a fill that stay rounded at every width. None of it is hard, but it is fiddly enough that most teams reach for a component and inherit an API surface for what is really one <div> inside another.
Drivn takes the lighter route: the CLI copies a progress.tsx file straight into your repository. The Progress is a single function — no context, no state, no 'use client'. It takes a value and a max, clamps the ratio with Math.min(100, Math.max(0, (value / max) * 100)), and writes that percentage to an inner <div>'s inline width. The outer track is a <div role="progressbar"> carrying aria-valuenow, aria-valuemin, and aria-valuemax, so assistive technology reads the position with no extra wiring. A CSS transition on the fill smooths every width change.
This guide adds the bar to any React app — install the CLI, render it from a fixed value, drive it from client state when you need motion, and restyle the two-line styles object it reads. It works the same in Vite + React or a Next.js project. For the App Router walkthrough see the Next.js Progress guide.
Prerequisites
Before installing Progress, confirm your React project has the three things Drivn assumes: Tailwind CSS v4 installed and processing your styles, TypeScript configured (the component ships as a .tsx file), and a @/ path alias pointing at your source directory. If you scaffolded with create-next-app, npm create vite, or npx drivn@latest create, all three are already wired. For a custom setup, check the compilerOptions.paths entry in tsconfig.json; the installation page lists the minimal config. Progress imports nothing past React and the cn utility for class merging, so it is the smallest possible install — no peer components and no runtime package to version-lock. That also means it drops cleanly into an existing design system without pulling in a positioning or animation library.
Step 1 — Install Drivn via the CLI
Run the CLI from your project root to add the Progress source file. The command prompts once for your install directory (defaulting to src/components/ui/) and writes a single progress.tsx — no other files, because Progress has no peer dependencies beyond React and the cn utility. No global config file is created; the result is TypeScript source you edit like any other component. Confirm it landed in your editor, then commit it for a clean baseline. If your project uses a monorepo layout or a non-standard source path, the CLI docs cover the flags for targeting a custom location during install. Because the file is yours after install, future Drivn releases will not overwrite your edits.
1 # add the Progress source file 2 npx drivn add progress
Step 2 — Render the bar
Import Progress from your UI directory and pass a value between 0 and 100. The component computes the fill percentage and writes it to the inner <div>'s width, so <Progress value={65} /> renders a bar filled to sixty-five percent. Because Progress holds no state and calls no browser API, its source carries no 'use client' directive — you can render it straight inside a Server Component in a Next.js App Router page, or anywhere in a Vite app. Pass a max other than the default 100 when your value is a raw count rather than a percentage: value={3} max={5} fills the bar to sixty percent, and the component does the division for you. The bar is accessible out of the box because the track carries the role="progressbar" attribute plus aria-valuenow, aria-valuemin, and aria-valuemax.
1 import { Progress } from '@/components/ui/progress' 2 3 export function ProfileCompletion() { 4 return <Progress value={72} /> 5 }
Step 3 — Drive a live value from client state
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 component that owns it becomes the client component while Progress itself stays untouched. Mark that parent with 'use client', hold the value in React.useState, and pass it down as the value prop; each state change re-renders the bar and the fill eases to its new width over the built-in 300-millisecond transition. Progress needs no directive of its own because the client boundary belongs to whoever owns the state, not to the presentational bar. In production, replace the interval below with your real progress source — a fetch upload event, an XHR onprogress callback, or a step counter from a multi-step form.
1 'use client' 2 import * as React from 'react' 3 import { Progress } from '@/components/ui/progress' 4 5 export function UploadBar() { 6 const [pct, setPct] = React.useState(0) 7 8 React.useEffect(() => { 9 const id = setInterval(() => { 10 setPct((p) => (p >= 100 ? (clearInterval(id), 100) : p + 4)) 11 }, 200) 12 return () => clearInterval(id) 13 }, []) 14 15 return <Progress value={pct} /> 16 }
Step 4 — Restyle the track and fill
Everything visual lives in one styles object at the top of the file you own, and it is only two entries. The outer track is styles.track — w-full h-2 bg-accent rounded-full overflow-hidden — a full-width two-pixel rail whose overflow-hidden keeps the rounded fill tucked inside it at every width. The inner fill is styles.bar — h-full bg-primary rounded-full plus transition-all duration-300 ease-out. Make the rail thicker by bumping h-2 to h-3 or h-4; square the ends by dropping rounded-full; or tint the fill by swapping bg-primary for bg-success or bg-destructive to signal state. Because both surfaces read Tailwind tokens — bg-accent for the track, bg-primary for the fill — changing them in theming re-themes every bar for dark and light mode at once. For copy-paste layouts and stateful patterns see the Progress examples.
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 }
Install Drivn in one command
Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.
Frequently asked questions
Yes. Set the project up with TypeScript and Tailwind v4 first, then run npx drivn add progress. 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, with no imports past React and the cn utility. In a Vite + React app everything already runs on the client, so there is no server boundary to think about; render the bar and drive its value from useState or your own store exactly as you would in Next.js.
No. Progress holds no state and calls no browser API — the percentage is computed inline during render and written to the inner <div> width via the style prop. Its source ships without a 'use client' directive, so it renders as a Server Component in the Next.js App Router. Only the component that owns a live value needs the directive, because that component runs useState or useEffect — the presentational bar it renders does not.
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 at the call site.
The component clamps the computed percentage with Math.min(100, Math.max(0, (value / max) * 100)) before writing it to the fill width. A value of 150 with the default max of 100 caps the bar at 100 percent — the fill never overflows the track. A negative value caps at 0 percent rather than rendering a negative width. The clamp lives directly in the source you own, so the guard is visible in the component file rather than hidden in a dependency.
You do not wire any animation yourself — the fill carries transition-all duration-300 ease-out, 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, or drop the transition entirely for an instant jump.

