The frame of a web app: a dark sidebar with the product name, two groups of links with icons and counts, and the signed-in user at the bottom; a white top bar with search, notifications and an account button; and a content area that scrolls on its own. The example content is a projects page with three totals and a list. Clicking a sidebar link moves the highlight and changes the page heading. Below the `lg` breakpoint the sidebar slides away and a menu button in the top bar opens it over a dimmed backdrop.
import { useState } from "react";
const nav = [
{
group: "Workspace",
items: [
{ name: "Overview", icon: "M3.5 3.5h5v5h-5zM11.5 3.5h5v5h-5zM3.5 11.5h5v5h-5zM11.5 11.5h5v5h-5z" },
{ name: "Projects", count: 12, icon: "M2.5 5.5a1 1 0 0 1 1-1h4l1.5 2h7.5a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1z" },
{ name: "Calendar", icon: "M3.5 5h13v11.5h-13zM3.5 8.5h13M7 3v3M13 3v3" },
{ name: "Invoices", count: 3, icon: "M5 2.5h7l3 3v12H5zM8 9.5h4M8 12.5h4" },
],
},
{
group: "Team",
items: [
{ name: "People", icon: "M7.5 9a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM2.5 16c0-2.5 2.2-4 5-4s5 1.5 5 4M13.5 9a2 2 0 1 0 0-4M14.5 12.2c1.8.4 3 1.6 3 3.8" },
{ name: "Reports", icon: "M4 16V9M10 16V4M16 16v-5" },
{ name: "Settings", icon: "M3 6h8M15 6h2M3 14h2M9 14h8M13 4v4M7 12v4" },
],
},
];
const summary = [
{ label: "Active", value: "12" },
{ label: "Due soon", value: "4" },
{ label: "Hours", value: "186" },
];
const projects = [
{ name: "Riverside Clinic rebrand", client: "Riverside Health", owner: "MO", ownerColor: "bg-amber-200 text-amber-900", progress: "w-4/5", due: "14 Oct", status: "On track" },
{ name: "Spring catalogue", client: "Alder and Pine", owner: "TK", ownerColor: "bg-sky-200 text-sky-900", progress: "w-1/2", due: "21 Oct", status: "On track" },
{ name: "Booking app redesign", client: "Tidal Yoga", owner: "JR", ownerColor: "bg-emerald-200 text-emerald-900", progress: "w-1/3", due: "17 Oct", status: "At risk" },
{ name: "Annual report 2026", client: "Northfold Trust", owner: "SA", ownerColor: "bg-rose-200 text-rose-900", progress: "w-11/12", due: "11 Oct", status: "In review" },
{ name: "Packaging refresh", client: "Marlow Coffee", owner: "MO", ownerColor: "bg-amber-200 text-amber-900", progress: "w-1/5", due: "4 Nov", status: "On track" },
];
const statusStyle: Record<string, string> = {
"On track": "bg-emerald-50 text-emerald-700",
"At risk": "bg-amber-50 text-amber-800",
"In review": "bg-indigo-50 text-indigo-700",
};
export default function AppShellWithSidebarAndTopBar() {
const [page, setPage] = useState("Projects");
const [menuOpen, setMenuOpen] = useState(false);
const focus = "focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500";
return (
<div className="relative flex h-[680px] overflow-hidden bg-slate-50 text-slate-900">
{menuOpen && (
<button type="button" aria-label="Close menu" onClick={() => setMenuOpen(false)} className="absolute inset-0 z-10 bg-slate-900/50 lg:hidden" />
)}
<aside
id="shell-sidebar"
className={`absolute inset-y-0 left-0 z-20 flex w-64 flex-none flex-col bg-slate-950 text-slate-300 transition-transform duration-200 lg:static lg:visible lg:translate-x-0 ${menuOpen ? "translate-x-0" : "invisible -translate-x-full"}`}
>
<div className="flex h-16 flex-none items-center gap-2.5 px-5">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500 text-sm font-bold text-white">P</span>
<span className="text-sm font-semibold text-white">Plotline</span>
<span className="ml-auto rounded bg-slate-800 px-1.5 py-0.5 text-xs font-medium text-slate-300">Studio</span>
</div>
<nav aria-label="Main" className="flex-1 overflow-y-auto px-3 pb-4">
{nav.map((g) => (
<div key={g.group} className="mt-5 first:mt-2">
<p className="px-3 text-xs font-medium uppercase tracking-wider text-slate-400">{g.group}</p>
<ul className="mt-2 space-y-0.5">
{g.items.map((item) => {
const current = page === item.name;
return (
<li key={item.name}>
<a
href="#"
aria-current={current ? "page" : undefined}
onClick={(e) => {
e.preventDefault();
setPage(item.name);
setMenuOpen(false);
}}
className={`flex h-10 items-center gap-3 rounded-lg px-3 text-sm font-medium transition ${focus} ${current ? "bg-slate-800 text-white" : "text-slate-300 hover:bg-slate-900 hover:text-white"}`}
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={`h-5 w-5 flex-none ${current ? "text-indigo-400" : "text-slate-400"}`}>
<path d={item.icon} />
</svg>
{item.name}
{item.count && <span className="ml-auto rounded-full bg-slate-800 px-2 py-0.5 text-xs tabular-nums text-slate-300">{item.count}</span>}
</a>
</li>
);
})}
</ul>
</div>
))}
</nav>
<div className="flex flex-none items-center gap-3 border-t border-slate-800 p-4">
<span className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-amber-200 text-xs font-semibold text-amber-900">MO</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">Maya Okafor</p>
<p className="truncate text-xs text-slate-400">Harbour Studio</p>
</div>
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-16 flex-none items-center gap-3 border-b border-slate-200 bg-white px-4 sm:px-6">
<button
type="button"
aria-label="Open menu"
aria-expanded={menuOpen}
aria-controls="shell-sidebar"
onClick={() => setMenuOpen(true)}
className={`-ml-1 flex h-10 w-10 flex-none items-center justify-center rounded-lg text-slate-700 hover:bg-slate-100 lg:hidden ${focus}`}
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" aria-hidden="true" className="h-5 w-5"><path d="M3 5.5h14M3 10h14M3 14.5h14" /></svg>
</button>
<div className="relative min-w-0 flex-1 sm:max-w-sm">
<label htmlFor="shell-search" className="sr-only">Search projects</label>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" aria-hidden="true" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500"><circle cx="9" cy="9" r="5.5" /><path d="m13.5 13.5 3.5 3.5" /></svg>
<input id="shell-search" type="search" placeholder="Search" className="h-10 w-full rounded-lg border border-slate-200 bg-slate-50 pl-9 pr-3 text-sm placeholder:text-slate-500 focus:border-indigo-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500/20" />
</div>
<div className="ml-auto flex flex-none items-center gap-1">
<button type="button" aria-label="Notifications, 2 unread" className={`relative flex h-10 w-10 items-center justify-center rounded-lg text-slate-600 hover:bg-slate-100 ${focus}`}>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5"><path d="M5 8a5 5 0 0 1 10 0c0 4 1.5 5 1.5 5h-13S5 12 5 8ZM8.5 16a1.6 1.6 0 0 0 3 0" /></svg>
<span className="absolute right-2.5 top-2.5 h-2 w-2 rounded-full bg-rose-500 ring-2 ring-white" />
</button>
<button type="button" aria-label="Account menu" className={`flex h-10 w-10 items-center justify-center rounded-lg hover:bg-slate-100 ${focus}`}>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-200 text-xs font-semibold text-amber-900">MO</span>
</button>
</div>
</header>
<main className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-xs font-medium text-slate-500">Harbour Studio</p>
<h2 className="truncate text-2xl font-semibold tracking-tight">{page}</h2>
</div>
{page === "Projects" && (
<button type="button" className={`flex h-10 flex-none items-center gap-1.5 rounded-lg bg-indigo-600 px-4 text-sm font-semibold text-white hover:bg-indigo-500 focus-visible:ring-offset-2 ${focus}`}>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true" className="h-4 w-4"><path d="M10 4.5v11M4.5 10h11" /></svg>
New project
</button>
)}
</div>
{page === "Projects" ? (
<>
<dl className="mt-6 grid grid-cols-3 gap-3 sm:gap-4">
{summary.map((s) => (
<div key={s.label} className="rounded-xl border border-slate-200 bg-white p-3 sm:p-4">
<dt className="text-xs font-medium text-slate-600 sm:text-sm">{s.label}</dt>
<dd className="mt-1 text-2xl font-semibold tabular-nums tracking-tight">{s.value}</dd>
</div>
))}
</dl>
<ul className="mt-4 divide-y divide-slate-100 rounded-xl border border-slate-200 bg-white sm:mt-6">
{projects.map((p) => (
<li key={p.name} className="flex items-center gap-4 px-4 py-3.5 sm:px-5">
<span className={`flex h-8 w-8 flex-none items-center justify-center rounded-full text-xs font-semibold ${p.ownerColor}`}>{p.owner}</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{p.name}</p>
<p className="truncate text-xs text-slate-500">{p.client}</p>
</div>
<div aria-hidden="true" className="hidden h-1.5 w-28 flex-none overflow-hidden rounded-full bg-slate-100 md:block">
<div className={`h-full rounded-full bg-indigo-500 ${p.progress}`} />
</div>
<span className="hidden w-14 flex-none text-right text-xs tabular-nums text-slate-600 sm:block">{p.due}</span>
<span className={`w-20 flex-none rounded-full py-1 text-center text-xs font-medium ${statusStyle[p.status]}`}>{p.status}</span>
</li>
))}
</ul>
</>
) : (
<div className="mt-6 flex h-72 flex-col items-center justify-center rounded-xl border border-dashed border-slate-300 bg-white px-6 text-center">
<p className="text-sm font-medium">Your {page.toLowerCase()} page goes here</p>
<p className="mt-1 max-w-xs text-sm text-slate-600">The sidebar and top bar stay in place. Only this area changes from page to page.</p>
</div>
)}
</main>
</div>
</div>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/app-shell-with-sidebar-and-top-bar.json
Remove the `page` state and the `onClick` handler, give each link its real `href`, and work out `current` by comparing the link with the current path from your router. Put the routed page where the example content is, inside `<main>`.
The outer element has a fixed height and hides overflow, and `<main>` has `overflow-y-auto`. That keeps the sidebar and top bar in place without `position: sticky` or `fixed`.
Below `lg` the sidebar is absolutely positioned and moved off screen with `-translate-x-full`. The menu button sets `menuOpen`, which slides it in and shows a backdrop button that closes it again. Picking a link also closes it.
Yes. Add a second state, switch `w-64` to `w-16` when it is on, and hide the link text, counts and group labels with `sr-only` so the links keep their names for screen readers.
Next to the page heading in the content area, as shown. Keep the top bar for things that apply everywhere: search, notifications and the account menu.
Related: Stat Tiles with Sparkline Trends, Cart Drawer with Quantity Steppers, Button Set: Primary, Secondary, Ghost, Icon and Loading, Pricing Table with Monthly and Yearly Toggle.
More dashboard or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.