MioUI

App Shell

An app shell with a collapsible sidebar, a header slot and content; switches to a hamburger + left sheet in narrow containers.

MioUI

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.

Includes

  • Sidebar expanded at 240px (group titles + icon + label + count / shortcut), collapsed to a 56px icon rail with a Tooltip per item
  • SNAPPY width spring and a sliding layoutId active highlight; collapsed state persists in localStorage, 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 left Sheet with the same nav
  • header slot (the demo uses TopHeader) above scrollable content
  • Defaults to a relative h-[560px] box so it works inside preview frames or any container; pass className="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

components/blocks/app-shell.tsx
'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>
  );
}