Primitives
Popover
A non-modal floating panel anchored to its trigger by floating-ui, dismissed on Escape, pointer-down outside or focus outside.
Open the popover from the trigger. Escape and an outside click close it, focus returns to the trigger, and data-side says which side it landed on.
A popover is a non-modal dialog: focus moves into the surface on open and returns to the trigger on close, but Tab is allowed to leave (no focus trap). For a modal version, use [forDialog]. For a non-interactive label that follows the cursor / focus, use [forTooltip].
New to overlays in forty-cdk? Your first overlay walks a Popover from empty markup to styled-and-animated and explains the
@if/ open-state model and the portal → global CSS rule.
When to choose
Popover is the one floating surface that opens on activation, takes focus and still leaves the page usable. That combination is what separates it from both the modal surfaces and the hover ones.
- Popover: non-modal
role="dialog", opened from its trigger. Focus moves into the surface and returns on close, but Tab may leave and nothing behind it is inert or scroll-locked. - Dialog / Drawer: modal. Choose either when the rest of the page must be unreachable until the task ends; Drawer adds the edge anchoring, swipe-to-dismiss and snap points.
- Tooltip: opens on hover or focus and never takes focus. Its content must be non-interactive because it is the trigger's description, wired with
aria-describedby. - Hover Card: opens on hover or focus and may hold interactive content, but it adds no ARIA relationship to its trigger. Choose it only for a preview whose trigger already stands on its own.
- For a list of commands reach for Dropdown Menu, and for a value picked from options Select:
role="dialog"announces neither.
Anatomy
<div forPopover #popover="forPopover" side="bottom" align="center">
<button forPopoverTrigger class="popover-trigger">
Settings
<span class="chevron" aria-hidden="true"></span>
</button>
<!-- @if (popover.open()) { -->
<div forPopoverContent>
<h3 forPopoverTitle>Display</h3>
<p forPopoverDescription>Adjust theme and density.</p>
<button forPopoverClose>Done</button>
<span forPopoverArrow></span>
</div>
<!-- } -->
</div>
Examples
Anchor & arrow
The element that opens the popover and the element it points at can differ: the button is the trigger, but [forPopoverAnchor] on the highlighted phrase is what floating-ui positions against.
Your plan renews on the 1st of next month and you can change it anytime.
Positioning & collisions
The trigger sits in a tight, scrollable frame. sideOffset nudges the surface off the trigger and collisionPadding reserves a margin from the edge before flip / shift kick in. Scroll the frame to see it react.
API
ForPopover
| Property | Type | Description |
|---|---|---|
open | model | Two-way bindable visibility. Default: — |
side | input | Anchor side ('top' / 'right' / 'bottom' / 'left'). Falls back to provideForPopoverDefaults.Default: 'bottom' |
align | input | Alignment along the chosen side ('start' / 'center' / 'end'). Falls back to provideForPopoverDefaults.Default: 'center' |
sideOffset | input | Gap (px) between trigger and content along the main axis. Falls back to provideForPopoverDefaults.Default: 8 |
alignOffset | input | Gap (px) along the cross axis (parallel to side).Default: 0 |
collisionPadding | input | Padding (px) for the flip / shift / size collision middlewares. Falls back to provideForPopoverDefaults.Default: 8 |
arrowPadding | input | Padding (px) keeping [forPopoverArrow] clear of the content edges. Falls back to provideForPopoverDefaults.Default: 0 |
disabled | input | When true, trigger does not toggle.Default: false |
dismissible | input | When false, Escape / outside-pointer / outside-focus do not close.Default: true |
lightDismiss | input | 'stack' also closes it when the overlay above closes on a press. See Stacked overlays.Default: 'topmost' |
returnFocus | input | Focus returns to the trigger on close. Default: true |
initialFocus | input | 'first' (first focusable inside content) or 'container' (the content host).Default: 'first' |
ariaLabel | input | Manual aria-label on the content when no [forPopoverTitle] is rendered.Default: null |
escapeKeyDown | OutputEmitterRef | Output. Fires on Escape while this popover is the topmost dismissible layer. Default: — |
pointerDownOutside | OutputEmitterRef | Output. Fires on pointer-down outside the content (and outside the trigger). Default: — |
focusOutside | OutputEmitterRef | Output. Fires when focus moves outside the content (and outside the trigger). Default: — |
interactOutside | OutputEmitterRef | Output. Composite: fires alongside both pointerDownOutside and focusOutside (and shares their veto state).Default: — |
autoFocusOnOpen | OutputEmitterRef | Output. Fires just before focus moves into the popover on mount. preventDefault() skips the move.Default: — |
autoFocusOnClose | OutputEmitterRef | Output. Fires just before focus returns to the trigger on unmount. preventDefault() skips the return-focus.Default: — |
openChange | OutputEmitterRef | Output. Implicit from model(). Emits only on internal transitions, not on consumer writes via [(open)].Default: — |
| Data attribute | Values | |
|---|---|---|
data-state | open | closed | |
data-disabled | present | absent | |
data-reduced-motion | present | absent |
The dismiss outputs and the auto-focus pair are vetoable: each receives a VetoableEvent (or VetoableNativeEvent<E> when there is a native DOM event to surface). Call preventDefault() on the emitted veto to suppress the automatic close / focus move; the original DOM event, when present, is on .event.
(autoFocusOnOpen) / (autoFocusOnClose) are output-shape because Popover always routes close transitions through [(open)] (via the implicit openChange emitter). Dialog and Drawer take callback-shape inputs for the same pair instead: either can be closed by a direct open.set(false) that bypasses the (dismiss) output entirely, so their close hook has to be a stored function reference that still runs during teardown.
Open without stealing focus
<div forPopover [(open)]="open">
<input forPopoverAnchor #q type="search" (input)="open.set(true)" placeholder="Search…" />
<button forPopoverTrigger hidden></button>
@if (open()) {
<div
forPopoverContent
(autoFocusOnOpen)="$event.preventDefault()"
(autoFocusOnClose)="$event.preventDefault()"
>
…
</div>
}
</div>
The popover opens / closes alongside the input but never steals focus from it. That behavior is handy for live-search panels where every keystroke matters.
ForPopoverTrigger
| Property | Type | Description |
|---|---|---|
disabled | input | Disables this trigger only and is merged OR with the root's disabled. The effective state drives the native disabled attribute, data-disabled and the click guard, but not aria-disabled (one channel only).Default: false |
| Data attribute | Values | |
|---|---|---|
data-state | open | closed | |
data-disabled | present | absent |
ForPopoverContent
| Data attribute | Values | |
|---|---|---|
data-state | open | closed | |
data-reduced-motion | present | absent |
ForPopoverArrow
| Data attribute | Values | |
|---|---|---|
data-popover-arrow | present |
ForPopoverInitialFocus
[forPopoverInitialFocus] marks the element inside [forPopoverContent] that receives focus when the popover opens, in place of the one initialFocus picks: a primary action rather than the close button leading the header, for instance. It takes no inputs. When the marked element is missing, disabled or hidden at mount, focus falls back to initialFocus; a vetoed (autoFocusOnOpen) still skips the move. Mark one element per popover: a second marker warns in dev mode and only the newest is used.
Scoped defaults
provideForPopoverDefaults configures positioning defaults for an injector subtree, either at the application root or in any component's providers array. Partial overrides inherit unspecified keys from the parent scope (or the library fallbacks at the root).
| Key | Library fallback | Meaning | |
|---|---|---|---|
side | 'bottom' | Anchor side for popovers that don't set side themselves. | |
align | 'center' | Alignment along side for popovers that don't set align themselves. | |
sideOffset | 8 | Main-axis gap (px) for popovers that don't set sideOffset themselves. | |
collisionPadding | 8 | Collision-middleware padding (px) for popovers that don't set it themselves. | |
arrowPadding | 0 | Arrow padding (px) for popovers that don't set it themselves. |
Per-instance inputs always win over the scope defaults.
import { provideForPopoverDefaults } from 'forty-cdk/defaults';
// Top-anchored popovers app-wide
bootstrapApplication(App, {
providers: [provideForPopoverDefaults({ side: 'top', sideOffset: 4 })],
});
// component-level override layers on top, per key
@Component({
providers: [provideForPopoverDefaults({ align: 'start' })],
...
})
class Toolbar {}
Keyboard
- Tab / Shift+Tab moves focus through the popover and beyond (no trap). When focus leaves,
focusOutsidefires and the popover closes unless prevented. - Escape closes when
dismissible. Use(escapeKeyDown)="$event.preventDefault()"to ask "are you sure?" first. - Enter / Space on the trigger toggles (native button behavior).
Accessibility
Implements the WAI-ARIA Modeless Dialog pattern.
- Always provide an accessible name: render a
[forPopoverTitle]or passariaLabel. A[forPopoverContent]that mounts with neither logs a dev-mode warning (FORCDK-CORE-011) after its first render, because a WCAG-tagged audit does not report the missing name. [forPopoverDescription]is optional. Use it for explanatory copy beyond the title.aria-haspopup="dialog"advertises the popover as a dialog (matchesrole="dialog"on the content). For menus or listboxes, build a different primitive.- The popover is not modal: assistive tech users can still navigate around it. That's intentional, because modeless surfaces should not interrupt.
Styling
forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-* attributes listed under API, not off the for* selectors (Styling forty-cdk explains why).
CSS custom properties
[forPopoverContent] is portaled to document.body and gets its position resolved by floating-ui. It exposes that geometry as custom properties on the content host (cleared on close), and [forPopoverArrow] reads the consumer-settable --for-floating-arrow-offset:
| Element | Custom property | Type / range | Direction | Meaning | |
|---|---|---|---|---|---|
[forPopoverContent] | --for-floating-anchor-width | px | out | Trigger (reference) width. Match it with width: var(--for-floating-anchor-width). | |
[forPopoverContent] | --for-floating-anchor-height | px | out | Trigger (reference) height. | |
[forPopoverContent] | --for-floating-available-width | px | out | Space available along the inline axis (floating-ui size middleware). Clamp with max-width. | |
[forPopoverContent] | --for-floating-available-height | px | out | Space available along the block axis. Clamp with max-height. | |
[forPopoverContent] | --for-floating-content-transform-origin | <origin> keywords | out | transform-origin matching the resolved side / align, so a scale enter animation pivots from the trigger. | |
[forPopoverArrow] | --for-floating-arrow-offset | px (default 0px) | in | Consumer-set. How far the arrow pokes out past the popover edge, typically a negative px (e.g. -4px). |
[forPopoverContent]portals todocument.body, so ancestor-scoped CSS can't reach it. Style it with global CSS or a class. See Styling floating content for the full positioner-property list and the floating-content rules.
.popover-trigger .chevron {
transition: transform 150ms;
}
.popover-trigger[data-state='open'] .chevron {
transform: rotate(180deg);
}
Reduced motion
[forPopover] and [forPopoverContent] reflect data-reduced-motion (present / absent) whenever the OS prefers-reduced-motion: reduce media query matches, so you can opt your own animate.enter / animate.leave and CSS transitions out without re-deriving the query. The attribute flips reactively if the preference changes mid-session. The popover toggles open / closed synchronously on click, so there is no JS-coordinated timing to skip. Only the visual transitions (which are yours) opt out.
.popover[data-reduced-motion] {
transition: none;
}
Behavior notes
- Portal: the content is moved to
document.bodyon first render. CSS scoped to ancestors won't reach it, so use global styles or classes. - Mount equals open: wrap
[forPopoverContent]in@ifon the open state, so mount and unmount driveanimate.enter/animate.leave. - Trigger exemption: clicks on the trigger never fire
pointerDownOutsideorinteractOutside. Their only effect is the trigger's own toggle. - Anchor vs. trigger:
[forPopoverAnchor]only changes the floating-ui reference. The trigger keepsaria-controls/aria-expanded, the click toggle, and focus return on close. The anchor is not exempt from outside dismissal: clicking it is treated as outside. - Non-modal: no focus trap, no body scroll lock, no
aria-modal. If you need modal semantics, use[forDialog]instead. - No backdrop: popovers don't render an overlay. Outside dismissal is event-driven.
- Focus return: on unmount, focus is sent back to the registered trigger element (unless
returnFocus="false"). The one exception is an outside-interaction close, meaning a pointer-down or focus-out that lands outside the popover. After such a close, focus stays where the interaction moved it instead of snapping back to the trigger, matching[forDropdownMenu](so a popover on a trigger that also carries a tooltip doesn't rip focus back and re-open that tooltip). Escape and programmatic closes still return focus. The return happens before the portal helper removes the node, so the trigger receivesfocusinagainst a stable layout. - Arrow offset:
[forPopoverArrow]writesposition: absolute, the floating-ui-resolvedleft/top, andvar(--for-floating-arrow-offset, 0px)on the side opposite the popover (so the arrow points back at the trigger). Set--for-floating-arrow-offseton the arrow element (or any ancestor) to control how far the arrow pokes out. A negativepxvalue such as-4pxis typical. Defaults to0px(flush with the popover edge); the helper ships no default visual.
Stacked overlays
An outside press reaches one overlay by default: whichever is stacked on top. That is right for a popover inside a modal dialog: a press outside the popover closes the popover and leaves the dialog alone. It is less right for two non-modal overlays side by side, such as a context menu opened over an open popover on the same chip: a press outside both closes the menu and leaves the popover open until a second press.
Set lightDismiss="stack" to close the popover on that first press too:
<div forContextMenu #chipMenu="forContextMenu" ariaLabel="Chip actions">
<div forPopover #filter="forPopover" lightDismiss="stack" ariaLabel="Status filter">
<button forPopoverTrigger forContextMenuTrigger>Status: open</button>
@if (filter.open()) {
<div forPopoverContent>…</div>
}
</div>
@if (chipMenu.open()) {
<div forMenuContent>…</div>
}
</div>
The popover then closes, with reason 'pointerDownOutside', when the overlay directly above it closed on the press and the press also landed outside the popover. It stays open when the press lands inside its content, when the overlay above vetoes through (pointerDownOutside) or (interactOutside), and when the overlay above is modal. Escape still closes one overlay per press.
#popover="forPopover" vs [(open)]
The minimal "click trigger → show content" case needs neither a separate open signal nor a two-way binding. [forPopover] is exportAs: 'forPopover', so expose the directive instance with a template reference variable (#popover="forPopover") and drive the @if straight off its own open() signal, as above. The trigger toggles it; Escape and outside dismissal flip it back.
Reach for the explicit [(open)]="mySignal" model binding only when the component class needs to read or drive open state (to open it programmatically, persist it, or react to it elsewhere):
<div forPopover [(open)]="open">
<button forPopoverTrigger>Settings</button>
@if (open()) {
<div forPopoverContent>…</div>
}
</div>
Triggers stamped from outside-declared templates
Angular resolves ng-template DI at the template's declaration site, not where it is stamped. A [forPopoverTrigger] declared in a template outside the root throws the orphan error even when the template is rendered inside the root via ngTemplateOutlet. For that case the selector attribute accepts the root reference as a value, routerLink-style. Grab it with #root="forPopover" and pass it through the outlet context. The bare valueless attribute keeps resolving via DI.
<div forPopover #root="forPopover">
<ng-container *ngTemplateOutlet="trig; context: { root }" />
@if (root.open()) {
<div forPopoverContent>…</div>
}
</div>
<ng-template #trig let-root="root">
<button [forPopoverTrigger]="root">Settings</button>
</ng-template>
Wrapping in a design system
Subclass the root and re-provide FOR_POPOVER_CONTEXT with useExisting pointing at the subclass, since Angular does not inherit a directive's providers; Wrapping non-form roots walks the pattern.