Sidebar
A rail of server-rendered links that collapses to a column of icons. Only the rail and its toggle run in the browser.
Sidebar
split - size not measured yetA rail of server-rendered links that collapses to a column of icons. Only the rail and its toggle run in the browser.
Try it
The code
import { folder } from 'topclassui/icons/folder'
import { gear } from 'topclassui/icons/gear'
import { house } from 'topclassui/icons/house'
import { Svg } from 'topclassui/svg'
import { SidebarBody, SidebarGroup, SidebarHeader, SidebarLink, SidebarRoot } from 'topclassui/sidebar'
<div className="h-96">
<SidebarRoot>
<SidebarHeader>Workspace</SidebarHeader>
<SidebarBody>
<SidebarGroup>
<SidebarLink href="#home" icon={<Svg icon={house} />}>Home</SidebarLink>
<SidebarLink href="#projects" icon={<Svg icon={folder} />}>Projects</SidebarLink>
<SidebarLink href="#settings" icon={<Svg icon={gear} />}>Settings</SidebarLink>
</SidebarGroup>
</SidebarBody>
</SidebarRoot>
</div>defaultOpen={false} sends the rail collapsed in the server HTML, so nothing moves when the page hydrates.
Every state
2 statesExamples
- Six flat parts and no bare
Sidebar.SidebarRootandSidebarTriggerrun in the browser;SidebarHeader,SidebarBody,SidebarGroupandSidebarLinkare server HTML.SidebarHeaderdraws the toggle itself. - Give the parent of the rail a height, such as
h-dvh. A long list then scrolls insideSidebarBody, and the page does not. - In a flex row the rail keeps its 220 px, or 60 collapsed. Give the column beside it
min-w-0 flex-1. - A link takes
href, orrender={<Link href="/docs" />}in Next.js. Every other prop goes to the link, not to the list item around it. - Give every link an
iconwhen the rail can collapse: collapsed, the icon is all that is drawn. openwithonOpenChangeneeds a'use client'file, as the controlled example shows. A button outside the rail can then toggle it.- Another width:
className="data-open:w-72"onSidebarRoot; the collapsed width stays. On a phone, start it collapsed: the drawer form is not built yet.
import { folder } from 'topclassui/icons/folder'
import { gear } from 'topclassui/icons/gear'
import { house } from 'topclassui/icons/house'
import { users } from 'topclassui/icons/users'
import { Svg } from 'topclassui/svg'
import { SidebarBody, SidebarGroup, SidebarHeader, SidebarLink, SidebarRoot } from 'topclassui/sidebar'
/** The drawn rail: the toggle and a title, then four links, of which Home is the page being
* read. Every link is written here, on the server; only the rail and its toggle hydrate. */
export default function Demo() {
return (
<div className="h-96">
<SidebarRoot>
<SidebarHeader>Workspace</SidebarHeader>
<SidebarBody>
<SidebarGroup>
<SidebarLink href="#home" icon={<Svg icon={house} />} current>Home</SidebarLink>
<SidebarLink href="#projects" icon={<Svg icon={folder} />}>Projects</SidebarLink>
<SidebarLink href="#team" icon={<Svg icon={users} />}>Team</SidebarLink>
<SidebarLink href="#settings" icon={<Svg icon={gear} />}>Settings</SidebarLink>
</SidebarGroup>
</SidebarBody>
</SidebarRoot>
</div>
)
}
import { bell } from 'topclassui/icons/bell'
import { folder } from 'topclassui/icons/folder'
import { gear } from 'topclassui/icons/gear'
import { house } from 'topclassui/icons/house'
import { signOut } from 'topclassui/icons/sign-out'
import { users } from 'topclassui/icons/users'
import { Svg } from 'topclassui/svg'
import { SidebarBody, SidebarGroup, SidebarHeader, SidebarLink, SidebarRoot } from 'topclassui/sidebar'
/** Two groups, each a list named by its label. Collapsed, the labels are hidden and a
* screen reader still hears "Workspace, list" and "Account, list". */
export default function Demo() {
return (
<div className="h-112">
<SidebarRoot>
<SidebarHeader>Acme</SidebarHeader>
<SidebarBody>
<SidebarGroup label="Workspace">
<SidebarLink href="#home" icon={<Svg icon={house} />}>Home</SidebarLink>
<SidebarLink href="#projects" icon={<Svg icon={folder} />} current>Projects</SidebarLink>
<SidebarLink href="#team" icon={<Svg icon={users} />}>Team</SidebarLink>
</SidebarGroup>
<SidebarGroup label="Account">
<SidebarLink href="#notifications" icon={<Svg icon={bell} />}>Notifications</SidebarLink>
<SidebarLink href="#settings" icon={<Svg icon={gear} />}>Settings</SidebarLink>
<SidebarLink href="#sign-out" icon={<Svg icon={signOut} />}>Sign out</SidebarLink>
</SidebarGroup>
</SidebarBody>
</SidebarRoot>
</div>
)
}
import { folder } from 'topclassui/icons/folder'
import { gear } from 'topclassui/icons/gear'
import { house } from 'topclassui/icons/house'
import { users } from 'topclassui/icons/users'
import { Svg } from 'topclassui/svg'
import { SidebarBody, SidebarGroup, SidebarHeader, SidebarLink, SidebarRoot } from 'topclassui/sidebar'
/** Collapsed from the start: the server sends the 60 px column of icons, so nothing moves
* when the page hydrates. Each label is hidden and still names its link. */
export default function Demo() {
return (
<div className="h-96">
<SidebarRoot defaultOpen={false}>
<SidebarHeader>Workspace</SidebarHeader>
<SidebarBody>
<SidebarGroup>
<SidebarLink href="#home" icon={<Svg icon={house} />} current>Home</SidebarLink>
<SidebarLink href="#projects" icon={<Svg icon={folder} />}>Projects</SidebarLink>
<SidebarLink href="#team" icon={<Svg icon={users} />}>Team</SidebarLink>
<SidebarLink href="#settings" icon={<Svg icon={gear} />}>Settings</SidebarLink>
</SidebarGroup>
</SidebarBody>
</SidebarRoot>
</div>
)
}
The rail asked to change 0 times.
'use client'
import { useState } from 'react'
import { folder } from 'topclassui/icons/folder'
import { house } from 'topclassui/icons/house'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { SidebarBody, SidebarGroup, SidebarHeader, SidebarLink, SidebarRoot } from 'topclassui/sidebar'
/** open and onOpenChange need a client file: a Server Component page cannot pass a function.
* Here the page holds the state, so a button outside the rail toggles it as its own does. */
export default function Demo() {
const [open, setOpen] = useState(true)
const [changes, setChanges] = useState(0)
return (
<div className="flex h-96 flex-wrap gap-4">
<SidebarRoot open={open} onOpenChange={(next) => { setOpen(next); setChanges((count) => count + 1) }}>
<SidebarHeader>Workspace</SidebarHeader>
<SidebarBody>
<SidebarGroup>
<SidebarLink href="#home" icon={<Svg icon={house} />} current>Home</SidebarLink>
<SidebarLink href="#projects" icon={<Svg icon={folder} />}>Projects</SidebarLink>
</SidebarGroup>
</SidebarBody>
</SidebarRoot>
<div className="grid min-w-0 grow basis-40 content-start gap-3">
<Button variant="outline" className="justify-self-start" onClick={() => { setOpen((current) => !current) }}>
{open ? 'Hide the labels' : 'Show the labels'}
</Button>
<p className="text-sm text-muted">The rail asked to change {changes} times.</p>
</div>
</div>
)
}
Orders
import { folder } from 'topclassui/icons/folder'
import { gear } from 'topclassui/icons/gear'
import { house } from 'topclassui/icons/house'
import { users } from 'topclassui/icons/users'
import { Svg } from 'topclassui/svg'
import { Table, type TableColumn } from 'topclassui/table'
import { SidebarBody, SidebarGroup, SidebarHeader, SidebarLink, SidebarRoot } from 'topclassui/sidebar'
interface Order { id: string; customer: string; placed: string; status: string; total: string }
const orders: Order[] = [
{ id: 'ORD-5521', customer: 'Northwind Traders', placed: '12 Mar 2026', status: 'Awaiting payment', total: '$1,250.00' },
{ id: 'ORD-5520', customer: 'Contoso Ltd', placed: '11 Mar 2026', status: 'Shipped', total: '$480.00' },
{ id: 'ORD-5519', customer: 'Fabrikam Inc', placed: '11 Mar 2026', status: 'Awaiting payment', total: '$9,120.50' },
]
const columns: TableColumn<Order>[] = [
{ key: 'id', header: 'Order', rowHeader: true, cell: (order) => order.id },
{ key: 'customer', header: 'Customer', cell: (order) => order.customer },
{ key: 'placed', header: 'Placed', cell: (order) => order.placed },
{ key: 'status', header: 'Status', cell: (order) => order.status },
{ key: 'total', header: 'Total', align: 'end', cell: (order) => order.total },
]
/** The app shell: a flex row as tall as the screen, the rail, then a column that takes the
* rest. The rail keeps its width; the column gives way, and its table scrolls inside it. */
export default function Demo() {
return (
<div className="flex h-[calc(100dvh-4rem)]">
<SidebarRoot>
<SidebarHeader>Workspace</SidebarHeader>
<SidebarBody>
<SidebarGroup>
<SidebarLink href="#home" icon={<Svg icon={house} />}>Home</SidebarLink>
<SidebarLink href="#orders" icon={<Svg icon={folder} />} current>Orders</SidebarLink>
<SidebarLink href="#team" icon={<Svg icon={users} />}>Team</SidebarLink>
<SidebarLink href="#settings" icon={<Svg icon={gear} />}>Settings</SidebarLink>
</SidebarGroup>
</SidebarBody>
</SidebarRoot>
<div className="min-w-0 flex-1 overflow-y-auto p-4">
<h2 className="pb-3 text-base font-semibold">Orders</h2>
<Table caption="Orders" items={orders} getKey={(order) => order.id} columns={columns} />
</div>
</div>
)
}
import { fileText } from 'topclassui/icons/file-text'
import { Svg } from 'topclassui/svg'
import { SidebarBody, SidebarGroup, SidebarHeader, SidebarLink, SidebarRoot } from 'topclassui/sidebar'
const PAGES = Array.from({ length: 60 }, (_, index) => `Page ${String(index + 1)}`)
/** Sixty links in a rail as tall as the screen: the list scrolls inside the body, under a
* header that stays put, and the page does not move. The parent gives the rail its height. */
export default function Demo() {
return (
<div className="h-[calc(100dvh-4rem)]">
<SidebarRoot>
<SidebarHeader>Handbook</SidebarHeader>
<SidebarBody>
<SidebarGroup label="Pages">
{PAGES.map((page, index) => (
<SidebarLink key={page} href={`#page-${String(index + 1)}`} icon={<Svg icon={fileText} />} current={index === 0}>
{page}
</SidebarLink>
))}
</SidebarGroup>
</SidebarBody>
</SidebarRoot>
</div>
)
}
import { folder } from 'topclassui/icons/folder'
import { gear } from 'topclassui/icons/gear'
import { house } from 'topclassui/icons/house'
import { users } from 'topclassui/icons/users'
import { TopClassProvider } from 'topclassui/provider'
import { Svg } from 'topclassui/svg'
import { SidebarBody, SidebarGroup, SidebarHeader, SidebarLink, SidebarRoot } from 'topclassui/sidebar'
/** Arabic, inside TopClassProvider as the root layout would mount it. The rail's line is on
* its left, the toggle at its right with its icon mirrored, and both are named in Arabic. */
export default function Demo() {
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar" className="h-96">
<SidebarRoot>
<SidebarHeader>مساحة العمل</SidebarHeader>
<SidebarBody>
<SidebarGroup label="التنقل">
<SidebarLink href="#home" icon={<Svg icon={house} />} current>الرئيسية</SidebarLink>
<SidebarLink href="#projects" icon={<Svg icon={folder} />}>المشاريع</SidebarLink>
<SidebarLink href="#team" icon={<Svg icon={users} />}>الفريق</SidebarLink>
<SidebarLink href="#settings" icon={<Svg icon={gear} />}>الإعدادات</SidebarLink>
</SidebarGroup>
</SidebarBody>
</SidebarRoot>
</div>
</TopClassProvider>
)
}
Props
SidebarRoot
SidebarTrigger
No props of its own: it takes the attributes of the element it renders.
SidebarHeader
SidebarBody
SidebarGroup
SidebarLink
Keyboard and accessibility
- The toggle is a real button with
aria-expanded, named by what a press will do: "Collapse the sidebar" or "Expand the sidebar". currenton oneSidebarLinksetsaria-current="page"and draws the row filled, bold and with a bar at its start.- Collapsed, the title, the label of each group and the label of each link are hidden on screen, and a screen reader still reads them.
Right to left
Under dir="rtl" the rail's line is on its left and the toggle on its right, with its icon mirrored. TopClassProvider with locale="ar" names both in Arabic.
Server and client
Renders on the server; the interactive part is a client component that ships only when it is used.
Files: index.tsx, client.tsx.
On the chip: split - size not measured yet.