AppShell
An admin panel and dashboard layout — a collapsible Sidebar, a sticky header, a Drawer on phones and a skip link.
Bosh sahifa
Tugma yoki Ctrl/⌘ + B sidebar'ni yig'adi. Holat localStorage'da saqlanadi.
'use client';
import { AppShell, Sidebar } from '@akhatmirzo/ui';
import { BarChart3, Box, Home, Settings, Store, Users } from 'lucide-react';
import { useState } from 'react';
const items = [
{ id: 'home', label: 'Bosh sahifa', icon: <Home /> },
{ id: 'orders', label: 'Buyurtmalar', icon: <Box />, badge: 12 },
{ id: 'customers', label: 'Mijozlar', icon: <Users /> },
{ id: 'reports', label: 'Hisobotlar', icon: <BarChart3 /> },
];
export default function AppShellBasic() {
const [page, setPage] = useState('home');
const current = items.find((i) => i.id === page)?.label ?? 'Sozlamalar';
return (
// Hujjat ichida cheklangan ramka; haqiqiy ilovada AppShell butun ekranni egallaydi
<div className="h-[480px] w-full overflow-auto rounded-xl border border-border">
<AppShell className="[--ak-app-shell-height:480px]" sidebarStorageKey="docs-app-shell">
<Sidebar>
<Sidebar.Header>
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-primary text-primary-foreground">
<Store className="size-5" aria-hidden="true" />
</span>
<span className="truncate text-sm font-bold">Chorsu Market</span>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group label="Do'kon">
{items.map((item) => (
<Sidebar.Item
key={item.id}
icon={item.icon}
badge={item.badge}
active={page === item.id}
onClick={() => setPage(item.id)}
>
{item.label}
</Sidebar.Item>
))}
</Sidebar.Group>
<Sidebar.Group label="Boshqaruv">
<Sidebar.Item
icon={<Settings />}
active={page === 'settings'}
onClick={() => setPage('settings')}
>
Sozlamalar
</Sidebar.Item>
</Sidebar.Group>
</Sidebar.Content>
</Sidebar>
<AppShell.Main>
<AppShell.Header>
<AppShell.SidebarToggle />
<span className="text-sm font-medium">{current}</span>
</AppShell.Header>
<div className="flex flex-col gap-3 p-6">
<h2 className="m-0 text-xl font-semibold">{current}</h2>
<p className="m-0 text-sm text-text-muted">
Tugma yoki Ctrl/⌘ + B sidebar'ni yig'adi. Holat localStorage'da saqlanadi.
</p>
</div>
</AppShell.Main>
</AppShell>
</div>
);
}
Import
import { AppShell, Sidebar, useAppShell } from '@akhatmirzo/ui';When to use
- Admin panels, CRMs, dashboards — a persistent menu on the left, page content on the right.
- Apps with many sections that users switch between often.
- Not for marketing pages or blogs — a top navigation works better there.
- The layout fills the screen; put it in your app's
layoutonce.
Anatomy
| Part | What it does |
|---|---|
AppShell | State (collapsed/expanded), persistence, the mod+B shortcut, skip link |
Sidebar | A left panel on desktop (256px ↔ 64px), a left Drawer below 1024px |
Sidebar.Header | Logo, app name |
Sidebar.Content | <nav aria-label="Asosiy menyu">, scrollable |
Sidebar.Group | A heading + a list of items; can be collapsible |
Sidebar.Item | A link (href), a button (onClick) or a Next Link via renderLink |
Sidebar.Footer | Bottom area: a user menu |
AppShell.Main | <main> — the skip link jumps here |
AppShell.Header | Sticky (sticky top-0), 56px, bottom border |
AppShell.SidebarToggle | The sidebar button: collapses on desktop, opens the Drawer on phones |
// app/(admin)/layout.tsx
<AppShell sidebarStorageKey="sidebar">
<Sidebar>
<Sidebar.Header>
<Logo />
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group label="Main">
<Sidebar.Item icon={<Home />} href="/" active={pathname === '/'}>
Home
</Sidebar.Item>
<Sidebar.Item icon={<Box />} href="/orders" badge={12}>
Orders
</Sidebar.Item>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer>
<UserMenu />
</Sidebar.Footer>
</Sidebar>
<AppShell.Main>
<AppShell.Header>
<AppShell.SidebarToggle />
<Breadcrumb items={…} />
</AppShell.Header>
{children}
</AppShell.Main>
</AppShell>Collapsed state
On desktop (≥ 1024px) the sidebar collapses to a 64px icon rail: item labels are hidden, each
item gets a Tooltip and an aria-label, and badges turn into a dot. Collapse it with
AppShell.SidebarToggle or Ctrl/⌘ + B. The animation runs on grid-template-columns and jumps
straight to the end with prefers-reduced-motion.
A collapsible group folds when its heading is clicked (Accordion style); in the collapsed rail
groups are always open and a divider replaces the heading.
Yig'ilgan holatda faqat ikonkalar ko'rinadi — ustiga kelsangiz Tooltip chiqadi. "Hisobotlar" guruhi to'liq holatda yig'iladi.
'use client';
import { AppShell, Avatar, Sidebar } from '@akhatmirzo/ui';
import { FileText, Home, PieChart, Store, TrendingUp, Wallet } from 'lucide-react';
export default function AppShellCollapsed() {
return (
<div className="h-[480px] w-full overflow-auto rounded-xl border border-border">
<AppShell
className="[--ak-app-shell-height:480px]"
defaultSidebarOpen={false}
shortcut={false}
>
<Sidebar>
<Sidebar.Header>
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-primary text-primary-foreground">
<Store className="size-5" aria-hidden="true" />
</span>
<span className="truncate text-sm font-bold">Chorsu Market</span>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.Item icon={<Home />} href="#home" active>
Bosh sahifa
</Sidebar.Item>
<Sidebar.Item icon={<Wallet />} href="#payments" badge={3}>
To'lovlar
</Sidebar.Item>
</Sidebar.Group>
<Sidebar.Group label="Hisobotlar" collapsible>
<Sidebar.Item icon={<TrendingUp />} href="#sales">
Savdo
</Sidebar.Item>
<Sidebar.Item icon={<PieChart />} href="#categories">
Toifalar
</Sidebar.Item>
<Sidebar.Item icon={<FileText />} href="#tax" disabled>
Soliq (tez orada)
</Sidebar.Item>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer>
<div className="flex min-w-0 items-center gap-3">
<Avatar name="Dilnoza Karimova" size="sm" />
<span className="truncate text-sm font-medium">Dilnoza Karimova</span>
</div>
</Sidebar.Footer>
</Sidebar>
<AppShell.Main>
<AppShell.Header>
<AppShell.SidebarToggle />
<span className="text-sm font-medium">Bosh sahifa</span>
</AppShell.Header>
<p className="m-0 p-6 text-sm text-text-muted">
Yig'ilgan holatda faqat ikonkalar ko'rinadi — ustiga kelsangiz Tooltip chiqadi.
"Hisobotlar" guruhi to'liq holatda yig'iladi.
</p>
</AppShell.Main>
</AppShell>
</div>
);
}
Persisting the state
With sidebarStorageKey, the state is written to localStorage and restored on the next visit.
It's SSR-safe: the server and the first client render use defaultSidebarOpen, and the stored
value is read after mount (no hydration mismatch). If storage is blocked, the state still works.
Phones and tablets
Below 1024px a left Drawer replaces the sidebar: AppShell.SidebarToggle
opens it and clicking a Sidebar.Item (navigating) closes it. The desktop/mobile switch happens
through CSS breakpoints, so the server and the browser render the same HTML.
With Next.js Link
renderLink lets you use your own link component. It receives href, className,
aria-current, onClick and the inner content — spread all of them.
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
function NavItem({ href, icon, children }) {
const pathname = usePathname();
return (
<Sidebar.Item
href={href}
icon={icon}
active={pathname.startsWith(href)}
renderLink={(props) => <Link {...props} />}
>
{children}
</Sidebar.Item>
);
}useAppShell
Any component inside AppShell can read and change the state:
{ sidebarOpen, setSidebarOpen, toggleSidebar, mobileOpen, setMobileOpen, collapsed }.
Holat: to'liq
'use client';
import { AppShell, Button, Sidebar, useAppShell } from '@akhatmirzo/ui';
import { Home, Inbox } from 'lucide-react';
function Status() {
const { sidebarOpen, setSidebarOpen, collapsed } = useAppShell();
return (
<div className="flex flex-col items-start gap-3 p-6 text-sm">
<p className="m-0 text-text-muted">
Holat: <b className="text-text">{collapsed ? "yig'ilgan" : "to'liq"}</b>
</p>
<Button size="sm" onClick={() => setSidebarOpen(!sidebarOpen)}>
{collapsed ? 'Yoyish' : "Yig'ish"}
</Button>
</div>
);
}
export default function AppShellUseAppShell() {
return (
<div className="h-[360px] w-full overflow-auto rounded-xl border border-border">
<AppShell className="[--ak-app-shell-height:360px]" shortcut={false}>
<Sidebar width={220}>
<Sidebar.Content>
<Sidebar.Group label="Pochta">
<Sidebar.Item icon={<Inbox />} href="#inbox" badge={4} active>
Kiruvchi
</Sidebar.Item>
<Sidebar.Item icon={<Home />} href="#home">
Bosh sahifa
</Sidebar.Item>
</Sidebar.Group>
</Sidebar.Content>
</Sidebar>
<AppShell.Main>
<AppShell.Header>
<AppShell.SidebarToggle />
</AppShell.Header>
<Status />
</AppShell.Main>
</AppShell>
</div>
);
}
Examples on this page
The examples here live in a bounded frame: className="[--ak-app-shell-height:480px]" sets the
layout height (the default is 100dvh). Don't pass it in a real app.
Props
AppShell
Also accepts <div> attributes and ref.
| Prop | Type | Default | Description |
|---|---|---|---|
sidebarOpen | boolean | — | Whether the desktop sidebar is expanded (controlled) |
defaultSidebarOpen | boolean | true | Initial state |
onSidebarOpenChange | (open: boolean) => void | — | Called on collapse / expand |
sidebarStorageKey | string | — | localStorage key — persists the state (SSR-safe) |
shortcut | string | false | 'mod+b' | Collapse shortcut (useHotkeys format); false disables it |
labels | { skipToContent?: string } | { skipToContent: "Asosiy kontentga o'tish" } | Skip link text |
Sidebar
Also accepts <aside> attributes (the desktop panel) and ref.
| Prop | Type | Default | Description |
|---|---|---|---|
width | number | string | 256 | Expanded width; 64px when collapsed |
labels | { nav?: string; drawerTitle?: string } | { nav: "Asosiy menyu", drawerTitle: "Menyu" } | The <nav> name and the mobile Drawer title (for screen readers) |
Sidebar.Item
| Prop | Type | Default | Description |
|---|---|---|---|
icon | ReactNode | — | Leading icon (18px); the only thing shown when collapsed |
href | string | — | Link target; without it — a <button> |
onClick | (event: MouseEvent) => void | — | Called on click (the mobile Drawer closes either way) |
active | boolean | false | Current page — aria-current="page" and a highlighted background |
badge | ReactNode | — | A counter (12) or your own element |
disabled | boolean | false | Disabled |
label | string | — | Tooltip and aria-label when collapsed (defaults to the children text) |
renderLink | (props: SidebarItemLinkProps) => ReactElement | — | Your own link component: (p) => <Link {...p} /> |
Sidebar.Group
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Group heading |
collapsible | boolean | false | The group folds when its heading is clicked |
defaultOpen | boolean | true | Whether a collapsible group starts open |
AppShell.SidebarToggle
IconButton props; aria-label defaults to "Yon menyu", variant to ghost. Pass children
to replace the built-in icon.
Data attributes
| Attribute | Where | Value |
|---|---|---|
data-sidebar-state | AppShell | expanded · collapsed |
data-collapsed | Sidebar | present when collapsed |
data-active | Sidebar.Item | present when active |
data-disabled | Sidebar.Item | present when disabled |
Keyboard
| Key | Action |
|---|---|
Tab (first) | Reveals the "skip to main content" link |
Ctrl/⌘ + B | Collapses/expands on desktop, opens the menu on phones |
Esc | Closes the mobile menu |
Accessibility
- Skip link.
AppShellputs a "skip to main content" link at the top of the page — it's visible only on keyboard focus and jumps toAppShell.Main. Change its text withlabels. - Navigation. The menu is inside
<nav aria-label="Asosiy menyu">and the active item getsaria-current="page"; groups arerole="group"and labelled by their heading. - Collapsed state. Even with the text hidden, every item keeps its name through
aria-labeland aTooltip. Badge counts stay available to screen readers as text. - Toggle.
AppShell.SidebarTogglegetsaria-expandedandaria-controls(the panel on desktop, the Drawer on phones). - Mobile menu is a
Drawer: focus is trapped inside,Esccloses it and focus returns to the button.