Skip to content
  1. Layouts
  2. Sidebar layout

Sidebar layout

An app shell with a fixed navigation column on desktop and a slide-out drawer on small screens.

'use client'

import { Avatar } from '@/components/avatar'
import { Dropdown, DropdownButton, DropdownDivider, DropdownItem, DropdownLabel, DropdownMenu } from '@/components/dropdown'
import { Navbar, NavbarItem, NavbarSection, NavbarSpacer } from '@/components/navbar'
import { Sidebar, SidebarBody, SidebarHeader, SidebarItem, SidebarLabel, SidebarSection, SidebarSpacer } from '@/components/sidebar'
import { SidebarLayout } from '@/components/sidebar-layout'
import { TextLink } from '@/components/text'
import { ArrowRightStartOnRectangleIcon, ChevronDownIcon, KeyIcon, PlusIcon, UserCircleIcon } from '@heroicons/react/16/solid'
import {
  BanknotesIcon,
  BugAntIcon,
  Cog6ToothIcon,
  CreditCardIcon,
  DocumentTextIcon,
  HomeIcon,
  QuestionMarkCircleIcon,
  UsersIcon,
} from '@heroicons/react/20/solid'
import clsx from 'clsx'

const sections = [
  { label: 'Overview', href: '/', icon: HomeIcon },
  { label: 'Payments', href: '/payments', icon: CreditCardIcon },
  { label: 'Invoices', href: '/invoices', icon: DocumentTextIcon },
  { label: 'Customers', href: '/customers', icon: UsersIcon },
  { label: 'Payouts', href: '/payouts', icon: BanknotesIcon },
  { label: 'Settings', href: '/settings', icon: Cog6ToothIcon },
]

const workspaces = [
  { name: 'Northwind', href: '/workspaces/northwind', color: 'bg-blue-500' },
  { name: 'Kivu Freight', href: '/workspaces/kivu-freight', color: 'bg-amber-500' },
]

function Dot({ color }: { color: string }) {
  return <span aria-hidden="true" className={clsx('size-2.5 shrink-0 rounded-[3px]', color)} />
}

