Stacked layout
An app shell with a two-row header: your top bar, then section tabs that scroll on small screens.
'use client'
import * as Headless from '@headlessui/react'
import { Avatar } from '@/components/avatar'
import { Button } from '@/components/button'
import { Dropdown, DropdownDivider, DropdownItem, DropdownLabel, DropdownMenu } from '@/components/dropdown'
import { LayoutTab, LayoutTabs, StackedLayout } from '@/components/stacked-layout'
import {
ArrowRightStartOnRectangleIcon,
CheckBadgeIcon,
ChevronRightIcon,
ChevronUpDownIcon,
MegaphoneIcon,
PlusIcon,
UserCircleIcon,
} from '@heroicons/react/16/solid'
const sections = [
{ label: 'Overview', href: '/' },
{ label: 'Payments', href: '/payments' },
{ label: 'Invoices', href: '/invoices' },
{ label: 'Customers', href: '/customers' },
{ label: 'Payouts', href: '/payouts' },
{ label: 'Settings', href: '/settings' },
]
const switcher =
'relative flex items-center gap-1.5 rounded-md px-2 py-1 text-base/6 font-medium text-zinc-950 sm:text-sm/6 dark:text-white data-hover:bg-zinc-950/5 data-open:bg-zinc-950/5 dark:data-hover:bg-white/5 dark:data-open:bg-white/5 focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
function Switcher({ label, value, children }: { label: string; value: React.ReactNode; children: React.ReactNode }) {
return (
<Dropdown>
<Headless.MenuButton aria-label={label} className={switcher}>
{value}
<ChevronUpDownIcon className="size-4 shrink-0 fill-zinc-400 dark:fill-zinc-500" />
</Headless.MenuButton>
<DropdownMenu className="min-w-56" anchor="bottom start">
{children}
</DropdownMenu>
</Dropdown>
)
}
export function AppShell({ current, children }: { current: string; children: React.ReactNode }) {
return (
<StackedLayout
navbar={
<>
<img src="/logo.svg" alt="Northwind" className="size-7 shrink-0" />
<nav aria-label="Workspace" className="min-w-0">
<ol role="list" className="flex min-w-0 items-center gap-1">
<li className="max-sm:hidden">
<Switcher label="Workspace" value="Northwind">
<DropdownItem href="/workspaces/kivu-freight">
<DropdownLabel>Kivu Freight</DropdownLabel>
</DropdownItem>
<DropdownDivider />
<DropdownItem href="/workspaces/new">
<PlusIcon />
<DropdownLabel>Create workspace</DropdownLabel>
</DropdownItem>
</Switcher>
</li>
<li aria-hidden="true" className="text-zinc-300 max-sm:hidden dark:text-zinc-600">
/
</li>
<li className="min-w-0">
<Switcher label="Store" value={<span className="truncate">Northwind Market</span>}>
<DropdownItem href="/stores/wholesale">
<DropdownLabel>Northwind Wholesale</DropdownLabel>
</DropdownItem>
</Switcher>
</li>
<li aria-hidden="true" className="text-zinc-300 dark:text-zinc-600">
/
</li>
<li>
<Switcher
label="Environment"
value={
<>
<CheckBadgeIcon className="size-4 shrink-0 text-green-600 dark:text-green-500" />
Live
</>
}
>
<DropdownItem href="?env=test">
<CheckBadgeIcon className="text-amber-500" />
<DropdownLabel>Test</DropdownLabel>
</DropdownItem>
</Switcher>
</li>
</ol>
</nav>
<div className="ml-auto flex shrink-0 items-center gap-3">
<Button color="light" href="/team" className="max-md:hidden">
Invite team
<ChevronRightIcon />
</Button>
<Button plain href="/changelog" aria-label="What’s new" className="max-sm:hidden">
<MegaphoneIcon />
</Button>
<Dropdown>
<Headless.MenuButton
aria-label="Account"
className="rounded-full focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500"
>
<Avatar src="/users/avatar.jpg" className="size-8" />
</Headless.MenuButton>
<DropdownMenu className="min-w-56" anchor="bottom end">
<DropdownItem href="/account">
<UserCircleIcon />
<DropdownLabel>Profile</DropdownLabel>
</DropdownItem>
<DropdownDivider />
<DropdownItem href="/logout">
<ArrowRightStartOnRectangleIcon />
<DropdownLabel>Log out</DropdownLabel>
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</>
}
tabs={
<LayoutTabs>
{sections.map((item) => (
<LayoutTab key={item.label} href={item.href} current={item.label === current}>
{item.label}
</LayoutTab>
))}
</LayoutTabs>
}
>
{children}
</StackedLayout>
)
}<script setup lang="ts">
import Avatar from '@/components/avatar.vue'
import Button from '@/components/button.vue'
import Dropdown from '@/components/dropdown.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 LayoutTab from '@/components/layout-tab.vue'
import LayoutTabs from '@/components/layout-tabs.vue'
import StackedLayout from '@/components/stacked-layout.vue'
import {
ArrowRightStartOnRectangleIcon,
CheckBadgeIcon,
ChevronRightIcon,
ChevronUpDownIcon,
MegaphoneIcon,
PlusIcon,
UserCircleIcon,
} from '@heroicons/vue/16/solid'
import { DropdownMenuTrigger } from 'reka-ui'
defineProps<{ current: string }>()
const sections = [
{ label: 'Overview', href: '/' },
{ label: 'Payments', href: '/payments' },
{ label: 'Invoices', href: '/invoices' },
{ label: 'Customers', href: '/customers' },
{ label: 'Payouts', href: '/payouts' },
{ label: 'Settings', href: '/settings' },
]
const switcher =
'relative flex items-center gap-1.5 rounded-md px-2 py-1 text-base/6 font-medium text-zinc-950 sm:text-sm/6 dark:text-white hover:bg-zinc-950/5 data-[state=open]:bg-zinc-950/5 dark:hover:bg-white/5 dark:data-[state=open]:bg-white/5 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500'
const chevron = 'size-4 shrink-0 fill-zinc-400 dark:fill-zinc-500'
</script>
<template>
<StackedLayout>
<template #navbar>
<img src="/logo.svg" alt="Northwind" class="size-7 shrink-0" />
<nav aria-label="Workspace" class="min-w-0">
<ol role="list" class="flex min-w-0 items-center gap-1">
<li class="max-sm:hidden">
<Dropdown>
<DropdownMenuTrigger aria-label="Workspace" :class="switcher">
Northwind
<ChevronUpDownIcon :class="chevron" />
</DropdownMenuTrigger>
<DropdownMenu class="min-w-56" anchor="bottom start">
<DropdownItem href="/workspaces/kivu-freight">
<DropdownLabel>Kivu Freight</DropdownLabel>
</DropdownItem>
<DropdownDivider />
<DropdownItem href="/workspaces/new">
<PlusIcon />
<DropdownLabel>Create workspace</DropdownLabel>
</DropdownItem>
</DropdownMenu>
</Dropdown>
</li>
<li aria-hidden="true" class="text-zinc-300 max-sm:hidden dark:text-zinc-600">/</li>
<li class="min-w-0">
<Dropdown>
<DropdownMenuTrigger aria-label="Store" :class="switcher">
<span class="truncate">Northwind Market</span>
<ChevronUpDownIcon :class="chevron" />
</DropdownMenuTrigger>
<DropdownMenu class="min-w-56" anchor="bottom start">
<DropdownItem href="/stores/wholesale">
<DropdownLabel>Northwind Wholesale</DropdownLabel>
</DropdownItem>
</DropdownMenu>
</Dropdown>
</li>
<li aria-hidden="true" class="text-zinc-300 dark:text-zinc-600">/</li>
<li>
<Dropdown>
<DropdownMenuTrigger aria-label="Environment" :class="switcher">
<CheckBadgeIcon class="size-4 shrink-0 text-green-600 dark:text-green-500" />
Live
<ChevronUpDownIcon :class="chevron" />
</DropdownMenuTrigger>
<DropdownMenu class="min-w-56" anchor="bottom start">
<DropdownItem href="?env=test">
<CheckBadgeIcon class="text-amber-500" />
<DropdownLabel>Test</DropdownLabel>
</DropdownItem>
</DropdownMenu>
</Dropdown>
</li>
</ol>
</nav>
<div class="ml-auto flex shrink-0 items-center gap-3">
<Button color="light" href="/team" class="max-md:hidden">
Invite team
<ChevronRightIcon />
</Button>
<Button plain href="/changelog" aria-label="What’s new" class="max-sm:hidden">
<MegaphoneIcon />
</Button>
<Dropdown>
<DropdownMenuTrigger
aria-label="Account"
class="rounded-full focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
<Avatar src="/users/avatar.jpg" class="size-8" />
</DropdownMenuTrigger>
<DropdownMenu class="min-w-56" anchor="bottom end">
<DropdownItem href="/account">
<UserCircleIcon />
<DropdownLabel>Profile</DropdownLabel>
</DropdownItem>
<DropdownDivider />
<DropdownItem href="/logout">
<ArrowRightStartOnRectangleIcon />
<DropdownLabel>Log out</DropdownLabel>
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</template>
<template #tabs>
<LayoutTabs>
<LayoutTab v-for="item in sections" :key="item.label" :href="item.href" :current="item.label === current">
{{ item.label }}
</LayoutTab>
</LayoutTabs>
</template>
<slot />
</StackedLayout>
</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 * as Headless from '@headlessui/react'
import clsx from 'clsx'
import { LayoutGroup, motion } from 'motion/react'
import React, { forwardRef, useId } from 'react'
import { Link } from './link'
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 header = {
background: 'bg-zinc-50 dark:bg-zinc-950',
border: 'border-b border-zinc-950/5 dark:border-white/5',
padding: 'pl-[max(--spacing(4),env(safe-area-inset-left))] pr-[max(--spacing(4),env(safe-area-inset-right))] sm:px-6',
}
type StackedLayoutProps = React.PropsWithChildren<{
navbar: React.ReactNode
tabs?: React.ReactNode
}>
export function StackedLayout({ navbar, tabs, children }: StackedLayoutProps) {
return (
<div className="relative isolate flex min-h-svh w-full flex-col bg-white dark:bg-zinc-900">
<a href="#main-content" className={skipLink}>
Skip to content
</a>
<header className={clsx(Object.values(header))}>
<div className={clsx('flex h-16 items-center gap-3', tabs && 'border-b border-zinc-950/5 dark:border-white/5')}>
{navbar}
</div>
{tabs}
</header>
<main id="main-content" tabIndex={-1} className="flex-1 outline-hidden">
<div className="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:py-14">{children}</div>
</main>
</div>
)
}
export function LayoutTabs({ className, ...props }: React.ComponentPropsWithoutRef<'nav'>) {
let id = useId()
return (
<LayoutGroup id={id}>
<nav aria-label="Sections" {...props} className={clsx(className, '-mx-3 flex gap-1 overflow-x-auto py-2.5')} />
</LayoutGroup>
)
}
const tab = {
layout: 'block rounded-md px-3 py-1.5',
text: 'text-base/6 sm:text-sm/6 text-zinc-600 dark:text-zinc-400',
hover: 'data-hover:bg-zinc-950/5 data-hover:text-zinc-950 dark:data-hover:bg-white/5 dark:data-hover:text-white',
current: 'data-current:font-medium data-current:text-zinc-950 dark:data-current:text-white',
focus: 'focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500',
}
const indicator = 'absolute inset-x-2 -bottom-2.5 h-0.5 rounded-full bg-zinc-950 dark:bg-white'
type LayoutTabProps = {
current?: boolean
className?: string
children: React.ReactNode
} & (
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
)
export const LayoutTab = forwardRef(function LayoutTab(
{ current, className, children, ...props }: LayoutTabProps,
ref: React.ForwardedRef<HTMLAnchorElement | HTMLButtonElement>
) {
let classes = clsx(Object.values(tab))
return (
<span className={clsx(className, 'relative shrink-0')}>
{current && <motion.span layoutId="current-tab" className={indicator} />}
{typeof props.href === 'string' ? (
<Link
{...props}
className={classes}
data-current={current ? '' : undefined}
aria-current={current ? 'page' : undefined}
ref={ref as React.ForwardedRef<HTMLAnchorElement>}
>
{children}
</Link>
) : (
<Headless.Button
{...props}
className={clsx('cursor-default', classes)}
data-current={current ? '' : undefined}
aria-current={current ? 'page' : undefined}
ref={ref}
>
{children}
</Headless.Button>
)}
</span>
)
})<script setup lang="ts">
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 header = {
background: 'bg-zinc-50 dark:bg-zinc-950',
border: 'border-b border-zinc-950/5 dark:border-white/5',
padding: 'pl-[max(--spacing(4),env(safe-area-inset-left))] pr-[max(--spacing(4),env(safe-area-inset-right))] sm:px-6',
}
</script>
<template>
<div class="relative isolate flex min-h-svh w-full flex-col bg-white dark:bg-zinc-900">
<a href="#main-content" :class="skipLink">Skip to content</a>
<header :class="Object.values(header)">
<div :class="['flex h-16 items-center gap-3', $slots.tabs && 'border-b border-zinc-950/5 dark:border-white/5']">
<slot name="navbar" />
</div>
<slot name="tabs" />
</header>
<main id="main-content" tabindex="-1" class="flex-1 outline-hidden">
<div class="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:py-14">
<slot />
</div>
</main>
</div>
</template>Component API
| Prop | Default | Description |
|---|---|---|
StackedLayoutrenders a <div> element | ||
navbar | - | Top row of the header: logo, switchers and account tools, laid out in a flex row. |
tabs | - | Second row of the header, usually a `LayoutTabs`. A line separates it from the top row. |
children | - | Page content, centered at `max-w-7xl`. |
LayoutTabsrenders a <nav> element | ||
aria-label | 'Sections' | Name of the navigation landmark. |
LayoutTabrenders a <Headless.Button | Link> element | ||
current | false | Marks the active section: bolder text, `aria-current="page"` and the underline, which slides from the previous tab. |
href | - | Renders a link instead of a button. |
| Prop | Default | Description |
|---|---|---|
StackedLayoutrenders a <div> element | ||
#navbar | - | Slot for the top row of the header: logo, switchers and account tools. |
#tabs | - | Slot for the second row, usually a `LayoutTabs`. A line separates it from the top row. |
#default | - | Slot for the page content, centered at `max-w-7xl`. |
LayoutTabsrenders a <nav> element | ||
aria-label | 'Sections' | Name of the navigation landmark. |
LayoutTabrenders a <button | Link> element | ||
current | false | Marks the active section: bolder text, `aria-current="page"` and the underline, which slides from the previous tab. |
href | - | Renders a link instead of a button. |
Usage
This shell suits apps with a handful of main sections and pages that use the full width, such as wide tables. The top row says where you are, with switchers for the workspace, the store and the environment. The tabs below move between sections of that place. Once the sections outgrow the tabs or need grouped shortcuts, move to the sidebar layout.
Wrap your app routes
Render it once in the layout shared by signed-in pages. Both rows are optional content: pass only a logo in navbar and a few tabs to start, then grow the top row as the app needs switchers and account tools.
import { LayoutTab, LayoutTabs, StackedLayout } from '@/components/stacked-layout'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<StackedLayout
navbar={<img src="/logo.svg" alt="Northwind" className="size-7" />}
tabs={
<LayoutTabs>
<LayoutTab href="/">Overview</LayoutTab>
<LayoutTab href="/payments">Payments</LayoutTab>
<LayoutTab href="/invoices">Invoices</LayoutTab>
</LayoutTabs>
}
>
{children}
</StackedLayout>
)
}<script setup lang="ts">
import LayoutTab from '@/components/layout-tab.vue'
import LayoutTabs from '@/components/layout-tabs.vue'
import StackedLayout from '@/components/stacked-layout.vue'
</script>
<template>
<StackedLayout>
<template #navbar>
<img src="/logo.svg" alt="Northwind" class="size-7" />
</template>
<template #tabs>
<LayoutTabs>
<LayoutTab href="/">Overview</LayoutTab>
<LayoutTab href="/payments">Payments</LayoutTab>
<LayoutTab href="/invoices">Invoices</LayoutTab>
</LayoutTabs>
</template>
<slot />
</StackedLayout>
</template>Structure
Mark the current section
Keep the sections in one array and compare each label with the current page. The current tab gets bolder text and aria-current="page", and its underline slides over from the tab you came from. The Dropdown page covers the menus behind each switcher.
const sections = [
{ label: 'Overview', href: '/' },
{ label: 'Payments', href: '/payments' },
{ label: 'Invoices', href: '/invoices' },
]
<LayoutTabs>
{sections.map((item) => (
<LayoutTab key={item.label} href={item.href} current={item.label === current}>
{item.label}
</LayoutTab>
))}
</LayoutTabs><script setup lang="ts">
defineProps<{ current: string }>()
const sections = [
{ label: 'Overview', href: '/' },
{ label: 'Payments', href: '/payments' },
{ label: 'Invoices', href: '/invoices' },
]
</script>
<template>
<LayoutTabs>
<LayoutTab v-for="item in sections" :key="item.label" :href="item.href" :current="item.label === current">
{{ item.label }}
</LayoutTab>
</LayoutTabs>
</template>Responsive behavior
The tabs row never wraps: when the sections do not fit, it scrolls sideways under the finger. The layout hides nothing in the top row for you. In the example above, max-sm:hidden drops the workspace switcher and the announcements button on a phone, and max-md:hidden drops the invite button, so the store, environment and account menu stay in reach.
The header pads itself by the safe area on notched screens. A Skip to content link comes first in the tab order and jumps over both rows to the main element.
Customization
Match the overscroll color
The layout paints its own white background. Give the html element the same colors so a rubber-band scroll does not flash a different shade:
<html class="bg-white dark:bg-zinc-900">
<!-- ... -->
</html>