SidePanel
A panel that slides in from the start edge after a button — navigation Links, filters, a cart — built on the APG disclosure pattern (a button with aria-expanded controlling a region) rather than a modal dialog, with a modal option for when the page must be inert. On wide screens optionally a persistent sidebar.
Basic usage
Install the package:
pnpm add @design-schema/react @design-schema/tokens Import the stylesheet once for the app, then the component:
import '@design-schema/tokens/calm-precise/css';
import '@design-schema/react/index.css';
import { SidePanel } from '@design-schema/react'; Examples
import { useEffect, useState, type ComponentProps, type ReactElement } from 'react';
import { SidePanel, Button, Icon, Link, Stack } from '@design-schema/react';
type SidePanelArgs = ComponentProps<typeof SidePanel>;
function StatefulSidePanel(args: SidePanelArgs): ReactElement {
const [open, setOpen] = useState<boolean | undefined>(args.open);
useEffect(() => setOpen(args.open), [args.open]);
if (open === undefined) return <SidePanel {...args} />;
return (
<SidePanel
{...args}
open={open}
onOpenChange={(next, reason) => {
setOpen(next);
args.onOpenChange?.(next, reason);
}}
/>
);
}
const menuTrigger = <Button variant="ghost" iconOnly label="Menu" leadingIcon={<Icon name="menu" inline />} />;
const navigationLinks = (
<Stack gap="tight">
<Link href="#dashboard" label="Dashboard" aria-current="page" />
<Link href="#projects" label="Projects" />
<Link href="#settings" label="Settings" />
</Stack>
);
<StatefulSidePanel trigger={menuTrigger} heading="Menu">{navigationLinks}</StatefulSidePanel>Props
14 rows| Prop | Type | Default | Description |
|---|---|---|---|
| trigger | content | — | The Button that shows and hides the panel (usually `iconOnly` with the `menu` Icon and a label like "Menu"). It is the APG disclosure button: the panel sets aria-expanded on it, and aria-controls whenever the element it names is in the DOM — a modal panel's <dialog> unmounts when closed, so the attribute is dropped then rather than pointing at nothing. It stays a toggle — pressing it again closes. Omit to control `open` from elsewhere (a Toolbar). On web and React Native it is exactly one element, because it is cloned to carry that wiring; a fragment or a bare string silently never opens the panel, so both warn in development. On web the clone sits in an overlay-owned `<span data-part="trigger">` with display: contents, and that wrapper — never the Button — is what persistent mode hides. |
| open | boolean | — | Controlled visibility. Omit for uncontrolled (the trigger toggles it); an uncontrolled panel always starts closed and there is no defaultOpen, so a panel that must start open is controlled. |
| heading required | string | — | The panel's title and accessible name ("Menu", "Filters", "Your cart"). May be visually hidden with `hideHeading`. |
| hideHeading | boolean | false | Keep the title for assistive technology but hide it visually (a navigation panel whose Links are self-explanatory): on web and Lit it stays rendered with the visually-hidden clip pattern so aria-labelledby still resolves; on native it is the surface's accessibilityLabel. When the header would then be empty (no close button because `dismissible` is false or the panel is persistent), the header part is not rendered: no padding, no gap, no height, and on web and Lit the hidden title moves to the top of the surface. On React Native there is nothing to move: the name is the surface's accessibilityLabel, which has no position in the view tree, so nothing is rendered at all. When the header keeps only the close button (`dismissible` true, not persistent), the close button is end-aligned in it on every platform. Accessibility: The accessible name is required regardless. |
| children required | content | — | The body: a Stack or Tree of Links for navigation, a Stack of filter controls (Checkboxes, a RadioGroup — not a Form, whose own actions would duplicate the footer), a Stack of Cards. Scrolls inside the panel when taller than the viewport. |
| footer | content | — | Pinned to the bottom of the panel above the safe area (a sign-out Button, a "Apply filters" action row). |
| side | start | end | start | The edge the panel slides from: `start` is left in left-to-right languages and right in right-to-left; `end` the opposite. Navigation comes from the start; contextual panels (a cart, a detail) from the end. |
| width | narrow | default | wide | default | Panel width on wide screens: narrow for a list of links, wide for a form or a detail. On phones the panel is the viewport width minus a gutter that keeps the scrim visible. |
| persistent | never | content | page | never | Above this layout width the panel stops being an overlay and becomes a fixed sidebar beside the content: always visible, no scrim, no trap, part of the page's tab order, and the trigger is hidden. `content` switches at layout.maxWidth.content, `page` at layout.maxWidth.page; the comparison is `(width > token)` (exactly the token width is still the overlay; on native `window width <= token` is the overlay — a width check, not an orientation check), and the breakpoint is read from the theme token, not per instance — on web and Lit from the token's resolved custom property on <html> when the component mounts (connects), measured once with an off-screen probe and converted to px before being handed to matchMedia, because a rem breakpoint inside a media query resolves against the initial font size rather than the one on <html>; a theme change after that takes effect on the next mount, and where matchMedia does not exist (jsdom) the overlay presentation renders. Below it, the overlay behavior applies. The sidebar renders where SidePanel sits in the tree (not through the portal), inline-size from the width binding, natural height (the page scrolls, not the body), with no close button; the page's own layout places it beside the content. This is how one component serves a phone's hamburger menu and a desktop's sidebar. |
| role | complementary | navigation | complementary | The landmark the panel exposes (in persistent mode and as the region's role when open), and the component's rendered role: `a11y.roleFrom` names this prop, so the panel is queried by `complementary` (the default) or `navigation` on every platform, and a test roots there rather than at the component's own element — which on Lit holds the slotted trigger and would make the trigger its first focusable. `navigation` for a menu of Links, `complementary` for filters, a cart, a detail. On web this is the composed Landmark's own role, so `navigation` renders a real <nav>; a modal panel is a dialog, not a landmark, and takes none of this. The name `role` is the doc's; on Lit the property and attribute are `landmark`, because a custom element inherits `Element.role` and must not shadow it. React Native has no landmark roles at all: the persistent sidebar carries the RN role prop and the overlay presentations expose no region role, only their label. |
| modal | boolean | false | False (the default, the disclosure pattern): the panel is a disclosed region — the page stays live and in the tab order after the panel, focus stays on the trigger when it opens, and Escape from inside or a click outside closes it. True: the panel is a modal Dialog at the edge — scrim, focus moved in and trapped, page inert and scroll-locked — for a panel that must be finished or dismissed (a cart checkout, a required filter). |
| scrim | boolean | true | Show the scrim in non-modal mode too (modal always has one). It defaults to true — this structured default is the one that counts — so turn it off for a panel that should feel like part of the page. |
| dismissible | boolean | true | Escape, the close button, a scrim tap / outside click, and the swipe gesture all request close. When false, the close button is not rendered and a scrim tap, an outside press and the swipe do nothing; Escape still reports through onOpenChange with reason escape (the consumer decides), as in Dialog — an uncontrolled non-dismissible panel reports it without closing, so it stays open. Only those four are gated: the trigger toggle (`trigger`), a followed Link (`navigation`) and a consumer's `action` always close. |
| swipeable | boolean | true | On touch, a swipe toward the edge dismisses; from the edge, a swipe opens (native only). Purely additive. Web and Lit accept the prop for parity and wire no gesture: dragging a panel with a mouse is not an idiom either platform has. On native the dismiss gesture lives on the header, excluding the close button — there is no handle part here; a touch that starts on the close button's wrapping View (`SidePanel.closeButton`) never becomes the header's move responder — and the edge-to-open swipe needs a controlled `open`, since an uncontrolled panel exposes nothing to open by hand. Accessibility: A gesture is never the only way (WCAG 2.5.1); the trigger and close button always exist. |
Events
1 row| Event | Type | Description |
|---|---|---|
| onOpenChange | onOpenChange / open-change | Fired when the panel opens or closes, with the new state and a reason: `trigger`, `escape`, `close-button`, `scrim`, `outside`, `swipe`, `action`, `navigation` (a Link inside was followed). The `overlay.dismiss` value `scrim` is the shared category for both `scrim` and `outside`; the other dismiss values are reported under their own names. |
Accessibility
Decisions this component leaves to you
Supply an accessible name. This component has no text of its own to fall back on, so without one a screen reader announces its role and nothing else.
If you wire up the gesture events, provide the pointer and keyboard path to the same outcome.
Choose the landmark role, and give it a name when the page has more than one landmark of that role.
ARIA role: set by the role prop, one of complementary, navigation.
Anatomy
What generation guarantees
Accessible name — Every instance carries a programmatic name. Where the component has no text of its own to use, the name is a prop the consumer supplies.
Expanded state — Open and closed are exposed programmatically (`aria-expanded`), not only visually.
Focus restore — On close, focus returns to whatever opened it.
Escape dismisses — Escape closes it from anywhere inside.
Gesture alternative — Every gesture — swipe, drag, long-press — has a single-pointer and a keyboard equivalent.
Keyboard operable — Everything the pointer can do here, the keyboard can do — see the keyboard section.
Visible focus — Keyboard focus is always drawn, from the theme’s focus tokens, and is never removed.
Contrast — Text and meaningful non-text contrast meets WCAG AA; the pairs are listed below.
Reduced motion — Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.
Target size — Interactive targets are at least 44×44 CSS pixels — the touch-first size, not the minimum.
Landmark — It renders a landmark, so assistive technology can jump straight to it.
Focus trap — While it is open, Tab cycles inside it; focus cannot reach the page behind.
Inert background — While it is open, the rest of the page is inert to the pointer and to assistive technology.
Scroll lock — While it is open, the page behind it does not scroll.
Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.