export function AppShell({ current, children }: { current: string; children: React.ReactNode }) {
  let workspace = workspaces[0]

  return (
    <SidebarLayout
      navbar={
        <Navbar>
          <NavbarSpacer />
          <NavbarSection>
            <Dropdown>
              <DropdownButton as={NavbarItem} aria-label="Account">
                <Avatar src="/users/avatar.jpg" square />
              </DropdownButton>
              <DropdownMenu className="min-w-64" anchor="bottom end">
                <DropdownItem href="/account">
                  <UserCircleIcon />
                  <DropdownLabel>Profile</DropdownLabel>
                </DropdownItem>
                <DropdownItem href="/account/security">
                  <KeyIcon />
                  <DropdownLabel>Security</DropdownLabel>
                </DropdownItem>
                <DropdownDivider />
                <DropdownItem href="/logout">
                  <ArrowRightStartOnRectangleIcon />
                  <DropdownLabel>Log out</DropdownLabel>
                </DropdownItem>
              </DropdownMenu>
            </Dropdown>
          </NavbarSection>
        </Navbar>
      }
      sidebar={
        <Sidebar>
          <SidebarHeader className="pb-0">
            <img src="/logo.svg" alt="Northwind" className="mx-2 h-6 w-auto self-start" />
            <Dropdown>
              <DropdownButton as={SidebarItem} className="mt-4">
                <Dot color={workspace.color} />
                <SidebarLabel>{workspace.name}</SidebarLabel>
                <ChevronDownIcon />
              </DropdownButton>
              <DropdownMenu className="min-w-64" anchor="bottom start">
                {workspaces.map((option) => (
                  <DropdownItem key={option.name} href={option.href}>
                    <Dot color={option.color} />
                    <DropdownLabel>{option.name}</DropdownLabel>
                  </DropdownItem>
                ))}
                <DropdownDivider />
                <DropdownItem href="/workspaces/new">
                  <PlusIcon />
                  <DropdownLabel>Create workspace</DropdownLabel>
                </DropdownItem>
              </DropdownMenu>
            </Dropdown>
          </SidebarHeader>
          <SidebarBody>
            <SidebarSection>
              {sections.map((item) => (
                <SidebarItem key={item.href} href={item.href} current={item.label === current}>
                  <item.icon />
                  <SidebarLabel>{item.label}</SidebarLabel>
                </SidebarItem>
              ))}
            </SidebarSection>
            <SidebarSpacer />
            <div className="rounded-xl bg-zinc-950/2.5 p-4 ring-1 ring-zinc-950/5 dark:bg-white/2.5 dark:ring-white/5">
              <p className="text-base/6 font-medium text-zinc-950 sm:text-sm/5 dark:text-white">You are on the Starter plan.</p>
              <p className="mt-1 text-base/6 text-zinc-500 sm:text-sm/5 dark:text-zinc-400">Unlock payouts in more currencies and team roles.</p>
              <TextLink href="/billing" className="mt-3 inline-block text-base/6 font-medium sm:text-sm/5">
                Upgrade
              </TextLink>
            </div>
            <SidebarSection className="mt-4">
              <SidebarItem href="/support/bug">
                <BugAntIcon />
                <SidebarLabel>Report a bug</SidebarLabel>
              </SidebarItem>
              <SidebarItem href="/help">
                <QuestionMarkCircleIcon />
                <SidebarLabel>Help</SidebarLabel>
              </SidebarItem>
            </SidebarSection>
          </SidebarBody>
        </Sidebar>
      }
    >
      {children}
    </SidebarLayout>
  )
}
<script setup lang="ts">
import Avatar from '@/components/avatar.vue'
import Dropdown from '@/components/dropdown.vue'
import DropdownButton from '@/components/dropdown-button.vue'
import DropdownDivider from '@/components/dropdown-divider.vue'
import DropdownItem from '@/components/dropdown-item.vue'
import DropdownLabel from '@/components/dropdown-label.vue'
import DropdownMenu from '@/components/dropdown-menu.vue'
import Navbar from '@/components/navbar.vue'
import NavbarItem from '@/components/navbar-item.vue'
import NavbarSection from '@/components/navbar-section.vue'
import NavbarSpacer from '@/components/navbar-spacer.vue'
import Sidebar from '@/components/sidebar.vue'
import SidebarBody from '@/components/sidebar-body.vue'
import SidebarHeader from '@/components/sidebar-header.vue'
import SidebarItem from '@/components/sidebar-item.vue'
import SidebarLabel from '@/components/sidebar-label.vue'
import SidebarLayout from '@/components/sidebar-layout.vue'
import SidebarSection from '@/components/sidebar-section.vue'
import SidebarSpacer from '@/components/sidebar-spacer.vue'
import TextLink from '@/components/text-link.vue'
import { ArrowRightStartOnRectangleIcon, ChevronDownIcon, KeyIcon, PlusIcon, UserCircleIcon } from '@heroicons/vue/16/solid'
import {
  BanknotesIcon,
  BugAntIcon,
  Cog6ToothIcon,
  CreditCardIcon,
  DocumentTextIcon,
  HomeIcon,
  QuestionMarkCircleIcon,
  UsersIcon,
} from '@heroicons/vue/20/solid'

defineProps<{ current: string }>()

const sections = [
  { label: 'Overview', href: '/', icon: HomeIcon },
  { label: 'Payments', href: '/payments', icon: CreditCardIcon },
  { label: 'Invoices', href: '/invoices', icon: DocumentTextIcon },
  { label: 'Customers', href: '/customers', icon: UsersIcon },
  { label: 'Payouts', href: '/payouts', icon: BanknotesIcon },
  { label: 'Settings', href: '/settings', icon: Cog6ToothIcon },
]

const workspaces = [
  { name: 'Northwind', href: '/workspaces/northwind', color: 'bg-blue-500' },
  { name: 'Kivu Freight', href: '/workspaces/kivu-freight', color: 'bg-amber-500' },
]

const workspace = workspaces[0]

const account = [
  { label: 'Profile', href: '/account', icon: UserCircleIcon },
  { label: 'Security', href: '/account/security', icon: KeyIcon },
]

const dot = 'size-2.5 shrink-0 rounded-[3px]'
</script>

