forty-cdk
llms.txt

Primitives

Popover

A non-modal floating panel anchored to its trigger by floating-ui, dismissed on Escape, pointer-down outside or focus outside.

forty-cdk/popover WAI-ARIA APG

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

PropertyTypeDescription
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 attributeValues
data-stateopen | closed
data-disabledpresent | absent
data-reduced-motionpresent | 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

PropertyTypeDescription
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 attributeValues
data-stateopen | closed
data-disabledpresent | absent

ForPopoverContent

Data attributeValues
data-stateopen | closed
data-reduced-motionpresent | absent

ForPopoverArrow

Data attributeValues
data-popover-arrowpresent

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).

KeyLibrary fallbackMeaning
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.
sideOffset8Main-axis gap (px) for popovers that don't set sideOffset themselves.
collisionPadding8Collision-middleware padding (px) for popovers that don't set it themselves.
arrowPadding0Arrow 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, focusOutside fires 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 pass ariaLabel. 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 (matches role="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:

ElementCustom propertyType / rangeDirectionMeaning
[forPopoverContent]--for-floating-anchor-widthpxoutTrigger (reference) width. Match it with width: var(--for-floating-anchor-width).
[forPopoverContent]--for-floating-anchor-heightpxoutTrigger (reference) height.
[forPopoverContent]--for-floating-available-widthpxoutSpace available along the inline axis (floating-ui size middleware). Clamp with max-width.
[forPopoverContent]--for-floating-available-heightpxoutSpace available along the block axis. Clamp with max-height.
[forPopoverContent]--for-floating-content-transform-origin<origin> keywordsouttransform-origin matching the resolved side / align, so a scale enter animation pivots from the trigger.
[forPopoverArrow]--for-floating-arrow-offsetpx (default 0px)inConsumer-set. How far the arrow pokes out past the popover edge, typically a negative px (e.g. -4px).

[forPopoverContent] portals to document.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.body on first render. CSS scoped to ancestors won't reach it, so use global styles or classes.
  • Mount equals open: wrap [forPopoverContent] in @if on the open state, so mount and unmount drive animate.enter / animate.leave.
  • Trigger exemption: clicks on the trigger never fire pointerDownOutside or interactOutside. Their only effect is the trigger's own toggle.
  • Anchor vs. trigger: [forPopoverAnchor] only changes the floating-ui reference. The trigger keeps aria-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 receives focusin against a stable layout.
  • Arrow offset: [forPopoverArrow] writes position: absolute, the floating-ui-resolved left / top, and var(--for-floating-arrow-offset, 0px) on the side opposite the popover (so the arrow points back at the trigger). Set --for-floating-arrow-offset on the arrow element (or any ancestor) to control how far the arrow pokes out. A negative px value such as -4px is typical. Defaults to 0px (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.