On this page
Overlays

Popover released

A panel of content anchored to a control, with the page behind it still answering.

  • Import@pacit/components/popover
  • Selectorpct-popover

Implements the non-modal reading of the W3C ARIA APG Dialog pattern ↗

popover.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 { PctPopover } from '@pacit/components/popover';
ExportedWhat it is
PctPopovercomponentpct-popoverA popover: a panel of content hanging off the control that opened it, with focus inside it and the rest of the page still answering.
PctPopoverTriggerdirective[pctPopoverTrigger]The control a popover hangs off: it toggles the panel and says so about itself.
PctPopoverCloseReasontype'trigger' | 'escape' | 'outside' | 'away' | 'api'Why a popover closed. open says *that* it is shut and nothing else, and the question an application asks about a panel it did not close itself — did the user answer, or did they walk away from it — is answered by this and by nothing in the boolean.
<button pctButton [pctPopoverTrigger]="filters">Filters</button> <pct-popover #filters heading="Filters">…</pct-popover>

<!-- the same panel drawn where it stands: no layer, no anchor, and there from the server -->
<pct-popover inline [open]="true" heading="Filters">…</pct-popover>

<!-- one panel with two lives — a column on a wide page, a layer on a narrow one -->
<button pctButton [pctPopoverTrigger]="filters">Filters</button>
<pct-popover #filters [inline]="wide()" [(open)]="filtersOpen" heading="Filters">…</pct-popover>
Examples

Every state, running

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

Placement

Ask for a side with placement; the panel takes it when it fits and flips to the opposite side when the viewport says no — a wish, not an order.

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 popover: a panel of content hanging off the control that opened it, with focus inside it and the rest of the page still answering.

Inputs

NameTypeWhat it does
opentwo-waydefault falsebooleanWhether the panel is up. A model, because both directions are ordinary: an application opens it, and the popover closes itself on Escape, on a press outside and on the trigger.
headingdefault ''stringThe visible title, rendered as the panel's heading and used as its accessible name. A panel with no name is announced as "dialog" and nothing else, so an empty one is reported in dev mode rather than passed over.
ariaLabeldefault ''stringThe accessible name of a popover with no visible heading — an INPUT and not an attribute on the tag, for the dialog's reason: the role sits on the panel inside the overlay, and the host carries none at all (req-a11y-built-in).
ariaLabelledbydefault ''stringAs ariaLabel, for a name that already stands somewhere inside the content.
placementdefault 'bottom'PctPlacementWhich side of the trigger it opens on — logical, so end is the right in an English page and the left in an Arabic one. The window has the last word: a side with no room for the panel falls back to the one across the trigger (pctPlacementPositions). It says nothing inline: a panel drawn in the page is placed by the page.
inlinedefault falsebooleanDraws the panel in the host, where the consumer wrote the component, instead of in an overlay over the page. The same template, the same classes, the same parts and the same stylesheet — a second panel written out for this case is the defect the input exists to avoid.

Outputs

NamePayloadWhen it fires
closedPctPopoverCloseReasonWhy it closed. See PctPopoverCloseReason.

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-popover
styledisplay: contentsThe host draws nothing of its own in either mode: with an overlay everything it draws lives outside the tree, and it stays here because it is where the panel's severed properties are read from. display: contents is also what makes the inline panel land in the consumer's own layout — the host box is not there to be a wrapper between the page's grid and the panel it wrote into it.
Styling

Parts to select, tokens to override

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

Parts

In a test, PctPopoverHarness, PctPopoverTriggerHarness 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
content[data-pct-part="content"]the projected body
heading[data-pct-part="heading"]the title the panel is named by
panel[data-pct-part="panel"]the floating box

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-popover-heading-fg{pct.text}#0f172a · #ffffffText colour of the heading.
--pct-popover-heading-font-size{pct.font.size.md}14pxFont size of the heading.
--pct-popover-heading-font-weight{pct.font.weight.medium}500Font weight of the heading.
--pct-popover-panel-bg{pct.surface}#ffffff · #0f172aBackground of the panel.
--pct-popover-panel-border{pct.border-strong}#64748bEdge colour of the panel.
--pct-popover-panel-fg{pct.text}#0f172a · #ffffffText colour of the panel.
--pct-popover-panel-gap{pct.space.3}8pxGap between the panel's heading and its content.
--pct-popover-panel-max-width320pxMaximum width of the panel.
--pct-popover-panel-padding-x{pct.space.4}12pxHorizontal padding of the panel.
--pct-popover-panel-padding-y{pct.space.4}12pxVertical padding of the panel.
--pct-popover-panel-radius{pct.radius.md}8pxCorner radius of the panel.
--pct-popover-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.

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-popover-panel-bg: #f0fdfa;
  --pct-popover-panel-fg: #134e4a;
  --pct-popover-heading-fg: #0f766e;
}
Accessibility

