Sidebar
Resizable workspace navigation with collapsible sections and a modal drawer on small screens.
Installation
Import from the Reflow workspace package.
Playground
Open demoimport { Sidebar, WorkspaceMenu, UserMenu } from '@rtzll/reflow'
import { type Handle } from 'remix/component'
export function Example(handle: Handle) {
const sections = [{ label: "Workspace", collapsible: true, actions: sectionActions,
items: [{ label: "Recent thread", href: "#work", actions: rowActions, actionCount: 1,
}]
}]
return () => (
<>
<Sidebar variant="inset" searchable={true} defaultCollapsed={false} peek="none" sections={sections}
header={<div style={{ display: "flex", flexDirection: "column" }}><WorkspaceMenu />{headerActions}</div>}
footer={<div style={{ display: "flex", flexDirection: "row" }}>{footerActions}<UserMenu /></div>}>
<div />
</Sidebar>
</>
)
}Layouts
3 layout options: default, floating to elevate the sidebar in a higher surface, inset to make your content stand out.
import { Sidebar, WorkspaceMenu, UserMenu } from '@rtzll/reflow'
import { type Handle } from 'remix/component'
export function Example(handle: Handle) {
const sections = [{ label: 'Workspace', items: [
{ label: 'Inbox', href: '#inbox', badge: '3' },
{ label: 'Projects', href: '#projects' },
] }]
return () => (
<>
<Sidebar variant="floating" sections={sections} header={<WorkspaceMenu />} footer={<UserMenu />} searchable>
Workspace content
</Sidebar>
</>
)
}Nesting
2 levels of nesting, one section level and one parent level. A row without an href folds its children.
import { Sidebar } from '@rtzll/reflow'
import { type Handle } from 'remix/component'
export function Example(handle: Handle) {
const sections = [{ label: 'Workspace', items: [
{ label: 'Inbox', href: '#inbox', badge: '3' },
{ label: 'Projects', href: '#projects' },
] }]
return () => (
<>
<Sidebar sections={[{ label: 'Recent', items: [
{ label: 'Today', defaultExpanded: true, children: [
{ label: 'API guidelines', href: '#api-guidelines' },
] },
]}]}>Workspace content</Sidebar>
</>
)
}Actions & badges
Status dots on threads, counts, and a hover-revealed action on every row.
import { Sidebar, Dropdown } from '@rtzll/reflow'
import { type Handle } from 'remix/component'
export function Example(handle: Handle) {
const items = ['Apple', 'Banana', 'Cherry', 'Mango'].map((label) => ({ value: label.toLowerCase(), label }))
const sections = [{ label: 'Workspace', items: [
{ label: 'Inbox', href: '#inbox', badge: '3' },
{ label: 'Projects', href: '#projects' },
] }]
return () => (
<>
<Sidebar sections={[{ label: 'Threads', items: [
{ label: 'Summarise the Q3 board deck', href: '#q3', status: 'active', badge: '12', actions: <Dropdown label="Actions" variant="ghost" items={items} /> },
]}]}>Workspace content</Sidebar>
</>
)
}Callouts
import { Sidebar, Banner, BannerTitle } from '@rtzll/reflow'
import { type Handle } from 'remix/component'
export function Example(handle: Handle) {
const sections = [{ label: 'Workspace', items: [
{ label: 'Inbox', href: '#inbox', badge: '3' },
{ label: 'Projects', href: '#projects' },
] }]
return () => (
<>
<Sidebar sections={sections} callout={<Banner><BannerTitle>Aurora 2 is here</BannerTitle></Banner>}>
Workspace content
</Sidebar>
</>
)
}Resize, collapse & peek
Drag the edge to resize, click it or press [ to collapse. Reveal the collapsed rail on hover or click.
import { Sidebar } from '@rtzll/reflow'
import { type Handle } from 'remix/component'
export function Example(handle: Handle) {
const sections = [{ label: 'Workspace', items: [
{ label: 'Inbox', href: '#inbox', badge: '3' },
{ label: 'Projects', href: '#projects' },
] }]
return () => (
<>
<Sidebar variant="inset" peek="hover" sections={sections} defaultWidth={256} minWidth={160} maxWidth={360}>
Drag the edge to resize, click it to collapse.
</Sidebar>
</>
)
}Inside a dialog
import { Dialog, Sidebar } from '@rtzll/reflow'
import { type Handle } from 'remix/component'
export function Example(handle: Handle) {
const sections = [{ label: 'Workspace', items: [
{ label: 'Inbox', href: '#inbox', badge: '3' },
{ label: 'Projects', href: '#projects' },
] }]
return () => (
<>
<Dialog size="xl" title="Settings" trigger="Open settings" flush>
<Sidebar fixed defaultWidth={208} sections={sections}>Settings panel</Sidebar>
</Dialog>
</>
)
}No icon rail version?
Collapsed navigation reveals full labels and nesting on demand. The icon-only collapsed mode is intentionally unsupported.
import { Sidebar } from '@rtzll/reflow'
import { type Handle } from 'remix/component'
export function Example(handle: Handle) {
const sections = [{ label: 'Workspace', items: [
{ label: 'Inbox', href: '#inbox', badge: '3' },
{ label: 'Projects', href: '#projects' },
] }]
return () => (
<>
<Sidebar peek="hover" sections={sections}>Content</Sidebar>
</>
)
}Functional and perfectly aligned
Guides are measured from the rendered header, footer, and action icons.
import { Sidebar, HeaderActions, FooterActions } from '@rtzll/reflow'
import { type Handle } from 'remix/component'
export function Example(handle: Handle) {
const sections = [{ label: 'Workspace', items: [
{ label: 'Inbox', href: '#inbox', badge: '3' },
{ label: 'Projects', href: '#projects' },
] }]
return () => (
<>
<Sidebar header={<HeaderActions />} footer={<FooterActions />} sections={sections}>Content</Sidebar>
</>
)
}Workspace composition
Switch workspaces, navigate nested projects, create a project, and open profile settings. The preview keeps its own local state.
import { on, type Handle } from 'remix/component'
import { Badge, Banner, BannerAction, BannerActions, BannerTitle, Button, Card, CardDescription, CardTitle, Dialog, Dropdown, Field, Input, Sidebar, Switch, type SidebarItem } from '@rtzll/reflow'
export function WorkspaceSidebar(handle: Handle) {
let workspace = 'Acme Inc'
let active = '#inbox'
let projects = [
{ id: 'design', label: 'Design system' },
{ id: 'website', label: 'Website' },
{ id: 'brand', label: 'Brand' },
]
let nextId = 1
let projectName = ''
let profileOpen = false
let workspaceName = 'Alex Morgan'
let upgradeOpen = false
let plan = 'Free'
const navigate = (event: MouseEvent) => {
if (!(event.currentTarget instanceof HTMLAnchorElement)) return
event.preventDefault()
active = event.currentTarget.hash
handle.update()
}
const icon = (path: string) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
aria-hidden="true"
>
<path d={path} />
</svg>
)
return () => {
const projectItems: SidebarItem[] = projects.map((project) => ({
label: project.label,
href: `#${project.id}`,
onClick: navigate,
actions: (
<Dropdown
label="⋯"
variant="ghost"
items={[{ value: 'archive', label: 'Archive project' }]}
onSelect={() => {
projects = projects.filter((item) => item.id !== project.id)
if (active === `#${project.id}`) active = '#projects'
handle.update()
}}
/>
),
}))
const title =
projects.find((project) => `#${project.id}` === active)?.label ??
{ '#inbox': 'Inbox', '#projects': 'Projects', '#settings': 'Settings' }[
active
] ??
'Workspace'
return (
<div class="doc-workspace-shell">
<Sidebar
variant="inset"
label="Workspace preview"
defaultWidth={204}
minWidth={160}
maxWidth={280}
toolbar={
<>
<span>{workspace} / {title}</span>
<Badge color={plan === 'Pro' ? 'violet' : 'gray'}>{plan}</Badge>
</>
}
searchable
activeHref={active}
header={
<Dropdown
label={workspace}
variant="ghost"
items={['Acme Inc', 'Design Studio'].map((label) => ({
value: label,
label,
}))}
onSelect={(value) => {
workspace = value
handle.update()
}}
/>
}
footer={
<Dropdown
label={workspaceName}
variant="ghost"
items={[
{ value: 'profile', label: 'Profile settings' },
{ value: 'plan', label: 'Workspace plan' },
]}
onSelect={(value) => {
profileOpen = value === 'profile'
upgradeOpen = value === 'plan'
handle.update()
}}
/>
}
callout={
<Banner>
<BannerTitle>
{plan === 'Free'
? 'Make room for more'
: 'Your workspace is on Pro'}
</BannerTitle>
<BannerActions>
<BannerAction
variant="ghost"
mix={on('click', () => {
upgradeOpen = true
handle.update()
})}
>
View plans
</BannerAction>
</BannerActions>
</Banner>
}
sections={[
{
label: 'Workspace',
items: [
{
label: 'Inbox',
href: '#inbox',
badge: '3',
icon: icon('M3 4h18v16H3zM3 14h5l2 3h4l2-3h5'),
onClick: navigate,
},
{
label: 'Projects',
href: '#projects',
icon: icon('M3 6h7l2 2h9v12H3z'),
onClick: navigate,
children: projectItems,
defaultExpanded: true,
},
{
label: 'Settings',
href: '#settings',
icon: icon('M4 6h16M4 12h16M4 18h16'),
onClick: navigate,
},
],
},
]}
>
<div class="doc-workspace-content">
<h3>{title}</h3>
<Dialog
title="New project"
trigger="New project"
onOpenChange={(open) => {
if (open) {
projectName = ''
handle.update()
}
}}
footer={(close) => (
<>
<Button variant="ghost" mix={on('click', close)}>
Cancel
</Button>
<Button
disabled={!projectName.trim()}
mix={on('click', () => {
const project = {
id: `new-${nextId++}`,
label: projectName.trim(),
}
projects = [...projects, project]
active = `#${project.id}`
close()
handle.update()
})}
>
Create project
</Button>
</>
)}
>
<Field label="Project name">
{(props) => (
<Input
{...props}
value={projectName}
placeholder="New project name"
mix={on<HTMLInputElement, 'input'>('input', (event) => {
projectName = event.currentTarget.value
handle.update()
})}
/>
)}
</Field>
</Dialog>
<p>
{active === '#inbox'
? 'Your team has shared three updates.'
: active === '#projects'
? `${projects.length} projects in this workspace.`
: `You are viewing ${title}.`}
</p>
</div>
</Sidebar>
<Dialog
title="Profile settings"
open={profileOpen}
onOpenChange={(value) => {
profileOpen = value
handle.update()
}}
footer={(close) => <Button mix={on('click', close)}>Done</Button>}
>
<Field label="Display name">
{(props) => (
<Input
{...props}
value={workspaceName}
mix={on<HTMLInputElement, 'input'>('input', (event) => {
workspaceName = event.currentTarget.value
handle.update()
})}
/>
)}
</Field>
<Switch label="Email notifications" defaultChecked />
</Dialog>
<Dialog
title="Workspace plan"
description="Choose a plan for this local preview."
open={upgradeOpen}
onOpenChange={(value) => {
upgradeOpen = value
handle.update()
}}
footer={(close) => <Button mix={on('click', close)}>Done</Button>}
>
<div class="doc-demo-row">
{['Free', 'Pro'].map((value) => (
<Card
key={value}
selected={plan === value}
label={value}
onClick={() => {
plan = value
handle.update()
}}
>
<CardTitle>{value}</CardTitle>
<CardDescription>
{value === 'Free'
? 'For personal projects'
: 'For growing teams'}
</CardDescription>
</Card>
))}
</div>
</Dialog>
</div>
)
}
}API Reference
Public props for the Reflow implementation. Native controls also accept their standard HTML attributes and Remix event mixes.
Sidebar
| Prop | Type | Default | Description |
|---|---|---|---|
sections* | readonly SidebarSection[] | — | Navigation groups and their items. |
activeHref | string | — | URL of the active navigation item. |
header | RemixNode | — | Content above the navigation. |
footer | RemixNode | — | Footer content or actions. |
toolbar | RemixNode | — | Breadcrumbs or actions alongside the built-in navigation button. |
children* | RemixNode | — | Content rendered inside the component. |
label | string | Workspace | Accessible name or visible label for the control. |
variant | "default" | "floating" | "inset" | — | Visual or layout treatment. |
defaultWidth | number | 220 | Initial sidebar width in pixels. |
minWidth | number | 160 | Minimum sidebar width in pixels. |
maxWidth | number | 380 | Maximum sidebar width in pixels. |
defaultCollapsed | boolean | — | Starts with the navigation collapsed. |
peek | "none" | "hover" | "click" | — | Temporarily reveals the collapsed rail from its edge. Escape/outside press dismiss; Pin sidebar expands it. |
fixed | boolean | — | Renders a fixed rail without resizing or collapsing controls. |
searchable | boolean | — | Enables filtering. |
onWidthChange | ((width: number) => void) | — | Called after the sidebar is resized. |
callout | RemixNode | — | Additional content below sidebar navigation. |
SidebarSection
| Prop | Type | Default | Description |
|---|---|---|---|
collapsible | boolean | — | Set false for a static section heading. |
label | string | — | Heading for this navigation group. |
items* | readonly SidebarItem[] | — | Items and their content. |
defaultCollapsed | boolean | — | Starts the navigation group collapsed. |
actions | RemixNode | — | Actions displayed beside the message or composer. |
SidebarItem
| Prop | Type | Default | Description |
|---|---|---|---|
label* | string | — | Accessible name or visible label for the control. |
href* | string | — | Destination of the navigation link. |
badge | RemixNode | — | Small status or count displayed with a navigation item. |
icon | RemixNode | — | Icon content. |
onClick | ((event: MouseEvent) => void) | — | Optional click handler for application navigation. |
children | readonly SidebarItem[] | — | Nested navigation items. |
defaultExpanded | boolean | — | Initial expansion state of the nested navigation. |
actions | RemixNode | — | Actions displayed beside the message or composer. |