App Shell
An app shell with a collapsible sidebar, a header slot and content; switches to a hamburger + left sheet in narrow containers.
MioUI
Saved · just now
Home
Press ⌘K / Ctrl K, or click “Search” in the sidebar to open the command palette.
Visits this week
12,480+8.2%
Active projects
24+3
Avg. response
182 ms−12%
Nothing here yet
Create a project, or jump anywhere from the command palette.
'use client';
import { useState } from 'react';
import { Plus, Tray } from '@phosphor-icons/react';
import { AppShell, defaultNav } from '@/components/blocks/app-shell';
import { TopHeader } from '@/components/blocks/top-header';
import { CommandPalette, useCommandPalette } from '@/components/blocks/command-palette';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { EmptyState, Skeleton } from '@/components/ui/feedback';
import { useDemoCommands } from './command-palette-demo';
const nav = defaultNav('en');
const labelOf = (id: string) => nav.flatMap((g) => g.items).find((i) => i.id === id)?.label ?? id;
const stats = [
{ label: 'Visits this week', value: '12,480', delta: '+8.2%' },
{ label: 'Active projects', value: '24', delta: '+3' },
{ label: 'Avg. response', value: '182 ms', delta: '−12%' },
];
export default function AppShellDemo() {
const [active, setActive] = useState('home');
const [loading, setLoading] = useState(false);
const palette = useCommandPalette();
const commands = useDemoCommands((label) => {
const hit = nav.flatMap((g) => g.items).find((i) => i.label === label);
if (hit) navigate(hit.id);
});
const navigate = (id: string) => {
setActive(id);
setLoading(true);
setTimeout(() => setLoading(false), 700);
};
return (
<>
<AppShell
lang="en"
active={active}
onNavigate={navigate}
onAction={(id) => id === 'search' && palette.setOpen(true)}
user={{ name: 'Mio Chan', email: 'mio@example.com' }}
header={<TopHeader lang="en" crumbs={[{ label: 'Workspace' }, { label: 'Design system' }, { label: labelOf(active) }]} />}
>
<div className="mx-auto max-w-[880px] p-6">
<h1 className="font-title text-[26px] leading-tight text-text-primary">{labelOf(active)}</h1>
<p className="mt-1 text-sm text-text-secondary">Press ⌘K / Ctrl K, or click “Search” in the sidebar to open the command palette.</p>
<div className="mt-6 grid grid-cols-1 gap-3 @lg:grid-cols-3">
{stats.map((s) => (
<Card key={s.label} className="p-4">
<div className="text-[12px] text-text-muted">{s.label}</div>
{loading ? (
<Skeleton className="mt-2 h-7 w-24" />
) : (
<div className="mt-1 flex items-baseline gap-2">
<span className="font-title text-[24px] tabular-nums text-text-primary">{s.value}</span>
<span className="text-[12px] tabular-nums text-success">{s.delta}</span>
</div>
)}
</Card>
))}
</div>
<Card className="mt-4 p-4">
<div className="flex flex-col gap-3">
{[0, 1, 2].map((i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton circle className="size-8" />
<div className="flex flex-1 flex-col gap-1.5">
<Skeleton className="h-3 w-1/3" />
<Skeleton className="h-3 w-2/3" />
</div>
</div>
))}
</div>
</Card>
<Card className="mt-4">
<EmptyState
icon={<Tray />}
title="Nothing here yet"
description="Create a project, or jump anywhere from the command palette."
action={
<>
<Button variant="secondary" size="sm" onClick={() => palette.setOpen(true)}>
Open command palette
</Button>
<Button size="sm">
<Plus />
New project
</Button>
</>
}
/>
</Card>
</div>
</AppShell>
<CommandPalette {...palette.props} lang="en" items={commands} />
</>
);
}
Includes
- Sidebar expanded at 240px (group titles + icon + label + count / shortcut), collapsed to a 56px icon rail with a
Tooltipper item - SNAPPY width spring and a sliding
layoutIdactive highlight; collapsed state persists inlocalStorage, toggle with⌘\/Ctrl+\ - Bottom-pinned avatar and settings entry
- Container queries (
@container): below 48rem of shell width the sidebar hides and a top bar with a hamburger opens a leftSheetwith the same nav headerslot (the demo usesTopHeader) above scrollable content- Defaults to a relative
h-[560px]box so it works inside preview frames or any container; passclassName="h-dvh"for a full-screen app - Demo: sidebar “Search” and ⌘K open the command palette; content shows skeletons, stat cards and an empty state
Usage
import { AppShell, defaultNav } from "@/components/blocks/app-shell";
import { TopHeader } from "@/components/blocks/top-header";
<AppShell
lang="en"
className="h-dvh"
nav={defaultNav("en")} // or your own NavGroup[]
active={route}
onNavigate={setRoute}
onAction={(id) => id === "search" && palette.setOpen(true)}
user={{ name: "Mio Chan", email: "mio@example.com" }}
header={<TopHeader lang="en" crumbs={[{ label: "Workspace" }, { label: "Home" }]} />}
>
{children}
</AppShell>Source
'use client';
import { motion } from 'motion/react';
import {
ChartBar,
Folder,
Gear,
House,
List,
MagnifyingGlass,
Notebook,
SidebarSimple,
Star,
Tray,
Users,
type Icon,
} from '@phosphor-icons/react';
import { useEffect, useId, useState, type ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
import { Tooltip } from '@/components/ui/tooltip';
import { Sheet } from '@/components/ui/sheet';
import { Kbd } from '@/components/ui/input';
import { SPRING_SNAPPY } from '@/lib/motion';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';
const COPY = {
zh: {
nav: '主导航',
brand: 'MioUI',
collapse: '收起侧边栏',
expand: '展开侧边栏',
menu: '打开菜单',
settings: '设置',
account: '账户',
groups: { workspace: '工作区', library: '资料库' },
items: {
search: '搜索',
home: '首页',
inbox: '收件箱',
projects: '项目',
notes: '笔记',
starred: '已加星标',
analytics: '数据分析',
team: '团队',
},
},
en: {
nav: 'Main navigation',
brand: 'MioUI',
collapse: 'Collapse sidebar',
expand: 'Expand sidebar',
menu: 'Open menu',
settings: 'Settings',
account: 'Account',
groups: { workspace: 'Workspace', library: 'Library' },
items: {
search: 'Search',
home: 'Home',
inbox: 'Inbox',
projects: 'Projects',
notes: 'Notes',
starred: 'Starred',
analytics: 'Analytics',
team: 'Team',
},
},
};
export interface NavItem {
id: string;
label: string;
icon: Icon;
/** 右侧计数(展开时显示) */
badge?: number;
/** 右侧快捷键提示(展开时 Kbd,收起时显示在 Tooltip 内) */
shortcut?: string;
/** 动作型条目(如“搜索”打开命令面板):不改变选中态 */
action?: boolean;
}
export interface NavGroup {
id: string;
/** 省略时不显示分组标题 */
title?: string;
items: NavItem[];
}
export function defaultNav(lang: 'zh' | 'en' = 'zh'): NavGroup[] {
const t = COPY[lang].items;
const g = COPY[lang].groups;
return [
{
id: 'top',
items: [
{ id: 'search', label: t.search, icon: MagnifyingGlass, shortcut: '⌘K', action: true },
{ id: 'home', label: t.home, icon: House },
{ id: 'inbox', label: t.inbox, icon: Tray, badge: 3 },
],
},
{
id: 'workspace',
title: g.workspace,
items: [
{ id: 'projects', label: t.projects, icon: Folder },
{ id: 'notes', label: t.notes, icon: Notebook },
{ id: 'analytics', label: t.analytics, icon: ChartBar },
],
},
{
id: 'library',
title: g.library,
items: [
{ id: 'starred', label: t.starred, icon: Star },
{ id: 'team', label: t.team, icon: Users },
],
},
];
}
export interface AppShellProps {
lang?: 'zh' | 'en';
nav?: NavGroup[];
/** 当前选中项 id */
active?: string;
onNavigate?: (id: string) => void;
/** 动作型条目(action: true)被点击 */
onAction?: (id: string) => void;
onSettings?: () => void;
user?: { name: string; email?: string };
/** 品牌区(替换默认 MioUI 字标) */
brand?: ReactNode;
/** 主区顶部槽位(如 TopHeader) */
header?: ReactNode;
children?: ReactNode;
/** 侧边栏收起状态的 localStorage 键;传 false 不持久化 */
storageKey?: string | false;
defaultCollapsed?: boolean;
/** 外壳类名:默认 h-[560px],在预览框内自适应 */
className?: string;
}
function initials(name: string) {
const s = name.trim();
if (!s) return '?';
if (/[\u3400-\u9fff]/.test(s[0])) return s.slice(0, 1);
return s
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase())
.join('');
}
function Avatar({ name }: { name: string }) {
return (
<span aria-hidden className="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-accent-soft text-[12px] font-medium text-accent-text">
{initials(name)}
</span>
);
}
function NavList({
groups,
active,
collapsed,
layoutGroup,
onPick,
}: {
groups: NavGroup[];
active?: string;
collapsed: boolean;
layoutGroup: string;
onPick: (item: NavItem) => void;
}) {
const reduce = useReducedMotionPref();
return (
<div className="flex flex-col gap-4">
{groups.map((g, gi) => (
<div key={g.id} role="group" aria-label={g.title}>
{g.title &&
(collapsed ? (
gi > 0 && <div aria-hidden className="mx-2.5 mb-2 h-px bg-hairline" />
) : (
<div className="mb-1 truncate px-2.5 text-[12px] font-medium text-text-muted">{g.title}</div>
))}
<ul className="flex flex-col gap-0.5">
{g.items.map((it) => {
const on = !it.action && it.id === active;
const I = it.icon;
const btn = (
<button
type="button"
aria-current={on ? 'page' : undefined}
aria-label={collapsed ? it.label : undefined}
onClick={() => onPick(it)}
className={twMerge(
'cds-focus relative flex h-8 w-full items-center gap-2.5 rounded-lg px-2.5 text-left text-sm transition-colors duration-[120ms] ease-cds',
on ? 'font-medium text-text-primary' : 'text-text-secondary hover:bg-ghost-hover hover:text-text-primary',
)}
>
{on && (
<motion.span
layoutId={`nav-active-${layoutGroup}`}
transition={reduce ? { duration: 0 } : SPRING_SNAPPY}
className="absolute inset-0 rounded-lg bg-ghost-selected"
/>
)}
<I size={18} weight={on ? 'fill' : 'regular'} className="relative z-10 shrink-0" />
{!collapsed && (
<>
<span className="relative z-10 min-w-0 flex-1 truncate">{it.label}</span>
{it.badge != null && (
<span className="relative z-10 rounded-full bg-accent-soft px-1.5 text-[11px] font-medium leading-[18px] text-accent-text tabular-nums">
{it.badge}
</span>
)}
{it.shortcut && <Kbd className="relative z-10">{it.shortcut}</Kbd>}
</>
)}
{collapsed && it.badge != null && (
<span aria-hidden className="absolute right-1.5 top-1.5 z-10 size-1.5 rounded-full bg-accent-brand" />
)}
</button>
);
return (
<li key={it.id} className={twMerge('flex', collapsed && 'justify-center')}>
{collapsed ? (
<Tooltip content={it.label} side="right" shortcut={it.shortcut}>
{btn}
</Tooltip>
) : (
btn
)}
</li>
);
})}
</ul>
</div>
))}
</div>
);
}
/**
* 应用外壳:可收起侧边栏(240 ↔ 56,SNAPPY 宽度弹簧,localStorage 持久化),
* 窄容器(< 48rem,容器查询)时隐藏侧边栏,改为顶栏汉堡 + 左侧 Sheet。
*/
export function AppShell({
lang = 'zh',
nav,
active,
onNavigate,
onAction,
onSettings,
user = { name: 'Mio', email: 'mio@example.com' },
brand,
header,
children,
storageKey = 'mioui-shell-collapsed',
defaultCollapsed = false,
className,
}: AppShellProps) {
const t = COPY[lang];
const groups = nav ?? defaultNav(lang);
const uid = useId();
const reduce = useReducedMotionPref();
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const [sheet, setSheet] = useState(false);
// 挂载后再读 localStorage,避免 SSR 水合不一致
useEffect(() => {
if (!storageKey) return;
try {
const v = localStorage.getItem(storageKey);
if (v != null) setCollapsed(v === '1');
} catch {}
}, [storageKey]);
const toggle = () => {
setCollapsed((c) => {
const n = !c;
if (storageKey)
try {
localStorage.setItem(storageKey, n ? '1' : '0');
} catch {}
return n;
});
};
// ⌘\ / Ctrl+\ 切换收起
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.isComposing || !(e.metaKey || e.ctrlKey) || e.key !== '\\') return;
e.preventDefault();
toggle();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storageKey]);
const pick = (it: NavItem, fromSheet?: boolean) => {
if (fromSheet) setSheet(false);
if (it.action) onAction?.(it.id);
else onNavigate?.(it.id);
};
const brandMark = brand ?? (
<span className="flex min-w-0 items-center gap-2">
<span aria-hidden className="inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-accent-brand font-title text-[13px] text-accent-fg">
M
</span>
<span className="truncate font-title text-[16px] text-text-primary">{t.brand}</span>
</span>
);
const footer = (isCollapsed: boolean) => (
<div className={twMerge('flex items-center gap-1 border-t border-hairline p-2', isCollapsed && 'flex-col')}>
<div className={twMerge('flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-1.5 py-1', isCollapsed && 'flex-none px-0')}>
<Avatar name={user.name} />
{!isCollapsed && (
<div className="min-w-0 flex-1 leading-tight">
<div className="truncate text-[13px] font-medium text-text-primary">{user.name}</div>
{user.email && <div className="truncate text-[12px] text-text-muted">{user.email}</div>}
</div>
)}
</div>
<Tooltip content={t.settings} side={isCollapsed ? 'right' : 'top'}>
<button
type="button"
aria-label={t.settings}
onClick={() => {
setSheet(false);
onSettings?.();
}}
className="cds-focus inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-text-secondary transition-colors duration-[120ms] hover:bg-ghost-hover hover:text-text-primary"
>
<Gear size={18} />
</button>
</Tooltip>
</div>
);
return (
<div className={twMerge('@container relative h-[560px] w-full overflow-hidden bg-surface-0 text-left', className)}>
<div className="flex h-full">
{/* 宽容器:常驻侧边栏 */}
<motion.aside
aria-label={t.nav}
initial={false}
animate={{ width: collapsed ? 56 : 240 }}
transition={reduce ? { duration: 0 } : SPRING_SNAPPY}
className="hidden h-full shrink-0 flex-col overflow-hidden border-r border-hairline bg-surface-1 @3xl:flex"
>
<div className={twMerge('flex h-12 shrink-0 items-center gap-2 px-3', collapsed && 'justify-center px-0')}>
{!collapsed && <div className="min-w-0 flex-1">{brandMark}</div>}
<Tooltip content={collapsed ? t.expand : t.collapse} side={collapsed ? 'right' : 'bottom'} shortcut="⌘\">
<button
type="button"
aria-label={collapsed ? t.expand : t.collapse}
aria-expanded={!collapsed}
onClick={toggle}
className="cds-focus inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-text-secondary transition-colors duration-[120ms] hover:bg-ghost-hover hover:text-text-primary"
>
<SidebarSimple size={18} />
</button>
</Tooltip>
</div>
<nav className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-2 pb-3 pt-1">
<NavList groups={groups} active={active} collapsed={collapsed} layoutGroup={`${uid}-rail`} onPick={(it) => pick(it)} />
</nav>
{footer(collapsed)}
</motion.aside>
<div className="flex min-w-0 flex-1 flex-col">
{/* 窄容器:顶栏 + 汉堡 */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-hairline bg-surface-1 px-2 @3xl:hidden">
<button
type="button"
aria-label={t.menu}
aria-expanded={sheet}
onClick={() => setSheet(true)}
className="cds-focus inline-flex size-8 items-center justify-center rounded-lg text-text-secondary transition-colors duration-[120ms] hover:bg-ghost-hover hover:text-text-primary"
>
<List size={18} />
</button>
{brandMark}
</div>
{header}
<main className="min-h-0 flex-1 overflow-y-auto">{children}</main>
</div>
</div>
<Sheet open={sheet} onOpenChange={setSheet} side="left" aria-label={t.nav} title={brandMark} closeLabel={lang === 'zh' ? '关闭' : 'Close'} className="w-[min(280px,calc(100vw-48px))]">
<nav aria-label={t.nav} className="-mx-2.5 flex h-full flex-col">
<div className="flex-1">
<NavList groups={groups} active={active} collapsed={false} layoutGroup={`${uid}-sheet`} onPick={(it) => pick(it, true)} />
</div>
<div className="-mx-2.5 -mb-5 mt-4">{footer(false)}</div>
</nav>
</Sheet>
</div>
);
}