📖 Developer Guide

How to Build a Popover in Angular — and What Stays Hard Afterwards

Angular gives you more popover machinery than any other framework, which is exactly how Angular popovers go wrong: a backdrop that swallows the trigger click, a scroll strategy nobody chose, and a position list with one entry. This guide builds the version that behaves at every corner of the viewport — and is honest about what is still hard when it works.

📅 Updated September 2026 ⏱ 13 min read ✍️ By Kompassify
The four decisions behind an Angular popover — the CDK Overlay or native shell, the ordered position list, the dismissal contract and the ARIA wiring — shown as a stack

Angular gives you more popover machinery than any other framework, which is exactly why Angular popovers go wrong in a particular way. The CDK Overlay can do everything, so the component ends up doing everything: a backdrop that swallows clicks, a scroll strategy nobody chose, a position list copied from a Stack Overflow answer, and a subscription that outlives the component.

The symptoms are familiar. The panel opens in the wrong place near the bottom of the viewport. It stays pinned in mid-air after the page scrolls. Clicking the trigger a second time closes and immediately reopens it. Nothing announces to a screen reader that anything happened.

This guide builds an Angular popover as four explicit decisions — the shell, the position list, the dismissal contract, the ARIA wiring — and then lists what stays hard once it works.

Key Takeaways

  • Pick the shell deliberately. CDK Overlay for control and legacy support; the native popover attribute when you want the top layer and light dismiss for free.
  • The position array is the API. A preferred position plus ordered fallbacks beats any amount of manual measuring.
  • An invisible backdrop is not free. It eats the click that would have hit the trigger, which is why the panel flickers closed and open again.
  • Choose a scroll strategy. The default repositions forever; a popover anchored to a row in a long table should usually close instead.
  • A popover does not trap focus. If it must, you wanted a dialog.
  • A UI popover is not an onboarding popover. Same rectangle, different lifecycle, different owner.

Popover, Tooltip, Modal: Three Different Components

These three get merged in design systems and then argued about for a year. The axis that separates them is who controls the page while it is open.

Tooltip Popover Modal dialog
Opened by Hover or focus A deliberate click or key press A deliberate action
Contains A short string, nothing focusable Anything, including inputs A whole task
Rest of the page Usable Usable Inert
Focus Never moves May move in, never trapped Moves in and is trapped
Angular building block MatTooltip or custom — see the Angular tooltip guide cdkConnectedOverlay Dialog / MatDialog

The tell: the moment you add cdkTrapFocus and a visible backdrop to your popover, you have built a dialog out of the wrong primitive. Use the CDK's Dialog before the keyboard semantics become a bug report.


1. The Shell: CDK Overlay or the Native Popover

Angular now has two credible answers, and the right one depends on what you need to control.

CDK Overlay when…

  • you need a position list with ordered fallbacks and push behaviour;
  • you need scroll strategies, backdrops and detachment as first-class concepts;
  • you are rendering a component, not a template fragment;
  • you support browsers without the popover API.

Native popover when…

  • you want the top layer, so clipping and z-index stop being your problem;
  • light dismiss and Escape should be the browser's job;
  • the panel is simple markup inside the same component;
  • you want it to work before hydration.

The CDK version is declarative and lives entirely in the template. Nothing here is imperative, which is what keeps it out of trouble:

<button #trigger="cdkOverlayOrigin" cdkOverlayOrigin type="button" [attr.aria-expanded]="open()" aria-controls="filters-panel" (click)="open.set(!open())" >Filters</button> <ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="trigger" [cdkConnectedOverlayOpen]="open()" [cdkConnectedOverlayPositions]="positions" [cdkConnectedOverlayScrollStrategy]="scrollStrategy" [cdkConnectedOverlayHasBackdrop]="true" cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" (backdropClick)="open.set(false)" (detach)="open.set(false)" > <div id="filters-panel" class="popover-panel"> <ng-content /> </div> </ng-template>

A signal for open rather than a plain boolean matters more than it looks: the overlay can detach on its own — a scroll strategy fired, the origin left the DOM — and the (detach) handler is what writes that back into your state. Without it the trigger needs two clicks after every self-dismissal, which is the single most reported Angular popover bug.


2. The Position List Is the Component's Real API

FlexibleConnectedPositionStrategy walks your array in order and uses the first position that fits. That array — not the CSS — is where a popover's behaviour actually lives, and writing it out explicitly is the difference between a panel that behaves near the bottom of a viewport and one that does not.