Measured, not assumed

Pattern: the non-modal reading of Dialog — role="dialog" with no aria-modal, focus moved into the panel and given back to the trigger, Escape from the closing stack. The trigger is the Disclosure half: aria-expanded on the control, aria-haspopup="dialog", aria-controls while the panel is up

Keyboard

keyeffecttest
Escapecloses it; focus goes back to the trigger if it was in the panelapps/sandbox-e2e/src/popover.spec.ts
Tabfrom the panel, into its content; from the last control, out — closing it and returning focus to the triggerapps/sandbox-e2e/src/popover.spec.ts
Shift+Tabfrom the first control, the same way outapps/sandbox-e2e/src/popover.spec.ts
Enter/Space on the triggertoggles it, which is the button's own behaviour and nothing this component addslibs/components/popover/src/popover.spec.ts

The panel itself is not in the Tab order (tabindex="-1"); it is focused programmatically on opening, so that its role and name are announced before the user is anywhere inside it.

None of the first three rows is true inline. Escape and the outside press are the closing stack's and never reach a panel that was never attached; Tab walks through the panel and out the far side, which is the whole point of drawing it in the page. Each is measured there as an absence, in libs/components/popover/src/popover.spec.ts.

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/popover/src/popover.ts — the pattern named in the class comment, together with the one attribute it must not carry
MeasuredKeyboard map testedapps/sandbox-e2e/src/popover.spec.ts — Escape, Tab out in both directions, in three engines; libs/components/popover/src/popover.spec.ts for the edge of the tabbable list
Measuredaxe auditapps/sandbox-e2e/src/a11y.spec.ts — the /popover view, and separately an open popover with the whole page: the dialog's audit is scoped to its panel because the rest is inert, and here nothing is — the trigger's aria-expanded/aria-controls only mean something measured together with what they point at
MeasuredVisual screenshotapps/sandbox-e2e/src/visual.spec.ts — popover-open, the card rather than the panel: this component draws the page's own surface, so the edge is all there is between the two and a picture of the panel alone would not show it — 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 — the mode takes the shadow away and the panel keeps a CanvasText edge, which is then the only thing separating it from the page
Measuredprefers-reduced-motionapps/sandbox-e2e/src/popover.spec.ts — the duration read from the panel is the reduced one, and the leave still ends
By designTouch target ≥ 24×24 pxnone — deliberately: the popover draws no target of its own. It hangs on the consumer's control, and that control's size is that control's promise
By designSize axisnone — deliberately: a popover has no control height. req-api-size is about --pct-control-height-*; the only width here is --pct-popover-panel-max-width, which a consumer overrides
GapDensity axisnone — gap. The same one every component here has (req-token-density)
MeasuredRTLapps/sandbox-e2e/src/popover.spec.ts — the gap on the inline axis measured in both writing directions. It is the one thing a screenshot would not catch: the dependency resolves start/end by direction and adds the offset as plain pixels afterwards, so a sign left physical lays the panel over the control it belongs to
MeasuredSSR + hydrationapps/sandbox-e2e/src/hydration.spec.ts — the /popover view renders and hydrates with no NG05xx; a closed popover contributes no markup, and the trigger's aria-expanded="false" is there from the server
MeasuredDrawn in the page (inline)libs/components/popover/src/popover.spec.ts — the panel in the consumer's own tree with no CDK pane beside it, put there by a pass over the host view alone (no afterNextRender, which is the hook a server never runs), plus one case per dropped semantic: no focus taken on opening, Tab left alone in both directions, Escape and the outside press inert, no missing-trigger warning, and a switch between the two modes that moves the panel without closing it. Gap: no e2e and no screenshot — apps/sandbox has no inline view yet, so the three-engine reading and the real hydration are unmeasured
By designFormsnone — deliberately: a popover is not a form control. It holds no value and implements no FormValueControl. A form inside one is the consumer's, and it reaches the chrome exactly as it would anywhere else
MeasuredThe page behind stays liveapps/sandbox-e2e/src/popover.spec.ts — a control beside the panel answers a press while the panel is up, and <html> is not locked. Measured rather than declared: it is the one promise that separates this component from the dialog
MeasuredFocus in, and backapps/sandbox-e2e/src/popover.spec.ts (the panel takes focus, Tab reaches the content, Escape hands it back) + libs/components/popover/src/popover.spec.ts (the restore happens only when focus was inside, so Escape pressed from the live page behind moves nothing)
MeasuredThe leave is waited outapps/sandbox-e2e/src/popover.spec.ts (the panel outlives the decision to close) + libs/components/core/src/core.spec.ts (pctAfterTransition: the event, the timeout floor and the cancel)
MeasuredParts in the inventorylibs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts)
MeasuredTokens + contrast.policy.jsonlibs/tokens/src/contrast.policy.json — the panel's text, its heading, and the edge measured against the page rather than against a veil, which is where the modal and the non-modal panel part company
By designStrings through PCT_TEXTSnone — deliberately: the component writes no string of its own
MeasuredSize budgetlibs/components/size.snapshot.md — the ./popover row, with ./core beside it
MeasuredScreen-reader logRead 2026-10-08 from docs/acr/at/ — three readers, whole sandbox, and the act that opens it. When the non-modal dialog takes focus, all three speak: NVDA Filters, dialog, VoiceOver Filters web dialog with 5 items heading level 2 Filters, Orca Filters Owner A name, or part of one · Browse mode — the role reaches two of the three, and Orca reads the panel's text with the role unsaid. On Escape the trigger says collapsed (Orca Filters · collapsed button. · opens dialog · Focus mode · collapsed); VoiceOver's Escape closed no panel at all in the pass of 2026-10-08 — the dialog's, the menu's and this one stayed open — which is the walk's reader and not this component. Arrival is as before: every trigger announces that it opens a dialog — start · collapsed button. · opens dialog (Orca), start, button, collapsed, opens dialog (NVDA), and VoiceOver, which reached Filters first in the pass of 2026-10-08, Filters dialog pop up button — VoiceOver alone omitting the collapsed state
Measureddocs page/components/popover 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.

