Skip to content
Drivn
6 min read

Next.js Stepper with the App Router

Add a step progress indicator to a Next.js App Router app. Drivn ships a copy-paste, controlled Stepper with labels, icons, and a vertical mode, zero deps.

A stepper is the numbered progress indicator across the top of a multi-step flow — a checkout with cart, shipping, payment, and review, or a signup wizard that walks the user through account, profile, and confirmation. The detail worth knowing up front in a Next.js App Router project is that Drivn's Stepper is interactive: each step indicator is a <button> that fires a callback when clicked, and the parts read the current step through React context. That makes it a Client Component — stepper.tsx opens with a 'use client' directive and renders inside a client boundary, not straight in a Server Component the way a purely presentational primitive would.

After install the component sits in src/components/ui/stepper.tsx as a root function plus a Stepper.Item sub-component. You hand the root a step number and it derives each item's state — completed, active, or upcoming — by comparing that item's zero-based index against step. There is no internal step state: the Stepper is driven entirely by the step prop you pass, with onStepChange reporting the index when a user clicks a step. Labels, icons, a vertical orientation, and a line-less variant are all props on the parts.

This guide installs Drivn in a Next.js 16 project, renders the Stepper inside a Client Component, drives the active step with state, and adds labels, icons, and a vertical layout. For the full reference see the Stepper docs; for the shadcn/ui comparison see Drivn vs shadcn/ui Stepper.

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 stepper. The Stepper imports nothing past React, the Check icon from lucide-react, and the local cn utility, so the CLI writes a single stepper.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. Because the component is a single self-contained file, there is no peer dependency to add and nothing to configure past having Tailwind v4 already processing your styles and lucide-react installed for the completed-step checkmark.

1# from the root of your Next.js 16 project
2npx drivn add stepper

Render the stepper in a Client Component

Because each step indicator is a clickable <button> and the Stepper.Item parts read the active step through React context, stepper.tsx opens with a 'use client' directive. In the App Router that means it must live inside a Client Component — either a file of your own that starts with 'use client', or a Server Component page that imports one. The usual shape is a small client wrapper that owns the step value in useState and renders the Stepper. The example below is that wrapper: a 'use client' file that holds the current step and passes it down. A bare <Stepper.Item /> with no label auto-numbers itself from its position, so four items render as 1, 2, 3, 4. Import this wrapper into any Server Component page and the interactive island stays scoped to the control while the rest of the route renders on the server. The verbatim usage mirrors the Stepper docs example.

1'use client'
2
3import * as React from 'react'
4import { Stepper } from '@/components/ui/stepper'
5
6export function Wizard() {
7 const [step, setStep] = React.useState(1)
8
9 return (
10 <Stepper step={step} onStepChange={setStep}>
11 <Stepper.Item />
12 <Stepper.Item />
13 <Stepper.Item />
14 <Stepper.Item />
15 </Stepper>
16 )
17}

Drive the active step with state

The Stepper has no internal step state — it renders exactly the step number you hand it, so the active step always lives in your component. Comparing each item's zero-based index against step decides its look: an index below step renders completed (a filled indicator with a checkmark), an index equal to step renders active (an outlined indicator), and anything above renders upcoming (a muted indicator). To move forward and back, pair the Stepper with a couple of Button controls that increment and decrement the state, clamped to the number of steps. The onStepChange callback also fires when a user clicks a step indicator directly, so wiring it to your setter lets people jump between steps as well as page through them. The example holds step in state and advances it with Next and Back buttons, disabling each at the ends of the range.

1'use client'
2
3import * as React from 'react'
4import { Stepper } from '@/components/ui/stepper'
5import { Button } from '@/components/ui/button'
6
7export function CheckoutSteps() {
8 const [step, setStep] = React.useState(0)
9 const last = 3
10
11 return (
12 <div className="space-y-6">
13 <Stepper step={step} onStepChange={setStep}>
14 <Stepper.Item label="Cart" />
15 <Stepper.Item label="Shipping" />
16 <Stepper.Item label="Payment" />
17 <Stepper.Item label="Review" />
18 </Stepper>
19 <div className="flex justify-between">
20 <Button
21 variant="outline"
22 disabled={step === 0}
23 onClick={() => setStep((s) => s - 1)}
24 >
25 Back
26 </Button>
27 <Button
28 disabled={step === last}
29 onClick={() => setStep((s) => s + 1)}
30 >
31 Next
32 </Button>
33 </div>
34 </div>
35 )
36}