<template>
  <SidebarLayout>
    <template #navbar>
      <Navbar>
        <NavbarSpacer />
        <NavbarSection>
          <Dropdown>
            <DropdownButton :as="NavbarItem" aria-label="Account">
              <Avatar src="/users/avatar.jpg" square />
            </DropdownButton>
            <DropdownMenu class="min-w-64" anchor="bottom end">
              <DropdownItem v-for="item in account" :key="item.label" :href="item.href">
                <component :is="item.icon" />
                <DropdownLabel>{{ item.label }}</DropdownLabel>
              </DropdownItem>
              <DropdownDivider />
              <DropdownItem href="/logout">
                <ArrowRightStartOnRectangleIcon />
                <DropdownLabel>Log out</DropdownLabel>
              </DropdownItem>
            </DropdownMenu>
          </Dropdown>
        </NavbarSection>
      </Navbar>
    </template>

    <template #sidebar>
      <Sidebar>
        <SidebarHeader class="pb-0">
          <img src="/logo.svg" alt="Northwind" class="mx-2 h-6 w-auto self-start" />
          <Dropdown>
            <DropdownButton :as="SidebarItem" class="mt-4">
              <span aria-hidden="true" :class="[dot, workspace.color]" />
              <SidebarLabel>{{ workspace.name }}</SidebarLabel>
              <ChevronDownIcon />
            </DropdownButton>
            <DropdownMenu class="min-w-64" anchor="bottom start">
              <DropdownItem v-for="option in workspaces" :key="option.name" :href="option.href">
                <span aria-hidden="true" :class="[dot, option.color]" />
                <DropdownLabel>{{ option.name }}</DropdownLabel>
              </DropdownItem>
              <DropdownDivider />
              <DropdownItem href="/workspaces/new">
                <PlusIcon />
                <DropdownLabel>Create workspace</DropdownLabel>
              </DropdownItem>
            </DropdownMenu>
          </Dropdown>
        </SidebarHeader>
        <SidebarBody>
          <SidebarSection>
            <SidebarItem v-for="item in sections" :key="item.href" :href="item.href" :current="item.label === current">
              <component :is="item.icon" />
              <SidebarLabel>{{ item.label }}</SidebarLabel>
            </SidebarItem>
          </SidebarSection>
          <SidebarSpacer />
          <div class="rounded-xl bg-zinc-950/2.5 p-4 ring-1 ring-zinc-950/5 dark:bg-white/2.5 dark:ring-white/5">
            <p class="text-base/6 font-medium text-zinc-950 sm:text-sm/5 dark:text-white">You are on the Starter plan.</p>
            <p class="mt-1 text-base/6 text-zinc-500 sm:text-sm/5 dark:text-zinc-400">Unlock payouts in more currencies and team roles.</p>
            <TextLink href="/billing" class="mt-3 inline-block text-base/6 font-medium sm:text-sm/5">Upgrade</TextLink>
          </div>
          <SidebarSection class="mt-4">
            <SidebarItem href="/support/bug">
              <BugAntIcon />
              <SidebarLabel>Report a bug</SidebarLabel>
            </SidebarItem>
            <SidebarItem href="/help">
              <QuestionMarkCircleIcon />
              <SidebarLabel>Help</SidebarLabel>
            </SidebarItem>
          </SidebarSection>
        </SidebarBody>
      </Sidebar>
    </template>

    <slot />
  </SidebarLayout>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

'use client'

import clsx from 'clsx'
import React, { useState } from 'react'
import { MobileSidebar } from './mobile-sidebar'
import { NavbarItem } from './navbar'
import { Bars2Icon } from '@heroicons/react/20/solid'

const background = 'bg-white dark:bg-zinc-900 lg:bg-zinc-100 dark:lg:bg-zinc-950'

const skipLink = clsx(
  'sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:px-3 focus:py-2',
  'focus:bg-white focus:text-sm/6 focus:font-medium focus:text-zinc-950 focus:shadow-xs focus:ring-1 focus:ring-zinc-950/10',
  'dark:focus:bg-zinc-900 dark:focus:text-white dark:focus:ring-white/10'
)

const content = clsx(
  'grow p-6 lg:p-10',
  'lg:rounded-2xl lg:bg-white dark:lg:bg-zinc-900 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:ring-white/10'
)

type SidebarLayoutProps = React.PropsWithChildren<{ navbar: React.ReactNode; sidebar: React.ReactNode }>

export function SidebarLayout({ navbar, sidebar, children }: SidebarLayoutProps) {
  let [open, setOpen] = useState(false)

  return (
    <div className={clsx('relative isolate flex max-lg:flex-col w-full min-h-svh', background)}>
      <a href="#main-content" className={skipLink}>
        Skip to content
      </a>

      <div className="max-lg:hidden fixed left-0 inset-y-0 w-64">{sidebar}</div>

      <MobileSidebar open={open} close={() => setOpen(false)}>
        {sidebar}
      </MobileSidebar>

      <header className="flex items-center pl-[max(--spacing(4),env(safe-area-inset-left))] pr-[max(--spacing(4),env(safe-area-inset-right))] lg:hidden">
        <div className="py-2.5">
          <NavbarItem onClick={() => setOpen(true)} aria-label="Open navigation" aria-expanded={open}>
            <Bars2Icon />
          </NavbarItem>
        </div>
        <div className="min-w-0 flex-1">{navbar}</div>
      </header>

      <main id="main-content" tabIndex={-1} className="outline-hidden flex flex-col flex-1 lg:min-w-0 pb-2 lg:pt-2 lg:pr-2 lg:pl-64">
        <div className={content}>
          <div className="mx-auto max-w-6xl">{children}</div>
        </div>
      </main>
    </div>
  )
}

