Skip to content
Drivn
6 min read

Next.js Switch with the App Router

Add a toggle switch to a Next.js App Router app. Drivn ships a copy-paste, controlled Switch built on a role=switch button with aria-checked — zero deps.

A switch is the on/off toggle you reach for when a setting takes effect immediately — dark mode, email notifications, a feature flag in a preferences panel. The detail worth knowing up front in a Next.js App Router project is that Drivn's Switch is controlled-only: it holds no internal state, renders exactly the checked boolean you pass, and reports the next value through onChange. Because it renders a <button> with an onClick handler, switch.tsx opens with a 'use client' directive and must live inside a client boundary rather than straight in a Server Component.

After install the component sits in src/components/ui/switch.tsx as a single function — no sub-components, no context. Under the hood it is a <button type="button"> with role="switch" and aria-checked tracking the current value, so it reads as a real switch to assistive technology out of the box. The track swaps from bg-border to bg-primary when checked, and a thumb <span> slides across with a CSS transition-transform — no animation library and no runtime dependency past React and the local cn utility.

This guide installs Drivn in a Next.js 16 project, renders the Switch inside a Client Component, drives it with state, wires it to a label for a settings row, and restyles the track from the file you own. For the full reference see the Switch docs; for the shadcn/ui comparison see Drivn vs shadcn/ui Switch.

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 switch. The Switch imports nothing past React and the local cn utility, so the CLI writes a single switch.tsx file and touches nothing else in your tree. The CLI reference documents every flag, including targeting a custom directory or installing several components in one command. After install you own the file, and future Drivn releases will not overwrite it, so commit it for a clean baseline before wiring it into a page. There is no peer dependency to add and nothing to configure past having Tailwind v4 already processing your styles — the component is pure React plus Tailwind classes, and there is not even an icon dependency because the thumb is a plain <span> that slides with a transform.

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

Render the switch in a Client Component

Because the Switch renders a <button> with an onClick handler, switch.tsx opens with a 'use client' directive, and 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 one imported into a Server Component page. The Switch is controlled-only: it keeps no internal state, so you always hold the checked boolean yourself and hand it back through onChange. The smallest working shape is a client wrapper that owns the value in useState and passes it down. The example below is that wrapper — a 'use client' file that toggles a single boolean. Import it into any Server Component page and the interactive island stays scoped to the control while the rest of the route renders on the server. Note that onChange hands you the next checked value directly — a plain boolean, not a DOM event — so wiring it to a useState setter is a one-liner.

1'use client'
2
3import { useState } from 'react'
4import { Switch } from '@/components/ui/switch'
5
6export function DarkModeToggle() {
7 const [enabled, setEnabled] = useState(false)
8
9 return (
10 <Switch
11 checked={enabled}
12 onChange={setEnabled}
13 />
14 )
15}

Drive the checked state

Since the Switch has no internal state, the checked boolean always lives in your component — which makes it easy to do something the moment it flips. Pass a setter to onChange and you can persist the value, fire a side effect, or sync a second control from the same boolean. The onChange callback receives the next value, so onChange={(next) => ...} gives you the new state without reading it back off the DOM. The example toggles a notifications preference and mirrors the current state in a caption next to the control. Because the value is yours, another element — a form, a reset button, a server-loaded default — can read and set the same boolean. For a switch that should reflect a value fetched on the server, seed useState from a prop the Server Component passes into the client wrapper. For more copy-paste patterns see the Switch examples.

1'use client'
2
3import { useState } from 'react'
4import { Switch } from '@/components/ui/switch'
5
6export function NotificationsToggle() {
7 const [on, setOn] = useState(true)
8
9 return (
10 <div className="flex items-center gap-3">
11 <Switch checked={on} onChange={setOn} />
12 <span className="text-sm text-muted-foreground">
13 Email notifications {on ? 'on' : 'off'}
14 </span>
15 </div>
16 )
17}

Pair the switch with a label for a settings row

A switch rarely stands alone — it sits in a settings row with a label describing what it toggles. Pair it with the Drivn Label and lay the two out in a flex row: the label and a short description on the left, the Switch pushed to the trailing edge with justify-between. Because the Switch is a real <button> with role="switch" and aria-checked, screen readers announce it as a switch and read its on/off state without any extra ARIA on your part. Wrap the row in a Card to group several toggles into a preferences panel, each with its own boolean in state. The example renders a labelled row with a title, a helper line, and the Switch aligned to the right — the canonical shape for a settings screen.

1'use client'
2
3import { useState } from 'react'
4import { Switch } from '@/components/ui/switch'
5import { Label } from '@/components/ui/label'
6
7export function MarketingRow() {
8 const [on, setOn] = useState(false)
9
10 return (
11 <div className="flex items-center justify-between gap-4">
12 <div className="space-y-0.5">
13 <Label>Marketing emails</Label>
14 <p className="text-sm text-muted-foreground">
15 Product news and occasional offers.
16 </p>
17 </div>
18 <Switch checked={on} onChange={setOn} />
19 </div>
20 )
21}

Restyle the track and thumb

Everything visible on the Switch is a Tailwind class in the styles object inside the file you own, so recoloring is a one-line edit. The track is a w-12 h-[26px] rounded-full button that switches between bg-border when off and bg-primary when on; the thumb is an absolutely positioned w-5 h-5 <span> with bg-primary-foreground that slides via translate-x-[3px] off and translate-x-[25px] on. Change the checked background to bg-success for a confirm-style toggle, or widen the track and bump the thumb translate to match — the two numbers move together. Because the classes read your theme tokens, the control matches dark and light mode with no override; see Theming for the token list. A disabled prop passes straight through to the underlying <button> via the spread props, so a disabled switch stops firing onChange the way any native button would.

1// packages/drivn/src/registry/components/switch.ts — verbatim
2const styles = {
3 base: cn(
4 'relative w-12 h-[26px] rounded-full outline-none',
5 'transition-colors duration-200 overflow-hidden',
6 'focus-visible:ring-[3px] focus-visible:ring-ring/50'
7 ),
8 thumb: cn(
9 'absolute left-0 top-[3px] w-5 h-5',
10 'bg-primary-foreground rounded-full shadow-md',
11 'transition-transform duration-200'
12 ),
13}
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 Switch renders a <button> with an onClick handler, so its source opens with a 'use client' directive and must render inside a Client Component. In the App Router, put the Switch — and the useState that holds its checked boolean — 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.

No. The Drivn Switch is controlled-only — it has no internal state and renders exactly the checked boolean you pass. You hold the value yourself, usually in useState, and update it from the onChange callback, which hands you the next boolean directly. There is no defaultValue and no uncontrolled mode, so the value always lives in your component and any other element can read or set the same boolean.

Yes. Under the hood it is a <button type="button"> with role="switch" and aria-checked bound to the current value, so assistive technology announces it as a switch and reads its on/off state without any extra ARIA from you. It is keyboard focusable and toggles on Enter or Space like any button, and the focus-visible ring is built into the base class. Add an associated Label for the visible caption.

Pass a disabled prop. The Switch spreads extra props onto the underlying <button>, so disabled reaches the native element and stops it firing onClick — which means onChange never runs while disabled. The button also gains the browser default disabled styling; add disabled:opacity-50 to the base class in the file you own if you want a dimmed look that matches the rest of the Drivn UI.

Yes. The component depends only on React and the local cn utility — 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 switch and drive it the same way, owning the checked boolean in useState.