Label steps and swap in icons

Each Stepper.Item takes a label prop for a short text caption inside the indicator, and an icon prop that accepts a lucide component reference — icon={User}, not a rendered <User /> — which the Stepper renders for you at the right size. When a step is complete the indicator shows a checkmark regardless of its icon; before that it shows the label, then the icon, then the auto-number, in that order of precedence. Mix the two across a flow: a labelled checkout up top, an icon-only stepper in a tight toolbar. The example pairs three icons for an account-setup wizard. Because the icon is passed as a reference, there is no <Icon /> boilerplate at the call site and no size class to remember — the same clean leftIcon-style API Drivn uses across the Button and the rest of the library. For copy-paste variants see the Stepper examples.

1'use client'
2
3import * as React from 'react'
4import { Stepper } from '@/components/ui/stepper'
5import { User, FileText, Send } from 'lucide-react'
6
7export function SetupSteps() {
8 const [step, setStep] = React.useState(1)
9
10 return (
11 <Stepper step={step} onStepChange={setStep}>
12 <Stepper.Item icon={User} />
13 <Stepper.Item icon={FileText} />
14 <Stepper.Item icon={Send} />
15 </Stepper>
16 )
17}

Vertical orientation and restyle the states

Pass orientation="vertical" and the root stacks the items in a column with the connecting line running top to bottom; pass line={false} and the connectors drop entirely, spacing the indicators evenly instead. Everything visible is a Tailwind class in the styles object inside the file you own, so recoloring is a one-line edit. The active indicator is bg-accent text-muted-foreground border-2 border-primary, a completed one is bg-primary text-primary-foreground, and the connecting line switches from bg-primary to bg-border at the boundary between done and not-done — change any of those keys and every Stepper in the project follows. Because those classes read your theme tokens, the control matches dark and light mode with no override. The example renders a four-step vertical stepper for a settings wizard sidebar.

1<Stepper
2 step={2}
3 orientation="vertical"
4 onStepChange={setStep}
5>
6 <Stepper.Item label="Account" />
7 <Stepper.Item label="Profile" />
8 <Stepper.Item label="Review" />
9 <Stepper.Item label="Done" />
10</Stepper>
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. Each step indicator is a clickable button and the Stepper.Item parts read the active step through React context, so the source opens with a 'use client' directive and must render inside a Client Component. In the App Router, put the Stepper — and the useState that holds the current step — in a file that starts with 'use client', then import that file into your Server Component page. The interactive island stays scoped to the control while the rest of the route renders on the server.

You tell it with the step prop. The Stepper has no internal step state — it derives each item's look by comparing that item's zero-based index against the step number you pass. An index below step renders completed, an index equal to step renders active, and anything above renders upcoming. Because state lives in your component, another element — a form, a page counter, a Back/Next pair — can read and set the same value.

Own the step number in useState and change it. Wire Next and Back buttons to increment and decrement it, clamped to the item count, or set it directly to jump to a specific step. The Stepper also calls onStepChange with an item's index when the user clicks that step's indicator, so passing your setter as onStepChange lets people navigate by clicking the numbers as well as paging through with buttons.

Yes. Each Stepper.Item takes a label prop for a text caption and an icon prop that accepts a lucide component reference like icon={User}. The indicator shows a checkmark once the step is complete, otherwise the label, then the icon, then the auto-number, in that order. Pass the icon as a reference, not a rendered element, and the Stepper sizes it for you — no <Icon /> boilerplate at the call site.

Yes. The component depends only on React, the local cn utility, and lucide-react for the completed-step checkmark — no Next.js API and no router — so it works anywhere React and Tailwind reach the DOM. In a Vite + React app there is no Server Component boundary to think about, and the 'use client' directive is simply ignored. Run npx drivn add stepper and drive it the same way, owning the step in useState.