Also copy Navbar.

<script setup lang="ts">
import { Bars2Icon } from '@heroicons/vue/20/solid'
import { ref } from 'vue'
import MobileSidebar from './mobile-sidebar.vue'
import NavbarItem from './navbar-item.vue'

const open = ref(false)

const background = 'bg-white dark:bg-zinc-900 lg:bg-zinc-100 dark:lg:bg-zinc-950'

const skipLink = [
  'sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:px-3 focus:py-2',
  'focus:bg-white focus:text-sm/6 focus:font-medium focus:text-zinc-950 focus:shadow-xs focus:ring-1 focus:ring-zinc-950/10',
  'dark:focus:bg-zinc-900 dark:focus:text-white dark:focus:ring-white/10',
]

const content = [
  'grow p-6 lg:p-10',
  'lg:rounded-2xl lg:bg-white dark:lg:bg-zinc-900 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:ring-white/10',
]
</script>

<template>
  <div
    :class="[
      'relative isolate flex max-lg:flex-col w-full min-h-svh',
      background,
    ]"
  >
    <a href="#main-content" :class="skipLink">Skip to content</a>

    <div class="max-lg:hidden fixed left-0 inset-y-0 w-64">
      <slot name="sidebar" />
    </div>

    <MobileSidebar :open="open" @close="open = false">
      <slot name="sidebar" />
    </MobileSidebar>

    <header class="flex items-center pl-[max(--spacing(4),env(safe-area-inset-left))] pr-[max(--spacing(4),env(safe-area-inset-right))] lg:hidden">
      <div class="py-2.5">
        <NavbarItem aria-label="Open navigation" :aria-expanded="open" @click="open = true">
          <Bars2Icon />
        </NavbarItem>
      </div>
      <div class="min-w-0 flex-1">
        <slot name="navbar" />
      </div>
    </header>

    <main id="main-content" tabindex="-1" class="outline-hidden flex flex-col flex-1 lg:min-w-0 pb-2 lg:pt-2 lg:pr-2 lg:pl-64">
      <div :class="content">
        <div class="mx-auto max-w-6xl">
          <slot />
        </div>
      </div>
    </main>
  </div>
</template>

Also copy Navbar.

Component API

PropDefaultDescription
SidebarLayoutrenders a <div> element
sidebar-Navigation column, pinned on the left from lg up and reused in the drawer below it.
navbar-Top bar rendered next to the menu button below lg.
children-Page content.
PropDefaultDescription
SidebarLayoutrenders a <div> element
#sidebar-Slot for the navigation column, pinned on the left from lg up and reused in the drawer below it.
#navbar-Slot for the top bar rendered next to the menu button below lg.
#default-Slot for the page content.

Usage

Reach for this shell when the app has more destinations than fit in one row, or when you want room for grouped shortcuts such as saved reports. If five or so links cover everything, the stacked layout keeps the full width for content.

Wrap your app routes

Render it once in the layout that every signed-in page shares. The sidebar holds the navigation and the navbar holds what small screens show next to the menu button:

import { Navbar, NavbarSpacer } from '@/components/navbar'
import { Sidebar, SidebarBody, SidebarItem, SidebarSection } from '@/components/sidebar'
import { SidebarLayout } from '@/components/sidebar-layout'

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <SidebarLayout
      navbar={
        <Navbar>
          <NavbarSpacer />
        </Navbar>
      }
      sidebar={
        <Sidebar>
          <SidebarBody>
            <SidebarSection>
              <SidebarItem href="/">Overview</SidebarItem>
              <SidebarItem href="/payments">Payments</SidebarItem>
              <SidebarItem href="/invoices">Invoices</SidebarItem>
            </SidebarSection>
          </SidebarBody>
        </Sidebar>
      }
    >
      {children}
    </SidebarLayout>
  )
}
<script setup lang="ts">
import Navbar from '@/components/navbar.vue'
import NavbarSpacer from '@/components/navbar-spacer.vue'
import Sidebar from '@/components/sidebar.vue'
import SidebarBody from '@/components/sidebar-body.vue'
import SidebarItem from '@/components/sidebar-item.vue'
import SidebarLayout from '@/components/sidebar-layout.vue'
import SidebarSection from '@/components/sidebar-section.vue'
</script>

