A panel that slides out of view along its own edge. Its open/close control stays visible at that edge when the panel is closed, so users can always find it. The chevron turns as the panel moves.
This is not the sidebar pattern.
That pattern uses a hamburger button to open a modal <ui-dialog>, with
no persistent affordance; this one is a non-modal panel that stays in the layout
and collapses in place. See Choosing a layout pattern.
Click the tab on the panel's edge. The panel slides out, the main content expands into its space, and the chevron turns to point the way back.
The panel slides out of the host's start edge; this column widens to take the space.
No additional class or wrapper is required. The panel is an <aside> styled through its two required attributes, and the handle is its first child.
start | end — which edge the panel lives on. Uses logical directions, including in RTL layouts.tab (default) | rail | float | grip — the handle's look. See the gallery below.dialog-dismiss marks a dialog's close button. Author it first inside the panel — it is painted in the panel's overhang whatever the markup order, and Tab order is DOM order.0 open, 1 collapsed. Everything visual reads it, so the panel, the handle and the chevron cannot disagree.0px by default: how much of the panel stays on screen when it is collapsed. Set it on the panel to the width of its rail — see Collapsing to a rail. At 0px the panel slides out entirely.<ui-toolbar> of disclosure buttons that switch the hosted view. The decorator never makes it inert, so the collapsed panel can still be reopened from it. A panel with a rail has no panel-toggle handle.
The handle comes first, ahead of the content it collapses — on both sides
and in all four looks. Place the disclosure control before the content it opens. Tab order is DOM order, so a handle authored
last sits after everything in the panel: users must then tab through all the content to reach the close control.
Move the element rather than using tabindex to create a separate keyboard order.
The handle sits outside the column containing the panel content. data-side chooses the edge: inset-inline-end for a start-side panel and inset-inline-start for an end-side panel. Vertically, tab and float begin at --_handle-inset, rail spans the panel height, and grip is centered. These visual positions do not change the requirement to place the handle first in the DOM.
Identify the handle with its marker attribute.
aria-controls cannot be matched in CSS — there is no way to select
"the button whose aria-controls names its ancestor" — and a positional > button selector could mistakenly select a direct-child button added as panel content.
The host must clip. The panel keeps its width and slides its
own box past the host's edge, so the host needs overflow: hidden.
That is what removes the need for an inner overflow box inside the panel — and
it is what lets the handle hang past the seam without being cut off.
Isolate a neighbouring column that holds raised layers. The handle
hangs over the neighbouring column at z-index: 1. A sticky bar or a
floating meter in that column with a higher z-index paints over the handle,
and the panel can no longer be opened. Give that column
isolation: isolate, so its layers stack inside it.
The styles come with the decorator. side_panel() injects
its CSS into the document on its first call, ahead of the page's stylesheets, so a page
rule of equal specificity overrides it. A page that links /ui/ui.css but never
calls side_panel() does not parse these styles. Until the call, the panel
renders as a plain <aside>, so a panel that must not show before then
starts hidden.
Four visual styles for the same button, chosen with data-handle. All four
carry the same behaviour and the same accessible name; only its appearance changes.
tab — the default. Biggest target of the four.rail — subtle, with a shape similar to a splitter.float — looks like a separate button.grip — signals "drag me" before "click me".
data-side is logical, so start/end follow
the writing direction rather than naming left and right. Two panels on one host
need nothing special — each owns its own --sp-shift.
Collapse either panel; the reader takes the space back.
Several panels that share one side of the layout are one panel with several views. Instead of sliding out entirely, that panel collapses to a rail: a vertical strip of icon buttons, one per view, that stays on screen. Pressing a button shows its view and opens the panel; pressing the open view's button collapses the panel back to the rail. The rail is also the answer when a handle attached to the panel's edge would hang over a neighbouring scroll container and cover its scrollbar: a rail overhangs nothing.
Set --sp-rail on the panel to the rail's width and mark the rail with panel-rail. The panel has no panel-toggle: pass open to side_panel() and drive set_open() from the rail's buttons. Which view is on screen is the page's — here a <ui-outlet>-shaped swap done by hand; the studio uses <ui-outlet> itself.
The panel on the end side collapses to its rail, not out of the host. Press a rail button to open that view; press it again to collapse.
The rail is authored first, before the views, so a keyboard user meets the controls before the content they disclose; for a start-side panel the stylesheet moves it to the inline-end with order, so the strip left on screen is the one nearest the host's main column either way. Each button is --size-8 square (48 CSS px, above WCAG 2.5.8's 24 px minimum and 2.5.5's 44 px), shows the open view with an inline-start bar and a filled background, and keeps that state visible under forced colors as an outline.
On a touch screen a panel is easier to push aside than to aim at. Add the bare panel-drag attribute and the panel can be dragged between its two positions: push it towards its own edge to collapse it, pull it back to open it. It follows the finger, carries on through a flick, and settles on whichever position it is nearest; --sp-shift and every trigger's aria-expanded follow the landing.
Opt in on a panel that collapses to a rail. A collapsed panel with --sp-rail: 0px has nothing left on screen for a finger to take hold of, so the gesture could close it and never reopen it. The gesture also turns the panel into a scroll container, which clips: a panel-toggle handle hanging past the seam would disappear, and a rail is the shape that replaces it.
Drag the panel sideways — with a finger, or with a horizontal trackpad swipe. It moves with you and settles open or collapsed.
The browser does the moving. The panel becomes an inline scroll container with a snap stop at each end of its travel, so momentum, velocity and an interrupted flick all come from the engine rather than from pointer events this library would have to interpret. The scroll position is read as a number and nothing else: the panel is already sliding its whole box, so its children carry an offset that cancels the scroll exactly, and nothing inside the panel moves relative to it. That is also why the panel shows no scrollbar — there is nothing to scroll to.
The finger is not the only inline scroll input. A horizontal trackpad swipe or a tilt wheel over the panel moves it too, because they reach the same scroll container. overscroll-behavior keeps the gesture from continuing into the page behind it.
The position of a draggable panel also lives in the scroll offset, which a rule such as a focus mode's display: none can reset when it takes the panel's box away. The decorator keeps the open or collapsed state itself and re-aims the scroller whenever the panel gets a box back, so a panel comes out of a chrome-off mode exactly where the finger left it.
A trigger and a set_open() move such a panel by scrolling it too, through scrollTo() with behavior: 'smooth', and the scroll handler writes --sp-shift from each position the scroller passes. That is what makes the sentence above true during the move and not only at rest: the two offsets annul each other only while they hold the same value, so transitioning --sp-shift over the panel's own duration while snapping the scroller to its stop would park everything inside the panel up to the full travel outside the scrollport until the transition ended — and the browser would then scroll to reveal a focused control from there, which arrives at the decorator as a finger. Following the position frame by frame from script is not an option instead: mandatory snapping rejects a mid-range scrollLeft write and lands it back on a stop. Under prefers-reduced-motion: reduce the scroller is snapped, matching the shortened transition.
Where a move stops does not decide whether the panel is open. Revealing a focused element scrolls an ancestor, and that cancels any smooth scroll in flight, so a move can be abandoned half way. A trigger's decision stands: the decorator puts the scroller on the stop that state asks for rather than reading a state out of the place the move was left in. A finger is the other way round — there the landing is the decision.
Keep the panel's position, availability, and visibility in page modes independent:
| Axis | Owner | Mechanism |
|---|---|---|
| Position — open or collapsed | the panel | --sp-shift |
| Availability — is there anything to show | the app | the hidden attribute |
| Chrome-off modes — "focus mode" | the page | a CSS rule keyed off an ancestor class |
So hiding a panel wholesale needs no panel feature: a rule from an ancestor
hides it, the handle goes too because it is a child, and
--sp-shift is untouched — the panel comes back exactly as open or
collapsed as it was. What it must not do is reuse hidden,
which the app is already writing for a different reason.
Collapse the panel first, then tick the box and untick it — it returns collapsed, not open.
One call. It measures the panel's width, keeps every trigger's
aria-expanded in step, and takes the collapsed content out of the
tab order.
When re-rendering, keep the handle as the panel's first child. Before replacing the panel's innerHTML, save the handle; afterwards, restore it with prepend(). This preserves its click listener, the decorator's aria-expanded state, and its position in the tab order. Using append() would put the handle after the content in keyboard navigation, even though absolute positioning leaves it visually unchanged.
The decorator leaves the markup order unchanged. It finds the handle by its
panel-toggle marker and never moves it. The author controls the handle's position, including when page-specific CSS places it elsewhere.
side_panel() is idempotent per element: decorating twice returns
the first controller rather than wiring a second set of listeners.
<button> carrying
aria-expanded and aria-controls — the APG
Disclosure
pattern. The native button supplies Enter and Space activation.
aria-expanded is for; changing the name would duplicate the state announcement and could conflict during a transition.
inert, so Tab skips them.
The marking is applied to the panel's children rather than the panel, because
inert is inherited with no way to opt back in — applying it to the panel would also disable the handle and prevent reopening.
aria-expanded already
announces, and <ui-icon> keeps it out of the accessible name.
prefers-reduced-motion with a shorter transition. Opening and closing must still change the panel's position to communicate its state.
aria-expanded and aria-controls naming the panel (APG Disclosure); <ui-toolbar aria-orientation="vertical"> makes the rail one Tab stop with Up/Down and Home/End between the buttons (APG Toolbar). At most one view is open, the APG Accordion rule. A tablist would be wrong twice: aria-selected="false" on every tab cannot say "every panel is closed", which is a normal state here, and the Tabs pattern has no tab that collapses its own panel and never puts aria-expanded on one.
.visually-hidden span beside the decorative <ui-icon aria-hidden="true">, repeated by a tooltip for sighted users. The state is aria-expanded's; a name that changes to "Close notes" re-announces the control as a different one.
inert when it collapses; the rail never does.
aria-labelledby at the open view's heading, and fall back to the panel's own aria-label when it is collapsed.
forced-colors: active the open state is an outline, not a colour. Reduced motion shortens the slide to the rail rather than removing it.
| Side panel with a handle | Side panel with a rail | Sidebar | |
|---|---|---|---|
| Presentation | Non-modal, stays in the layout | Non-modal, stays in the layout; several views in one panel | Modal dialog over the content |
| Affordance | Attached to the panel and always visible | A strip of icon buttons the panel collapses to, one per view | A hamburger button elsewhere in the interface |
| Closing | Collapses in place; content reclaims the space | Collapses to the rail; content reclaims the rest | Dismissed — Escape, backdrop, close button |
| Focus | Stays where it was; nothing is trapped | Stays on the rail button | Trapped, and returned to the trigger |
| Reach for it when | One panel is a working surface the reader keeps returning to | Several panels share one side, or a handle would hang over a scroll container and cover its scrollbar | Users open the panel occasionally on a narrow viewport |
<details>.caret-left and the rest of the Phosphor set.