70.45%mutation score155 of 220 mutants killed
15 × 3e2e cases × enginesplus the shared audits: axe, forced colors, preferences, size, hydration
3colour pairs measuredlight and dark, at every token build
1visual baselinesChromium, the sandbox's own pictures
3elements in the preview1 listeners · 1 render to settle · 2856 µs in jsdom, held exactly but for the clock

Why it is built this way

It is the dialog minus one word, and every difference follows from that word: no veil, nothing made inert, no scroll lock — the page behind goes on answering, which is the whole point of a panel that hangs off a control instead of standing over the page. What it keeps is the part a panel with content cannot do without: it takes focus, it says what it is, and it hands focus back to the trigger on the way out (0031).

Decisions behind it

  • 0066A panel is a layer or a region, and the consumer says which
  • 0031A panel's tab order belongs to its trigger
  • 0029A modal is an overlay, not a <dialog> — the platform's modal is not composable with ours
  • 0024The overlay layer carries what an overlay severs, and borrows the closing stack
  • 0025A panel says whether it takes focus, and the rest of the focus layer waits for a consumer
  • 0013No headless core / skin split
  • lesson-93The outside press and the trigger's press are one event
  • lesson-35The control handed the wrapper its frame but not its panel

Known limitations

  • One trigger per popover. Focus comes back to "the trigger", so two controls opening one panel would make that "whichever registered last". Reported in dev mode rather than repaired: the fix is a popover each, and the library cannot know which of the two the author meant.
  • No close button. A dialog draws one because its trigger may be anywhere and the veil hides the page; a popover always has a live trigger beside it, and Escape, a press outside and Tab are three more ways out. A panel that needs an explicit answer puts a button in its own content — (click)="panel.open.set(false)" — which is also why the component carries no string to translate.
  • It is not a place to park — unless it is inline. Over the page, Tab is a way out, so a panel the user is meant to leave and come back to is not this component (0031). Drawn in the page it is exactly that shape, because the tab order it stands in is the page's own. What still sends a reader to the drawer is the other half of 0047: a drawer is docked to an edge of the window and is still text in the document when it is shut (hidden="until-found", so find-in-page reaches it), where an inline popover is in the flow where it was written and is gone entirely while open is false.
  • No arrow. A pointer drawn from the panel to the trigger is one more thing to position and repaint on every flip; the 8 px gap and the placement say the same thing more cheaply. The same call as the tooltip's.
  • A popover inside a popover is not a pattern here. Nothing forbids it and the closing stack orders the two correctly, but the focus return of the inner one lands on a trigger inside the outer one, and nothing measures that arrangement. A menu with submenus is the menu's problem, and it is a different component.