import { ConnectedPosition } from '@angular/cdk/overlay' positions: ConnectedPosition[] = [ // preferred: below the trigger, left edges aligned { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 8 }, // no room below -> flip above { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -8 }, // near the right edge -> align right edges instead { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 8 }, { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -8 }, ]

Two modifiers change how that list is read. withPush(true) (the default) nudges the panel back into the viewport instead of moving to the next fallback — convenient, but it means the panel can end up visibly detached from its trigger, so turn it off when the connection matters. withFlexibleDimensions(true) lets the CDK shrink the panel to fit rather than reposition it, which is what you want for a long list and wrong for a fixed-size form.

How the strategy chooses a position position[0] below, start-aligned fits? → done position[1] above, start-aligned fits? → done position[2…n] end-aligned variants fits? → done nothing fits withPush → nudge in else → best-effort The order encodes your design intent Listing only one position does not mean “always below” — it means “below, then push it wherever it fits”. A one-entry position array is the most common cause of a popover floating away from its trigger.

Write the fallbacks you want; the CDK will not invent them for you.


3. Dismissal: The Backdrop and the Scroll Strategy

Two CDK defaults cause most of the reported weirdness, and both are one line to fix once you know what they are.

The transparent backdrop eats the trigger click

With hasBackdrop, the CDK inserts a full-viewport element above the page. Click the trigger while the panel is open and that click hits the backdrop, not the button: the panel closes, then your (click) never fires — or, if the backdrop is not there, it fires and reopens what you just closed. That is the flicker.

Pick one contract and hold it. Either use the backdrop and let (backdropClick) be the only close path — the trigger's own handler becomes open-only — or drop the backdrop and handle outside clicks yourself, excluding the trigger's own element from the check.

// no backdrop: own the outside click, and exclude the trigger private host = inject(ElementRef<HTMLElement>) @HostListener('document:click', ['$event']) onDocumentClick(e: MouseEvent) { if (!this.open()) return const target = e.composedPath()[0] as Node // composedPath: shadow DOM safe if (this.host.nativeElement.contains(target)) return if (this.panel?.nativeElement.contains(target)) return this.open.set(false) }

The default scroll strategy is rarely the right one

reposition() is the CDK's default: the panel follows its origin forever. In a long table that means a filter popover that stays glued to a row you can no longer see, or hovers over unrelated content once the origin scrolls out of view. Usually you want one of the other two.

Strategy Behaviour on scroll Use it for
reposition() Follows the origin Short pages, panels anchored to fixed chrome
close() Detaches the overlay Row menus and cell popovers in long tables
block() Prevents page scroll entirely Almost never for a popover — that is modal behaviour
private sso = inject(ScrollStrategyOptions) scrollStrategy = this.sso.close() // and handle (detach) to reset your signal

4. ARIA and Keyboard: A Short, Non-Negotiable List

A popover is not a dialog, so it needs far less than the CDK's a11y module offers. What it does need:

Resist adding role="dialog" to make a screen reader announce the panel. It announces it as a dialog, which promises modality you are not providing. Leave the role off for a panel of form controls; use role="menu" only if you are also implementing arrow-key roving focus and menuitem children, because the role is a promise about the keyboard.


What Stays Hard After It Works

Change detection

An overlay rendered from a template attaches outside the host component's view. With OnPush, content that changes from a callback the CDK invoked can render stale. Signals largely fix this; if you are still on Subject-driven state, expect a markForCheck() in the close path.

Leaks on destroy

backdropClick(), keydownEvents() and positionChanges are long-lived streams. An overlay that is detached but not dispose()d keeps its subscriptions and its DOM. Use takeUntilDestroyed() and dispose in the destroy hook.

Nesting

A select or datepicker opened from inside a popover creates a second overlay whose backdrop sits above the first. Clicking inside the child then closes the parent. You have to teach the parent that the child's DOM counts as "inside" — which is why composedPath and an explicit panel reference are worth the effort.

Testing

Overlays render outside the fixture's own element, so fixture.debugElement.query finds nothing. Query the overlay container, and remember that jsdom implements neither the top layer nor light dismiss — native-popover dismissal needs a real browser.


A UI Popover Is Not an Onboarding Popover

The component ships, and the next ticket asks for "a popover that points new users at the export button". Same rectangle, different product.

An onboarding panel anchored to a control in a real product interface, pointing a new user at a feature they have not used

A panel anchored to a control — but opened because of who the user is, not because they clicked.

UI popover (what you just built) Onboarding popover
Opens because The user clicked a control This user has not done something yet
Audience Everyone, identically A segment: plan, role, tenure, feature usage
State One interaction Seen / dismissed / completed, per user, persisted
Sequencing None Step 3 of 6, resumed days later
Changes Rarely, in a release Weekly, by a non-engineer, with no release
Success is It positioned correctly Activation moved — which means measuring it

The positioning work carries over. Segment targeting, per-user persistence, sequencing, per-step analytics and a copy loop that does not touch your release train do not — and those are the expensive parts. That is a contextual help system rather than a UI component, which is the honest framing of the build-versus-buy decision.

Keep the component. Skip the content pipeline.

Kompassify lets product and onboarding teams add popovers, tooltips, hotspots, checklists and guided tours to an Angular app without shipping a release for every copy change — targeted by segment, with adoption data on each one. Free up to 100 monthly active users, plans from $129/month, GDPR-compliant and EU-hosted.

Start for free →

Build It, or Configure It?

Both — the line is drawn by who edits it and how often, not by how it looks.

Build the component

  • Menus, filter panels, pickers, column choosers — the product's own interface.
  • Anything a designer specified and a developer maintains.
  • Anything that must obey your design tokens and work offline.

Configure the guidance

  • Anything targeted at a segment rather than everyone.
  • Anything whose copy will change without a deploy.
  • Anything you need completion data for, step by step.
A dashboard showing per-step completion rates and drop-off for an in-app guidance sequence

Per-step completion is the question a UI popover cannot answer about itself.

Teams that build both quietly acquire a second product — an under-instrumented onboarding platform — as a side effect of having written a popover.


A Pre-Ship Checklist

  1. Trigger is a real <button type="button"> with aria-expanded and aria-controls.
  2. Position array has explicit fallbacks, and the panel is verified at all four viewport corners.
  3. withPush chosen deliberately, not inherited.
  4. Scroll strategy chosen deliberately; (detach) writes back into your open state.
  5. One dismissal contract: backdrop or a document listener that excludes the trigger.
  6. Clicking the trigger while open closes once — no flicker, no double-click to reopen.
  7. Escape closes it, and focus returns to the trigger.
  8. Overlays are disposed and streams unsubscribed on destroy.
  9. A nested overlay (select, datepicker) does not close the parent.
  10. Nothing essential lives only inside the popover.

The One-Sentence Version

Build an Angular popover as four deliberate decisions — CDK Overlay or the native popover for the shell, an ordered position list instead of manual measuring, exactly one dismissal contract, and the short ARIA list that does not pretend to be a dialog — and recognise that the moment the panel needs to know which user is looking at it, you have left UI and entered onboarding.

If the next ticket is a sequence of these pointing at five features for trial users only, read the product tour guide first — and the hotspot UX guide if the trigger is meant to be a pulsing dot rather than a button. The same pattern in other frameworks: React and Vue 3.


Frequently Asked Questions

How do you create a popover in Angular?

Use the CDK Overlay's declarative form: put cdkOverlayOrigin on the trigger button, put cdkConnectedOverlay on an ng-template holding the panel, and bind cdkConnectedOverlayOpen to a signal. Supply an explicit cdkConnectedOverlayPositions array — a preferred position plus ordered fallbacks — so the panel behaves near every edge of the viewport, and choose a scroll strategy rather than accepting the default reposition. Handle the overlay's (detach) event by writing false back into your open signal, because the overlay can close itself. Then wire aria-expanded and aria-controls on the trigger and return focus to it on close. If you do not need the CDK's control and can rely on modern browsers, the native popover attribute gives you the top layer, light dismiss and Escape with no library at all.

Why does my Angular popover close and immediately reopen?

Because the trigger click is being counted twice, or is hitting the backdrop instead of the button. With cdkConnectedOverlayHasBackdrop the CDK inserts a full-viewport element above the page: clicking the trigger while the panel is open hits the backdrop, which closes the overlay, and then your own click handler toggles it back open. The fix is to pick one dismissal contract. Either keep the backdrop and make (backdropClick) the only close path, leaving the trigger handler open-only, or drop the backdrop and write a document click listener that ignores clicks inside the trigger's host element and inside the panel.

Should I use the CDK Overlay or the native popover attribute in Angular?

Use the CDK Overlay when you need control: an ordered position list with fallbacks and push behaviour, scroll strategies, backdrops, attaching a whole component rather than a template, or support for browsers without the popover API. Use the native popover attribute when you want the browser to do the hard parts: it promotes the panel to the top layer, so no ancestor's overflow can clip it and no z-index can stack over it, and it brings light dismiss and Escape for free. For a simple panel of markup inside one component, the native version is far less code; for a design-system primitive with a documented position API, the CDK still earns its place.

Which scroll strategy should an Angular popover use?

Rarely the default. reposition() keeps the panel glued to its origin forever, which in a long table means a popover that follows a row you can no longer see, or floats over unrelated content once its origin scrolls away. For row menus and cell popovers, close() is almost always right: the overlay detaches on scroll, and your (detach) handler resets the open state. block() prevents page scrolling entirely, which is modal behaviour and wrong for a popover. Reserve reposition() for short pages or panels anchored to fixed chrome like a header.

How do you make an Angular popover accessible?

The list is short but not optional. Use a real button element with type="button" as the trigger, because a div with a click handler cannot be reached by keyboard and no ARIA attribute repairs that. Bind [attr.aria-expanded] to the open state and point aria-controls at the panel's id. Make Escape close it — free with the native popover, or via the overlay's keydownEvents() with the CDK. Return focus to the trigger when it closes, including when the overlay detached by itself. Do not add cdkTrapFocus and do not add role="dialog": both promise modality you are not providing, and the rest of the page staying reachable is precisely what makes this a popover rather than a dialog.

What is the difference between a UI popover and an onboarding popover?

A UI popover is user-invoked and stateless: someone clicked a control, it opens, everyone sees the same thing, and it is finished after one interaction. An onboarding popover is proactive and stateful: it appears because a particular user has not done a particular thing yet, it is targeted by segment, it is often one step in a sequence that must resume days later, and it needs per-user seen and dismissed state plus per-step completion analytics. The positioning work transfers between them; the targeting, persistence, sequencing and the weekly copy changes do not, and those are the parts that consume a roadmap. That is why product and onboarding teams usually configure in-app guidance in a dedicated tool rather than shipping it through the front-end release cycle.