<template>
  <SidebarLayout>
    <template #navbar>
      <Navbar>
        <NavbarSpacer />
      </Navbar>
    </template>
    <template #sidebar>
      <Sidebar>
        <SidebarBody>
          <SidebarSection>
            <SidebarItem href="/">Overview</SidebarItem>
            <SidebarItem href="/payments">Payments</SidebarItem>
            <SidebarItem href="/invoices">Invoices</SidebarItem>
          </SidebarSection>
        </SidebarBody>
      </Sidebar>
    </template>

    <slot />
  </SidebarLayout>
</template>

Structure

Split the column into regions

SidebarHeader and SidebarFooter stay put while the body scrolls. SidebarBody scrolls on its own when the list runs long, and SidebarSpacer pushes the sections after it to the bottom. The Sidebar page covers each part.

<Sidebar>
  <SidebarHeader>{/* workspace switcher */}</SidebarHeader>
  <SidebarBody>
    <SidebarSection>{/* Overview, Payments, Invoices... */}</SidebarSection>
    <SidebarSection>
      <SidebarHeading>Pinned reports</SidebarHeading>
      {/* ... */}
    </SidebarSection>
    <SidebarSpacer />
    <SidebarSection>{/* Help center, API status */}</SidebarSection>
  </SidebarBody>
  <SidebarFooter>{/* account menu */}</SidebarFooter>
</Sidebar>
<Sidebar>
  <SidebarHeader><!-- workspace switcher --></SidebarHeader>
  <SidebarBody>
    <SidebarSection><!-- Overview, Payments, Invoices... --></SidebarSection>
    <SidebarSection>
      <SidebarHeading>Pinned reports</SidebarHeading>
      <!-- ... -->
    </SidebarSection>
    <SidebarSpacer />
    <SidebarSection><!-- Help center, API status --></SidebarSection>
  </SidebarBody>
  <SidebarFooter><!-- account menu --></SidebarFooter>
</Sidebar>

Keep the top bar short

The navbar only appears below lg, squeezed next to the menu button, so give it the one or two controls people need without opening the drawer. An account menu is usually enough. Build it with the Navbar parts.

<Navbar>
  <NavbarSpacer />
  <NavbarSection>
    <Dropdown>
      <DropdownButton as={NavbarItem} aria-label="Account">
        <Avatar src="/users/avatar.jpg" square />
      </DropdownButton>
      <AccountMenu anchor="bottom end" />
    </Dropdown>
  </NavbarSection>
</Navbar>
<Navbar>
  <NavbarSpacer />
  <NavbarSection>
    <Dropdown>
      <DropdownButton :as="NavbarItem" aria-label="Account">
        <Avatar src="/users/avatar.jpg" square />
      </DropdownButton>
      <DropdownMenu class="min-w-64" anchor="bottom end">
        <!-- Profile, Security, Log out -->
      </DropdownMenu>
    </Dropdown>
  </NavbarSection>
</Navbar>

Responsive behavior

From lg up, the sidebar is pinned to the left at 16rem wide and the page sits on a raised panel beside it. Below lg, the column is hidden and a header with a menu button takes its place. The button opens a drawer that renders the same sidebar content, and following a link inside it closes the drawer.

The drawer also closes on its own when the window grows past lg, so turning a tablet to landscape never leaves the page locked behind a hidden drawer. A Skip to content link comes first in the tab order and jumps over the navigation to the main element.

Leave extras out of the drawer

Since the drawer reuses the sidebar as is, hide what is redundant on a phone with max-lg:hidden. Here the pinned reports go away, and the account menu is already in the top bar:

<SidebarSection className="max-lg:hidden">
  <SidebarHeading>Pinned reports</SidebarHeading>
  {/* ... */}
</SidebarSection>

<SidebarFooter className="max-lg:hidden">{/* account menu */}</SidebarFooter>
<SidebarSection class="max-lg:hidden">
  <SidebarHeading>Pinned reports</SidebarHeading>
  <!-- ... -->
</SidebarSection>

<SidebarFooter class="max-lg:hidden"><!-- account menu --></SidebarFooter>

Customization

Match the overscroll color

The layout paints its own background, zinc-100 behind the panel from lg up. Give the html element the same colors so a rubber-band scroll does not flash a different shade:

<html class="bg-white lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-950">
  <!-- ... -->
</html>