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.
Your cart
- Design tokens, 1
- Components, 33
- Schematics, 2
A panel docked to an edge of the window that the page makes room for instead of disappearing under.
@pacit/components/drawerpct-drawer, button[pctDrawerTrigger]Implements the W3C ARIA APG Disclosure pattern ↗
import { Component } from '@angular/core';
import { PctButton } from '@pacit/components/button';
import { PctDrawer, PctDrawerTrigger } from '@pacit/components/drawer';
/** A side panel and the button that owns it — aria-expanded and aria-controls for free. */
@Component({
selector: 'demo-drawer',
imports: [PctButton, PctDrawer, PctDrawerTrigger],
template: `
<button pctButton [pctDrawerTrigger]="nav">Sections</button>
<pct-drawer #nav heading="Sections">
<nav style="display: grid; gap: 8px">
<a href="#shipping">Shipping</a>
<a href="#payment">Payment</a>
<a href="#returns">Returns</a>
</nav>
</pct-drawer>
`,
})
export class DrawerDemo {}drawer.demo.ts — the same file runs here and is highlighted under Code
One import per component, and the shortest possible use — the package itself is installed once, on the start page.
import { PctDrawer } from '@pacit/components/drawer';| Exported | What it is |
|---|---|
PctDrawercomponentpct-drawer | A 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>Each example is the component itself, not a screenshot. Open the code under any of them.
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.
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.
| Name | Type | What it does |
|---|---|---|
opentwo-waydefault false | boolean | Whether 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' | PctDrawerSide | Which edge it is docked to. See PctDrawerSide for why it is logical. |
headingdefault '' | string | The 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 '' | string | The accessible name of a drawer with no visible heading. |
ariaLabelledbydefault '' | string | As ariaLabel, for a name that already stands somewhere inside the content. |
closeButtondefault true | boolean | Whether the panel draws a cross of its own in the header. |
closeOnEscapedefault true | boolean | Whether 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. |
| Name | Payload | When it fires |
|---|---|---|
closed | PctDrawerCloseReason | Why it closed. |
The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.
| Attribute | Bound to | Note |
|---|---|---|
class | pct-drawer | |
role | region | The 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. |
idproperty | panelId | |
aria-label | ariaLabel() || null | |
data-pct-side | side() | |
data-pct-open | open() ? "" : null | |
hidden | present() ? null : "until-found" |
Two ways in, both stable. No class names to depend on.
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.
| Part | Selector | What 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 |
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.
| Token | Styles |
|---|---|
--pct-drawer-close-bg-hover{pct.surface-100}#f1f5f9 · #1e293b | Background of the close button on hover. |
--pct-drawer-close-fg{pct.text-muted}#334155 · #e2e8f0 | Text colour of the close button. |
--pct-drawer-close-fg-hover{pct.text}#0f172a · #ffffff | Text colour of the close button on hover. |
--pct-drawer-close-radius{pct.radius.md}8px | Corner radius of the close button. |
--pct-drawer-close-size{pct.target.min}24px | Size of the close button. |
--pct-drawer-heading-fg{pct.text}#0f172a · #ffffff | Text colour of the heading. |
--pct-drawer-heading-font-size{pct.font.size.lg}16px | Font size of the heading. |
--pct-drawer-heading-font-weight{pct.font.weight.medium}500 | Font weight of the heading. |
--pct-drawer-panel-bg{pct.surface}#ffffff · #0f172a | Background of the panel. |
--pct-drawer-panel-border{pct.border-strong}#64748b | Edge colour of the panel. |
--pct-drawer-panel-fg{pct.text}#0f172a · #ffffff | Text colour of the panel. |
--pct-drawer-panel-gap{pct.space.4}12px | Gap between the panel's heading and its content. |
--pct-drawer-panel-height320px | Height of the panel. |
--pct-drawer-panel-padding-x{pct.space.5}16px | Horizontal padding of the panel. |
--pct-drawer-panel-padding-y{pct.space.5}16px | Vertical 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-width320px | Width of the panel. |
--pct-drawer-z-index900 | Stacking level of the drawer. |
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;
}| key | effect | test |
|---|---|---|
Tab | walks in and out of the panel in document order — nothing is spliced | apps/sandbox-e2e/src/drawer.spec.ts |
Enter / Space | on the trigger: opens or shuts it — the platform's, off a <button> | apps/sandbox-e2e/src/drawer.spec.ts |
Escape inside | shuts it, and gives the keyboard back to the control that opened it | apps/sandbox-e2e/src/drawer.spec.ts |
Escape anywhere else | nothing — the key stays the page's | apps/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.
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.
libs/components/drawer/src/drawer.ts, libs/components/drawer/src/drawer-trigger.tsapps/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 keyapps/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 trappingapps/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 assertedapps/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 backapps/sandbox-e2e/src/a11y.spec.ts — the /drawer view, LTR and RTLapps/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)forced-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 stopsprefers-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--pct-drawer-close-size is --pct-target-min, so the cross's box is the floor outright — the dialog's rule, one component overreq-api-size is about --pct-control-height-*; the panel's measure is --pct-drawer-panel-width / -height, which a skin movesapps/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 ownapps/sandbox-e2e/src/hydration.spec.ts — the /drawer view renders and hydrates with no NG05xxFormValueControlapps/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-testedapps/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 1000apps/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 itlibs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts)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 looserPCT_TEXTSdrawerClose, read at render time in drawer.html; swapped in libs/components/drawer/src/drawer.spec.ts through providePctTextslibs/components/size.snapshot.md — the ./drawer rowdocs/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/components/drawer on the published site — prerendered, the demo's own source is the code tabRead at build time from the tracked snapshots, not typed here.
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).
<dialog> — the platform's modal is not composable with oursPCT_ICONSinert 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).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.exclusive one component over, and no case has asked for it yet.req-api-platform is about not inventing what the platform has, and here the platform has nothing).@defer if they would rather pay differently.