Side panel with an edge handle

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.

Live demo

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.

Show code

Markup

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.

data-side
start | end — which edge the panel lives on. Uses logical directions, including in RTL layouts.
data-handle
tab (default) | rail | float | grip — the handle's look. See the gallery below.
panel-toggle
Marks the handle button, the way 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.
--sp-shift
Registered property: 0 open, 1 collapsed. Everything visual reads it, so the panel, the handle and the chevron cannot disagree.
--sp-rail
Registered property, 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.
panel-drag
Opts the panel into the drag gesture: it can be pushed open and shut with a finger. See Pushing the panel with a finger. Without this attribute a panel is moved by its controls alone.
panel-rail
Marks the child that is the rail: a <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.

Handle styles

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.

Either side

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.

Show code

Collapsing to a rail

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.

Show code

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.

Pushing the panel with a finger

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.

Show code

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.

Control position, availability, and page modes separately

Keep the panel's position, availability, and visibility in page modes independent:

AxisOwnerMechanism
Position — open or collapsedthe panel--sp-shift
Availability — is there anything to showthe appthe hidden attribute
Chrome-off modes — "focus mode"the pagea 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.

Reader

Collapse the panel first, then tick the box and untick it — it returns collapsed, not open.

Show code /* The page's own rule — the panel knows nothing about it. */ body.focus-mode .side-panel-host aside[data-side] { display: none; }

JS

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.

Show code import { side_panel } from '/ui/lib/side-panel.mjs' const panel = side_panel(document.querySelector('#nav')) panel.open // boolean panel.set_open(false) // collapse panel.toggle() panel.refresh() // after replacing the panel's contents panel.destroy() // A panel may have more triggers than its own handle — a menu item, a // header button. All of them stay in aria-expanded step. side_panel(document.querySelector('#nav'), { triggers: [document.querySelector('#menu-item')], on_change: (open) => save(open), })

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.

Accessibility

The rail

Choosing a layout pattern

Side panel with a handleSide panel with a railSidebar
PresentationNon-modal, stays in the layoutNon-modal, stays in the layout; several views in one panelModal dialog over the content
AffordanceAttached to the panel and always visibleA strip of icon buttons the panel collapses to, one per viewA hamburger button elsewhere in the interface
ClosingCollapses in place; content reclaims the spaceCollapses to the rail; content reclaims the restDismissed — Escape, backdrop, close button
FocusStays where it was; nothing is trappedStays on the rail buttonTrapped, and returned to the trigger
Reach for it whenOne panel is a working surface the reader keeps returning toSeveral panels share one side, or a handle would hang over a scroll container and cover its scrollbarUsers open the panel occasionally on a narrow viewport

See also