diff --git a/src/index.ts b/src/index.ts
index a2747233..3a95ae24 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -41,6 +41,7 @@ export * from './badge'
export * from './expansion-panel'
export * from './menu'
export * from './modal'
+export * from './sidebar'
export * from './tabs'
export * from './tooltip'
diff --git a/src/sidebar/index.ts b/src/sidebar/index.ts
new file mode 100644
index 00000000..28a1f2af
--- /dev/null
+++ b/src/sidebar/index.ts
@@ -0,0 +1 @@
+export * from './sidebar'
diff --git a/src/sidebar/sidebar.mdx b/src/sidebar/sidebar.mdx
new file mode 100644
index 00000000..f7a6a87b
--- /dev/null
+++ b/src/sidebar/sidebar.mdx
@@ -0,0 +1,353 @@
+import {
+ ArgTypes,
+ Canvas,
+ Controls,
+ Description,
+ Markdown,
+ Meta,
+ Subtitle,
+ Title,
+} from '@storybook/addon-docs/blocks'
+
+import { Sidebar, SidebarContent, SidebarPersistentContent, SidebarResizeHandle } from './sidebar'
+import * as SidebarStories from './sidebar.stories'
+
+
+
+
+
+A controlled, composable, and accessible sidebar primitive
+
+The `Sidebar` component is a resizable panel that supports being docked on the left or right, or rendered as an overlay. It's fully controlled, so the
+consumer owns the breakpoints and the state of the panel.
+
+## Basic usage
+
+To define a sidebar, render it in a flex parent. Use `align` to determine the side it's docked, `isOpen` to control its open state, and `width` for its default size.
+
+Use `` to host your styled panel, and a `` as its child to hold content that need to remain visible and interactive when the panel is closed.
+
+
+
+## Anatomy
+
+A sidebar is comprised of the following components:
+
+{`
+| Component | Renders | Responsibilities |
+| --- | --- | --- |
+| \`\\` | The main provider and the modal backdrop | Configuration options like \`width\`, \`resizeStep\`, \`overlayMode\`, and events like \`onDismiss\` |
+| \`\\` | The panel element, with \`role="dialog"\` when needed | The panel's size and position, transitions, dialog and modal behaviour |
+| \`\\` | An optional slot inside the panel | Hosts UI elements within the sidebar that need to be interactive while the sidebar is closed (e.g. a collapse toggle) |
+| \`\\` | An element with \`role="separator"\` | Enables pointer and keyboard resize |
+`}
+
+Place the `` in a flex parent, provide it the controlled `width` and `isOpen` props, and ensure that its sibling is able to absorb and yield space when the sidebar resizes.
+
+- **Parent**: When using a `Box`, assign `display="flex"`
+- **Sibling content**: Assign `flexGrow={1}`, and `minWidth={0}`, as `` already defaults to `flex-shrink: 1`
+- **State and persistence**: The open and overlay states are controlled, and persisted if required by the consumer
+- **The visual skin**: The component is headless, and only controls the container's width and transition, which can be further customized. The panel is styled through the children passed into ``
+
+```tsx
+const isOverlay = viewportWidth < maxNavWidth + minContentWidth
+
+
+
+
+
+ {/* nav content */}
+
+
+
+
+
+ {/* main content */}
+
+
+```
+
+## Props
+
+### ``
+
+
+
+
+
+### ``
+
+
+
+
+
+### ``
+
+
+
+### ``
+
+
+
+
+
+## Resizing
+
+Render a `SidebarResizeHandle` to make the panel resizable. The handle sits on
+the inner edge (right for `align="start"`, left for `align="end"`), drives a
+render-free pointer drag, and commits the width through `onWidthChange` on
+pointer up. Width is controlled by the consumer.
+
+A resizable sidebar needs `width`, `minWidth`, and `maxWidth` on `Sidebar` (with
+`minWidth` below `maxWidth`), plus `resizeStep` for keyboard steps and
+`defaultWidth` for the double-click reset. Without a usable range the handle
+renders and focuses but cannot move, and logs a development warning.
+
+
+
+Keyboard support, from the separator:
+
+- **Arrow keys** resize by `resizeStep`, edge-aware: with `align="start"` the
+ handle is on the right, so ArrowRight grows and ArrowLeft shrinks; `align="end"`
+ reverses it. Omitting `resizeStep` disables arrow stepping (Home/End still
+ work).
+- **Home / End** jump to `minWidth` and `maxWidth`, also edge-aware: `align="end"`
+ swaps them, so Home jumps to `maxWidth`.
+- **Double-click** resets to `defaultWidth`.
+- There is no Enter / Space and no PageUp / PageDown: it is a separator, not a
+ button. Escape-to-dismiss belongs to the panel, not the handle.
+
+Resize direction is not RTL-aware. The layout mirrors under `dir="rtl"` (anchor
+edge, off-edge slide, and handle edge), but the pointer and keyboard resize
+direction assumes LTR, so a drag or arrow key resizes the opposite way. RTL is not
+officially supported; treat this as a known limitation.
+
+`onWidthChange` fires on pointer up, on each keystroke during keyboard resize, and
+on a double-click reset, so debounce persistence if it is expensive. A drag that
+does not move the handle does not fire it. A consumer that ignores it keeps the
+dragged preview width on screen (the drag writes it imperatively;
+nothing overrides it until `width` changes). `aria-valuenow` and `aria-valuetext`
+update on each keystroke and on commit after a drag (not mid-drag). Pass
+`aria-valuetext` to localize the announced width; it defaults to `"{width}px"`.
+
+## Overlays
+
+Two props describe an overlay. `isOverlay` is dynamic: the consumer computes it
+from a breakpoint, and `false` keeps the panel docked in flow. `overlayMode` is
+static: it sets what the overlay is while floating.
+
+{`
+| \`overlayMode\` | role=dialog | aria-modal | focus trap | Background | Backdrop |
+| --- | --- | --- | --- | --- | --- |
+| \`plain\` (default) | – | – | – | interactive | none |
+| \`dialog\` | yes | – | – | interactive | none |
+| \`modal\` | yes | yes | yes | inert / AT-hidden (auto) | auto-rendered |
+`}
+
+The backdrop is not a component: `Sidebar` renders it automatically for
+`overlayMode="modal"`, dims and blocks the page, and dismisses on click. A
+non-modal overlay closes through its own control plus Escape; it renders no
+backdrop and leaves the background interactive.
+
+### Element and role
+
+`SidebarContent` takes the `dialog` role when it is an open overlay and
+`overlayMode` is `dialog` or `modal`, otherwise it has no landmark role of its
+own. Nest your landmark element as a child and name it there:
+
+{`
+| Intent | Landmark child |
+| --- | --- |
+| Top-level sidebar (peer of \`main\`) | \`
+
+Name the dialog on `SidebarContent`: prefer `aria-labelledby` pointing at a
+visible heading in the panel when there is one, or `aria-label` otherwise.
+
+### Modal drawer
+
+The full modal overlay from the table above; because it inerts the background
+itself, the shell no longer marks `main`.
+
+
+
+### Non-modal dialog side pane
+
+An end-aligned contextual pane that floats but leaves the background interactive
+(no backdrop). It closes through its own control plus Escape. The rounded card
+skin is a child with `overflow: hidden`, so the resize handle on the panel edge
+stays outside the clip. The card is inset from the edges with the overlay inset
+custom properties.
+
+A nonzero `--reactist-sidebar-overlay-inset-inline` stays on screen as a sliver
+while the pane is closed: the panel slides off by its own width from the inset
+anchor, leaving the inset gap visible (toggle the demo closed to see it). Keep the
+inline inset at `0` for a fully off-screen close.
+
+
+
+### One modal overlay at a time
+
+Keep only one `overlayMode="modal"` sidebar open at once. A modal overlay inerts
+everything outside its own panel and lays a backdrop over the page, so two open
+together inert each other's panels and stack backdrops, leaving neither usable.
+`Sidebar` has no cross-sidebar coordinator and the consumer owns `isOpen`, so
+holding to a single open modal is the consumer's responsibility.
+
+The same holds across breakpoint changes: default a modal sidebar to closed when
+it enters overlay mode rather than carrying an open state across the breakpoint
+(the app navs already behave this way). Derive the open state from the layout, so
+a docked panel is always shown and only the overlay obeys the toggle, as the
+multi-sidebar example below does:
+
+```tsx
+// Docked: always in view. Overlay: toggled by the trigger (closed until opened).
+const open = isOverlay ? isOpen : true
+```
+
+## The toggle trigger
+
+Reactist ships no trigger. The toggle is the consumer's own button, wired by
+hand, so it can live anywhere (a top bar, a command palette) without hoisting the
+provider. Set the same `id` on `Sidebar` that the trigger points its
+`aria-controls` at.
+
+```tsx
+
+```
+
+## A control that stays live while closed
+
+While a sidebar is closed, its contents are made `inert`, so off-screen controls
+leave the tab order and the accessibility tree. `SidebarPersistentContent` is the
+exception: a control placed in it stays usable while the sidebar is closed. The
+collapse toggle in the demo at the top of this page is the canonical case: it
+peeks out of the slid-off panel and reopens it. Unlike the external trigger above,
+this is for a toggle that belongs _in_ the panel.
+
+Its children render inside the panel, so they ride the collapse transition and
+join the focus trap when modal, but outside the closed-state `inert`. It works at
+any nesting depth, so a toggle already living deep in the content does not need
+hoisting.
+
+Do not pair it with `unmountOnHide` on ``: that unmounts the whole panel
+(this control included) at the end of the close transition, so nothing is left to
+stay operable while closed. The two contradict each other, and the component
+warns when both are set.
+
+```tsx
+
+
+
+
+
+
+```
+
+When the panel opens as a modal, the trap's initial focus goes to the first
+focusable, often the persistent control. Whether that is wanted is the consumer's
+call: add `data-no-autofocus` to your control (a react-focus-lock attribute) to
+keep a toggle from grabbing it, or `data-autofocus` to send initial focus to a
+specific element; the control stays tabbable either way. `data-no-autofocus` is
+subtree-scoped, so setting it on a wrapping element opts the whole panel out of
+autofocus (focus stays on the trigger). The consumer owns the control, its
+`aria-*`, and its positioning (e.g. a peek offset while collapsed).
+
+## Multiple sidebars
+
+Several sidebars compose in one flex shell. Tile them on the same side (a
+workspace rail beside its nav) or place them on both edges (`align="start"` and
+`align="end"` are the same component mirrored). Each is a `flex-shrink: 0` child
+and the main content absorbs the rest, so every pane resizes from its inner edge
+independently.
+
+Responsiveness is per sidebar: the consumer computes each panel's `isOverlay` from
+its own breakpoint (a container query or a `ResizeObserver`), so a layout can drop
+its panels to overlays at different widths as the viewport narrows. The demo docks
+a workspace rail, a main nav, and a right details pane, then flips the details
+pane to an overlay first (it needs the most room) and the nav next; the rail stays
+docked. Resize the canvas to cross the breakpoints.
+
+
+
+## Playground
+
+Switch alignment, overlay mode, and resizability live, and drag or keyboard-resize
+the panel. The controls below map to every `` prop.
+
+
+
+### API
+
+
+
+## Custom properties
+
+The component ships sensible defaults and exposes per-instance theming through
+CSS custom properties, set on `SidebarContent` (or any ancestor).
+
+{`
+| Property | Default | Purpose |
+| --- | --- | --- |
+| \`--reactist-sidebar-overlay-z-index\` | \`40\` | Layering of a floating panel |
+| \`--reactist-sidebar-collapsed-z-index\` | \`1\` | Layering of a collapsed docked panel, so a persistent control paints above the main content |
+| \`--reactist-sidebar-overlay-min-viewport-gap\` | \`0px\` | Gap kept from the viewport edge by the overlay width cap |
+| \`--reactist-sidebar-overlay-inset-block\` | \`0px\` | Top and bottom inset of a floating panel (shorthand) |
+| \`--reactist-sidebar-overlay-inset-block-start\` | \`-inset-block\` | Top inset alone, e.g. below a title bar; unset, it falls back to the \`-inset-block\` shorthand |
+| \`--reactist-sidebar-overlay-inset-block-end\` | \`-inset-block\` | Bottom inset alone; unset, it falls back to the \`-inset-block\` shorthand |
+| \`--reactist-sidebar-overlay-inset-inline\` | \`0px\` | Inset of the anchored edge; also subtracted from the overlay width cap |
+| \`--reactist-sidebar-backdrop-color\` | \`#000000\` | Modal backdrop color |
+| \`--reactist-sidebar-backdrop-opacity\` | \`0.5\` | Modal backdrop opacity |
+| \`--reactist-sidebar-backdrop-z-index\` | \`39\` | Modal backdrop layering |
+| \`--reactist-sidebar-resize-handle-width\` | \`4px\` | Handle hit-area width |
+| \`--reactist-sidebar-resize-handle-idle-fill\` | \`transparent\` | Handle color at rest |
+| \`--reactist-sidebar-resize-handle-hover-fill\` | divider | Handle color on hover |
+| \`--reactist-sidebar-resize-handle-focus-fill\` | primary | Handle color on focus |
+| \`--reactist-sidebar-transition-duration\` | \`300ms\` | Panel slide and backdrop fade duration (shared) |
+| \`--reactist-sidebar-transition-easing\` | \`cubic-bezier(0.4, 0, 0.2, 1)\` | Panel slide and backdrop fade easing (shared) |
+`}
+
+## Data attributes
+
+The component sets these `data-*` attributes as part of the public contract, so
+you can style or target them in tests.
+
+{`
+| Element | Attribute | Values |
+| --- | --- | --- |
+| Panel (\`SidebarContent\`) | \`data-state\` | \`open\` / \`closed\` |
+| Panel | \`data-align\` | \`start\` / \`end\` |
+| Panel | \`data-overlay\` | \`true\` / \`false\` |
+| Resize handle | \`data-align\` | \`start\` / \`end\` |
+| Backdrop | \`data-state\` | \`open\` / \`closed\` |
+| Backdrop | \`data-testid\` | \`sidebar-backdrop\` |
+`}
+
+The panel and handle also forward any `data-*` you pass; the handle's own
+`data-align` is applied last and wins.
+
+## Accessibility
+
+A checklist; each item links to the section with the full treatment.
+
+- A floating `dialog` / `modal` panel becomes a `dialog`; name it with
+ `aria-label` / `aria-labelledby` on `SidebarContent` (see
+ [Element and role](#element-and-role)).
+- The panel is a neutral container; its content must define the landmark.
+- A `modal` overlay traps focus, sets `aria-modal`, returns focus to the trigger
+ on close, and inerts everything outside the panel and backdrop (see
+ [Modal drawer](#modal-drawer)).
+- Escape dismisses an open overlay when `dismissOverlayOnEscape` is set, only
+ while focus is in the panel, and respects `event.defaultPrevented` (see
+ [Overlays](#overlays)).
+- The resize handle is a focusable `separator` while open and leaves the tab
+ order and accessibility tree while closed (see [Resizing](#resizing)).
+- Closed contents are `inert`, except a control in
+ [`SidebarPersistentContent`](#a-control-that-stays-live-while-closed); when the
+ panel opens as a modal, add `data-no-autofocus` to keep initial focus off it.
diff --git a/src/sidebar/sidebar.module.css b/src/sidebar/sidebar.module.css
new file mode 100644
index 00000000..6d64beb9
--- /dev/null
+++ b/src/sidebar/sidebar.module.css
@@ -0,0 +1,200 @@
+:root {
+ /* Overlay layering and viewport behaviour. */
+ --reactist-sidebar-overlay-z-index: 40;
+ --reactist-sidebar-collapsed-z-index: 1;
+ --reactist-sidebar-overlay-min-viewport-gap: 0px;
+ --reactist-sidebar-overlay-inset-block: 0px;
+ /*
+ * `-block` is the shorthand; the per-edge `-block-start`/`-block-end`
+ * longhands are read at the point of use below and fall back to it, so a
+ * consumer can inset a single edge (e.g. below a top title bar) or both via
+ * the shorthand. The longhands are deliberately not declared here: resolving
+ * their fallback at :root would bake in the shorthand's :root value and a
+ * consumer override set lower (on the panel or an ancestor) would not take.
+ * Inline stays a single `-inline`: the overlay anchors to one inline edge and
+ * is width-sized, so only that edge is a positional inset.
+ */
+ --reactist-sidebar-overlay-inset-inline: 0px;
+
+ /* Modal backdrop. */
+ --reactist-sidebar-backdrop-color: #000000;
+ --reactist-sidebar-backdrop-opacity: 0.5;
+ --reactist-sidebar-backdrop-z-index: 39;
+
+ /* Resize handle. `fill` is the line colour; the state prefix selects idle / hover / focus. */
+ --reactist-sidebar-resize-handle-width: 4px;
+ --reactist-sidebar-resize-handle-idle-fill: transparent;
+ --reactist-sidebar-resize-handle-hover-fill: var(--reactist-divider-primary);
+ --reactist-sidebar-resize-handle-focus-fill: var(--reactist-actionable-primary-idle-fill);
+
+ --reactist-sidebar-transition-duration: 300ms;
+ --reactist-sidebar-transition-easing: cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.panel {
+ position: relative;
+ box-sizing: border-box;
+ width: var(--reactist-sidebar-width);
+ /*
+ * `contain: layout` bounds the collapse reflow without `paint`, so consumer
+ * popovers, focus rings, the resize handle, and an overlay card's shadow are
+ * never clipped to the panel box.
+ */
+ contain: layout;
+}
+
+/*
+ * Docked: the panel is an in-flow flex child that holds its width (`flex-shrink`
+ * comes from the Box prop). Collapsing slides it out with a negative inline
+ * margin so the main absorber reflows into the freed space.
+ */
+.panel[data-overlay='false'] {
+ height: 100%;
+ transition: margin var(--reactist-sidebar-transition-duration)
+ var(--reactist-sidebar-transition-easing);
+}
+
+.panel[data-overlay='false'][data-state='closed'][data-align='start'] {
+ margin-inline-start: calc(-1 * var(--reactist-sidebar-width, 0px));
+}
+
+.panel[data-overlay='false'][data-state='closed'][data-align='end'] {
+ margin-inline-end: calc(-1 * var(--reactist-sidebar-width, 0px));
+}
+
+/*
+ * Collapsed + docked, the panel's own `contain: layout` traps a persistent
+ * control's z-index in the panel's stacking context, so it paints under a main
+ * that is itself a stacking context. Lift the panel to keep the control visible.
+ */
+.panel[data-overlay='false'][data-state='closed'] {
+ z-index: var(--reactist-sidebar-collapsed-z-index);
+}
+
+/*
+ * Overlay: the panel floats over the content layer, anchored to its align edge
+ * and capped to the viewport. It slides off-edge with a compositor-only
+ * transform. Box geometry (insets, rounding, skin) is the consumer's; the inset
+ * custom properties let them inset a floating card without a specificity fight.
+ */
+.panel[data-overlay='true'] {
+ position: fixed;
+ inset-block-start: var(
+ --reactist-sidebar-overlay-inset-block-start,
+ var(--reactist-sidebar-overlay-inset-block)
+ );
+ inset-block-end: var(
+ --reactist-sidebar-overlay-inset-block-end,
+ var(--reactist-sidebar-overlay-inset-block)
+ );
+ z-index: var(--reactist-sidebar-overlay-z-index);
+ max-width: calc(
+ 100vw - var(--reactist-sidebar-overlay-inset-inline) -
+ var(--reactist-sidebar-overlay-min-viewport-gap)
+ );
+ transition: transform var(--reactist-sidebar-transition-duration)
+ var(--reactist-sidebar-transition-easing);
+}
+
+.panel[data-overlay='true'][data-align='start'] {
+ inset-inline-start: var(--reactist-sidebar-overlay-inset-inline);
+}
+
+.panel[data-overlay='true'][data-align='end'] {
+ inset-inline-end: var(--reactist-sidebar-overlay-inset-inline);
+}
+
+.panel[data-overlay='true'][data-state='closed'][data-align='start'] {
+ transform: translateX(-100%);
+}
+
+.panel[data-overlay='true'][data-state='closed'][data-align='end'] {
+ transform: translateX(100%);
+}
+
+/*
+ * `translateX` is physical, so flip the off-edge slide under RTL to stay aligned
+ * with the logical `inset-inline` anchor above.
+ */
+[dir='rtl'] .panel[data-overlay='true'][data-state='closed'][data-align='start'] {
+ transform: translateX(100%);
+}
+
+[dir='rtl'] .panel[data-overlay='true'][data-state='closed'][data-align='end'] {
+ transform: translateX(-100%);
+}
+
+/*
+ * Layout-neutral wrapper for the focus trap: it must not establish a positioning
+ * or stacking context, so the absolutely-positioned resize handle still resolves
+ * against the panel.
+ */
+.focusLock {
+ display: contents;
+}
+
+.persistentContent,
+.inertContent {
+ display: contents;
+}
+
+.backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: var(--reactist-sidebar-backdrop-z-index);
+ background-color: var(--reactist-sidebar-backdrop-color);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity var(--reactist-sidebar-transition-duration)
+ var(--reactist-sidebar-transition-easing);
+}
+
+.backdrop[data-state='open'] {
+ opacity: var(--reactist-sidebar-backdrop-opacity);
+ pointer-events: auto;
+}
+
+.resizeHandle {
+ position: absolute;
+ inset-block: 0;
+ z-index: 1;
+ width: var(--reactist-sidebar-resize-handle-width);
+ background-color: var(--reactist-sidebar-resize-handle-idle-fill);
+ cursor: col-resize;
+ touch-action: none;
+ transition: background-color 120ms ease;
+}
+
+.resizeHandle[data-align='start'] {
+ inset-inline-end: 0;
+}
+
+.resizeHandle[data-align='end'] {
+ inset-inline-start: 0;
+}
+
+.resizeHandle:hover {
+ background-color: var(--reactist-sidebar-resize-handle-hover-fill);
+}
+
+.resizeHandle:focus-visible {
+ background-color: var(--reactist-sidebar-resize-handle-focus-fill);
+ outline: none;
+}
+
+/* The closed handle is `aria-hidden`; drop pointer events so it can't be grabbed. */
+.resizeHandle[aria-hidden='true'] {
+ pointer-events: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ :root {
+ --reactist-sidebar-transition-duration: 0s;
+ }
+
+ .panel,
+ .backdrop,
+ .resizeHandle {
+ transition: none !important;
+ }
+}
diff --git a/src/sidebar/sidebar.stories.tsx b/src/sidebar/sidebar.stories.tsx
new file mode 100644
index 00000000..a8b9f14d
--- /dev/null
+++ b/src/sidebar/sidebar.stories.tsx
@@ -0,0 +1,643 @@
+import * as React from 'react'
+
+import { Box, Button, Divider, Heading, IconButton, Stack, Text } from '../index'
+
+import { Sidebar, SidebarContent, SidebarPersistentContent, SidebarResizeHandle } from './sidebar'
+
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import type { SidebarAlign, SidebarOverlayMode } from './sidebar'
+
+const NAV_ITEMS = ['Inbox', 'Today', 'Upcoming', 'Filters & Labels', 'Projects', 'Team']
+
+const DETAIL_ITEMS = ['Description', 'Sub-tasks', 'Comments', 'Activity', 'Attachments']
+
+function DemoNav({
+ title = 'Workspace',
+ navItems = NAV_ITEMS,
+ as = 'nav',
+ 'aria-label': ariaLabel,
+ children,
+}: {
+ title?: string
+ navItems?: string[]
+ as?: React.ComponentProps['as']
+ 'aria-label'?: string
+ children?: React.ReactNode
+}) {
+ return (
+
+
+ {title}
+
+
+ {navItems.map((item) => {
+ return (
+
+ )
+ })}
+
+ {children}
+
+ )
+}
+
+function DemoRail({
+ 'aria-label': ariaLabel,
+ children,
+}: {
+ 'aria-label'?: string
+ children?: React.ReactNode
+}) {
+ return (
+
+
+ {['T', 'C', 'A'].map((label) => (
+
+
+ {label}
+
+
+ ))}
+
+ {children}
+
+ )
+}
+
+const CARD_INSET_OVERRIDES = {
+ '--reactist-sidebar-overlay-inset-block': '12px',
+ '--reactist-sidebar-overlay-inset-inline': '12px',
+} as React.CSSProperties
+
+function useShellWidth() {
+ const shellRef = React.useRef(null)
+ const [width, setWidth] = React.useState(Infinity)
+
+ React.useEffect(function observeShellWidth() {
+ const node = shellRef.current
+ if (!node) return
+
+ const observer = new ResizeObserver((entries) => {
+ const entry = entries[0]
+ if (entry) setWidth(entry.contentRect.width)
+ })
+ observer.observe(node)
+ return () => observer.disconnect()
+ }, [])
+
+ return [shellRef, width] as const
+}
+
+const meta = {
+ title: '🧭 Navigation & structure/Sidebar',
+ component: Sidebar,
+ parameters: {
+ badges: ['accessible'],
+ figma: {
+ path: 'Web › Components / Todoist › Sidebar › Main Navigation / Sidebar',
+ url: 'https://www.figma.com/design/LYlWNzvhMDh907l07mPPQk/Product-Library---Web?node-id=1194-17741',
+ },
+ docs: { source: { type: 'dynamic' } },
+ },
+ decorators: [
+ (Story: () => React.JSX.Element) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+
+type Story = StoryObj
+
+function SidebarToggleIcon() {
+ return (
+
+ )
+}
+
+SidebarToggleIcon.displayName = 'SidebarToggleIcon'
+
+/** Docked nav with its collapse toggle in ``, kept reachable while collapsed. */
+export const CollapsibleNav = {
+ render: function CollapsibleNav() {
+ const [isOpen, setIsOpen] = React.useState(true)
+
+ return (
+
+
+
+
+
+ )
+ const view = render(ui({}))
+ return {
+ ...view,
+ rerender: (overrides: Partial = {}) => view.rerender(ui(overrides)),
+ }
+}
+
+describe('when isOverlay is false', () => {
+ it('renders a docked panel as a neutral
wrapping the content', () => {
+ const width = 280
+ // `role` is omitted from the public type but we force it to prove the
+ // component owns the rendered role and a host role is ignored.
+ renderSidebar(
+ { align: 'end', id: 'app-sidebar', width },
+ {
+ contentProps: { role: 'banner', exceptionallySetClassName: 'app-skin' },
+ children: