On this page
Overlays

Drawer released

A panel docked to an edge of the window that the page makes room for instead of disappearing under.

  • Import@pacit/components/drawer
  • Selectorspct-drawer, button[pctDrawerTrigger]

Implements the W3C ARIA APG Disclosure pattern ↗

drawer.demo.ts — the same file runs here and is highlighted under Code

Start here

Usage

One import per component, and the shortest possible use — the package itself is installed once, on the start page.

Import this component
import { PctDrawer } from '@pacit/components/drawer';
ExportedWhat it is
PctDrawercomponentpct-drawerA drawer: a panel docked to an edge of the window that the page goes on working around.
PctDrawerTriggerdirectivebutton[pctDrawerTrigger]The control that opens a drawer: it toggles the panel and says so about itself.
PctDrawerSidetype'start' | 'end' | 'top' | 'bottom'Which edge of the viewport the drawer is docked to.
PctDrawerCloseReasontype'escape' | 'close' | 'trigger' | 'api'Why the drawer closed.
<button pctButton [pctDrawerTrigger]="nav">Sections</button> <pct-drawer #nav heading="Sections">…</pct-drawer>
Examples

Every state, running

Each example is the component itself, not a screenshot. Open the code under any of them.

From the other side

side="end" slides the panel in from the inline end — the right edge in a left-to-right page, the left edge under dir="rtl", with nothing to rewrite.

API

What it reads, and what it writes back

Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.

A drawer: a panel docked to an edge of the window that the page goes on working around.

Inputs

NameTypeWhat it does
opentwo-waydefault falsebooleanWhether the drawer stands open. A model, because both directions are ordinary: an application opens it, and the drawer closes itself on Escape, on its own cross and on a second press of the trigger.
sidedefault 'start'PctDrawerSideWhich edge it is docked to. See PctDrawerSide for why it is logical.
headingdefault ''stringThe visible title, drawn at the top of the panel and used as its accessible name. A region with no name is not a landmark at all — a screen reader lists it as "region" and the user has to read the panel to find out what it is — so an unnamed one is reported in dev mode.
ariaLabeldefault ''stringThe accessible name of a drawer with no visible heading.
ariaLabelledbydefault ''stringAs ariaLabel, for a name that already stands somewhere inside the content.
closeButtondefault truebooleanWhether the panel draws a cross of its own in the header.
closeOnEscapedefault truebooleanWhether Escape closes it — and only an Escape pressed inside the panel, because the page behind is live. A non-modal panel that answered every Escape in the document would be taking the key away from whatever the user was really doing.

Outputs

NamePayloadWhen it fires
closedPctDrawerCloseReasonWhy it closed.

What it writes on the element

The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.

AttributeBound toNote
classpct-drawer
roleregionThe host IS the panel, so the role, the name and the state all sit on the tag the consumer typed — the opposite of the dialog, whose role travels to an overlay and whose name therefore has to be an input (req-a11y-built-in). A named region is a landmark: a screen reader reaches a parked panel by its name without walking the page to it.
idpropertypanelId
aria-labelariaLabel() || null
data-pct-sideside()
data-pct-openopen() ? "" : null
hiddenpresent() ? null : "until-found"
Styling

Parts to select, tokens to override

Two ways in, both stable. No class names to depend on.

Parts

In a test, PctDrawerHarness, PctDrawerTriggerHarness from @pacit/components/testing read a part by the name below and a state by its attribute — the harness gate holds the names to the package.

PartSelectorWhat it is
close[data-pct-part="close"]the close button
content[data-pct-part="content"]the projected body
header[data-pct-part="header"]the row holding the heading and the close button
heading[data-pct-part="heading"]the title the drawer is named by

Tokens

Every value the component reads, with the light and the dark default. Override a token at any scope and it cascades like any custom property.

TokenStyles
--pct-drawer-close-bg-hover{pct.surface-100}#f1f5f9 · #1e293bBackground of the close button on hover.
--pct-drawer-close-fg{pct.text-muted}#334155 · #e2e8f0Text colour of the close button.
--pct-drawer-close-fg-hover{pct.text}#0f172a · #ffffffText colour of the close button on hover.
--pct-drawer-close-radius{pct.radius.md}8pxCorner radius of the close button.
--pct-drawer-close-size{pct.target.min}24pxSize of the close button.
--pct-drawer-heading-fg{pct.text}#0f172a · #ffffffText colour of the heading.
--pct-drawer-heading-font-size{pct.font.size.lg}16pxFont size of the heading.
--pct-drawer-heading-font-weight{pct.font.weight.medium}500Font weight of the heading.
--pct-drawer-panel-bg{pct.surface}#ffffff · #0f172aBackground of the panel.
--pct-drawer-panel-border{pct.border-strong}#64748bEdge colour of the panel.
--pct-drawer-panel-fg{pct.text}#0f172a · #ffffffText colour of the panel.
--pct-drawer-panel-gap{pct.space.4}12pxGap between the panel's heading and its content.
--pct-drawer-panel-height320pxHeight of the panel.
--pct-drawer-panel-padding-x{pct.space.5}16pxHorizontal padding of the panel.
--pct-drawer-panel-padding-y{pct.space.5}16pxVertical padding of the panel.
--pct-drawer-panel-shadow{pct.shadow-panel}0 8px 24px rgba(15, 23, 42, 0.14) · 0 8px 24px rgba(0, 0, 0, 0.55)Shadow of the panel.
--pct-drawer-panel-width320pxWidth of the panel.
--pct-drawer-z-index900Stacking level of the drawer.

Theming

A brand override in a few declarations. Scope it to a theme attribute, a page or one container — the component never notices.

[data-theme='brand'] {
  --pct-drawer-panel-bg: #f0fdfa;
  --pct-drawer-panel-fg: #134e4a;
  --pct-drawer-heading-fg: #0f766e;
}
Accessibility

Measured, not assumed

Pattern: Disclosure — named in the class JSDoc

Keyboard

keyeffecttest
Tabwalks in and out of the panel in document order — nothing is splicedapps/sandbox-e2e/src/drawer.spec.ts
Enter / Spaceon the trigger: opens or shuts it — the platform's, off a <button>apps/sandbox-e2e/src/drawer.spec.ts
Escape insideshuts it, and gives the keyboard back to the control that opened itapps/sandbox-e2e/src/drawer.spec.ts
Escape anywhere elsenothing — the key stays the page'sapps/sandbox-e2e/src/drawer.spec.ts

There is no focus trap and no arrow-key walk. Both would make this a modal, which it is not: a panel the user is expected to leave and come back to has to let them leave.

What is measured, and what is not yet

Every line is a test in three engines or a gate on every commit. The gaps are listed the same way, each with the requirement that will close it.

MeasuredARIA APG pattern in the JSDoclibs/components/drawer/src/drawer.ts, libs/components/drawer/src/drawer-trigger.ts
MeasuredKeyboard map testedapps/sandbox-e2e/src/drawer.spec.ts (every row above, in three engines), plus libs/components/drawer/src/drawer.spec.ts for the two Escape branches and the shut-drawer case that must not swallow the key
MeasuredThe tab order is the page'sapps/sandbox-e2e/src/drawer.spec.ts — Tab from the trigger reaches the cross, the links, and then the page beyond, with no handler run and nothing trapping
MeasuredThe page behind stays liveapps/sandbox-e2e/src/drawer.spec.ts — zero [inert] in the document, the root's overflow unchanged from before the open, and a control outside the panel still taking a press. The dialog's opposite, measured rather than asserted
MeasuredThe theme comes down the treeapps/sandbox-e2e/src/drawer.spec.ts — the drawer resolves closest('[data-theme]') to the demo card's own stage and paints that stage's surface. lesson-35 read as an absence: nothing was moved, so nothing had to be patched back
Measuredaxe auditapps/sandbox-e2e/src/a11y.spec.ts — the /drawer view, LTR and RTL
MeasuredVisual screenshotapps/sandbox-e2e/src/visual.spec.ts — drawer-docked, of the viewport and not of a card: what a drawer looks like is a relation between the panel and the window — the sandbox's navigation blanked by the stage, so a row in that list is not a pixel here (req-quality-e2e)
Measuredforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts — Canvas on the panel, CanvasText on the docked edge, the heading and the cross. There is no veil here, so the border is the whole of what says where the panel stops
Measuredprefers-reduced-motionlibs/components/drawer/src/drawer.scss — the only transition is the inset over --pct-motion-transition-duration, and the preference is answered by the token build (req-a11y-motion). The leave waits out exactly what that token says, through pctAfterTransition
MeasuredTouch target ≥ 24×24 px--pct-drawer-close-size is --pct-target-min, so the cross's box is the floor outright — the dialog's rule, one component over
By designSize axisnone — deliberately: a drawer is not a control with a height. req-api-size is about --pct-control-height-*; the panel's measure is --pct-drawer-panel-width / -height, which a skin moves
GapDensity axisnone — gap. The same one every component here has (req-token-density)
MeasuredRTLapps/sandbox-e2e/src/drawer.spec.ts — a start-docked drawer flush with the left edge in LTR and with the right edge under dir="rtl", off one value. The slide is a transition of the inset rather than of a transform, which is why it mirrors with no rule of its own
MeasuredSSR + hydrationapps/sandbox-e2e/src/hydration.spec.ts — the /drawer view renders and hydrates with no NG05xx
By designFormsnone — deliberately: a drawer is not a form control. It holds no value a form owns and implements no FormValueControl
MeasuredA shut drawer is findableapps/sandbox-e2e/src/drawer.spec.ts — hidden="until-found", content-visibility: hidden, display: flex, and the content answering checkVisibility() === false, in three engines. beforematch opening the model is unit-tested
MeasuredA panel opened from inside itapps/sandbox-e2e/src/drawer.spec.ts — a pct-select in the filter drawer, its listbox hit-tested at its own centre. That is the whole reason --pct-drawer-z-index is 900 and not a larger number: the CDK stamps its overlay container at 1000
MeasuredDocked to the window, or told why notapps/sandbox-e2e/src/drawer.spec.ts › an ancestor with a transform catches the panel — the demo card given a transform takes the panel's edges and the report names <sbx-demo> and the property, in three engines; without it the panel spans the viewport and nothing is said. libs/components/drawer/src/drawer.spec.ts › the containing block — the sentence whole, the reasons read off the ancestor (transform, contain, filter, will-change, none), and <body> named only with a reason on it
MeasuredParts in the inventorylibs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts)
MeasuredTokens + contrast.policy.jsonlibs/tokens/src/contrast.policy.json — five entries. The panel's edge is measured against the page, not against a veil, because there is no veil: that makes it stricter than the dialog's namesake rather than looser
MeasuredStrings through PCT_TEXTSdrawerClose, read at render time in drawer.html; swapped in libs/components/drawer/src/drawer.spec.ts through providePctTexts
MeasuredSize budgetlibs/components/size.snapshot.md — the ./drawer row
MeasuredScreen-reader logRead 2026-09-28 from docs/acr/at/ — three readers, whole sandbox, and the act that opens it. The trigger's state reaches all three the moment the region opens: Orca expanded, NVDA expanded, VoiceOver Sections expanded button — with focus staying on the trigger, which is the design of a docked, non-modal region: nothing took the focus away, and no reader announced the region itself. Whether the named region is then offered in a reader's landmark list is a rotor question and not an announcement, so no walk asks it. Shut, the trigger reads collapsed as before: Sections · collapsed button. (Orca), Sections, from further down, button, collapsed (NVDA). Escape on the trigger closes nothing — an Escape inside the panel does — so the close row is a silence by design
Measureddocs page/components/drawer on the published site — prerendered, the demo's own source is the code tab
Evidence

What the repository holds for this component

Read at build time from the tracked snapshots, not typed here.

79.19%mutation score118 of 149 mutants killed
13 × 3e2e cases × enginesplus the shared audits: axe, forced colors, preferences, size, hydration
5colour pairs measuredlight and dark, at every token build
1visual baselinesChromium, the sandbox's own pictures
14elements in the preview4 listeners · 1 render to settle · 4737 µs in jsdom, held exactly but for the clock

Why it is built this way

It is the one place in this library where that pattern has to be written rather than taken. The accordion showed a disclosure is the platform's, on one condition: <summary> has to be the first child of the <details> it opens (0046). A drawer's button is in the header bar and its panel is at the edge of the window, so the two are apart and no element expresses them — what is left is aria-expanded on somebody else's button, aria-controls pointing at the panel, and a panel that is hidden without being gone (0047).

Decisions behind it

  • 0047A drawer is a region of the page, not a layer over it
  • 0031A panel's tab order belongs to its trigger
  • 0045A panel nobody chose is still text in the document
  • 0046A disclosure is the platform's, and so is the group it belongs to
  • 0029A modal is an overlay, not a <dialog> — the platform's modal is not composable with ours
  • 0011Icons through a template and PCT_ICONS
  • lesson-163A platform rule written from memory is a claim, and the platform's own answer is the detector

Known limitations

  • It is not modal, and there is no input that makes it one. No veil, no focus trap, no inert on the page behind, no scroll lock. A side sheet that must be answered before anything else is pct-dialog. The two are not one component with a flag, because everything that differs between them is a promise rather than a skin (0047).
  • It covers the page rather than pushing it. An open drawer stands over whatever is under its edge, and a control there is out of the pointer's reach until it shuts — the keyboard still gets everywhere, which is what "the page behind is live" really promises. A layout that must stay uncovered adds the panel's own measure as padding to its container; that is one line of the application's CSS and would be a second layout mode here.
  • position: fixed means the window — unless an ancestor says otherwise. A transform, a filter, contain: paint, content-visibility: auto or a will-change naming one of them on any element above the drawer makes that element the containing block, and the panel docks to it instead — container-type does not, whatever the first draft of this bullet said: the list was measured in three engines (lesson-163). This is the platform's rule and not this component's, and it is the price of being drawn where the consumer wrote it instead of in an overlay — so the drawer cannot undo it, and what it does is say it: an open drawer whose offsetParent is not the window is reported in dev mode, with the ancestor and the property named.
  • Nothing keeps two open drawers apart. Two panels docked to the same edge stand on top of each other. A group that closes its siblings is the accordion's exclusive one component over, and no case has asked for it yet.
  • No keyboard shortcut jumps into it. A drawer at the end of the document is at the end of the tab order, and this library installs no document-level key to skip there — the same question the toast's action raised, and it is written down as one item rather than answered twice (req-api-platform is about not inventing what the platform has, and here the platform has nothing).
  • A drawer whose content is expensive renders it anyway. That is the price of a shut drawer being text in the document rather than a branch — the accordion's price, for the accordion's reason. A consumer wraps their own content in @defer if they would rather pay differently.