App Shell with Sidebar and Top Bar

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.

Code

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

When to use it

How to customize it

Accessibility

Questions

How do I use this with a router?

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>`.

Why does only the content area scroll?

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`.

How does the mobile menu work?

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.

Can the sidebar collapse to icons only?

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.

Where should page actions like New project go?

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.

More in Dashboard

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.

Product

Who It's For

Features

Resources

Company

View Sitemap