Skip to content
Ndop UI

Popover

Displays rich content in a portal, triggered by a button.

import { Component } from '@angular/core';
import { Button } from '@/ui/button/button';
import { Input } from '@/ui/input/input';
import { Label } from '@/ui/label/label';
import {
  Popover,
  PopoverPortal,
  PopoverTrigger,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
} from '@/ui/popover/popover';

/** A popover holding a small dimensions form. */
@Component({
  selector: 'example-popover-demo',
  imports: [
    Popover,
    PopoverTrigger,
    PopoverPortal,
    PopoverContent,
    PopoverHeader,
    PopoverTitle,
    PopoverDescription,
    Button,
    Input,
    Label,
  ],
  template: `
    <ng-container ndPopover>
      <button ndButton ndPopoverTrigger variant="outline">Open popover</button>
      <ng-template ndPopoverPortal>
        <div ndPopoverContent class="w-80" aria-labelledby="popover-demo-title">
          <div class="grid gap-4">
            <div ndPopoverHeader>
              <h4 ndPopoverTitle id="popover-demo-title">Dimensions</h4>
              <p ndPopoverDescription>Set the dimensions for the layer.</p>
            </div>
            <div class="grid gap-2">
              @for (field of fields; track field.id) {
                <div class="grid grid-cols-3 items-center gap-4">
                  <label ndLabel [for]="field.id">{{ field.label }}</label>
                  <input
                    ndInput
                    [id]="field.id"
                    [value]="field.value"
                    class="col-span-2 h-8"
                  />
                </div>
              }
            </div>
          </div>
        </div>
      </ng-template>
    </ng-container>
  `,
})
export class PopoverDemoExample {
  protected readonly fields = [
    { id: 'popover-demo-width', label: 'Width', value: '100%' },
    { id: 'popover-demo-max-width', label: 'Max. width', value: '300px' },
    { id: 'popover-demo-height', label: 'Height', value: '25px' },
    { id: 'popover-demo-max-height', label: 'Max. height', value: 'none' },
  ];
}

A popover shows rich, interactive content next to the element that opens it, such as a small form or a list of filters. It is non-modal: the rest of the page stays usable, and clicking outside or pressing Escape closes it, returning focus to the trigger. side and align place it, using logical start and end so it mirrors in right-to-left layouts.

Installation

npx @ndop/cli add popover

Usage

import { Button } from '@/ui/button/button';
import { Input } from '@/ui/input/input';
import { Label } from '@/ui/label/label';
import {
  Popover,
  PopoverPortal,
  PopoverTrigger,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
} from '@/ui/popover/popover';
<ng-container ndPopover>
  <button ndButton ndPopoverTrigger variant="outline">Open popover</button>
  <ng-template ndPopoverPortal>
    <div ndPopoverContent class="w-80" aria-labelledby="popover-demo-title">
      <div class="grid gap-4">
        <div ndPopoverHeader>
          <h4 ndPopoverTitle id="popover-demo-title">Dimensions</h4>
          <p ndPopoverDescription>Set the dimensions for the layer.</p>
        </div>
        <div class="grid gap-2">
          @for (field of fields; track field.id) {
            <div class="grid grid-cols-3 items-center gap-4">
              <label ndLabel [for]="field.id">{{ field.label }}</label>
              <input
                ndInput
                [id]="field.id"
                [value]="field.value"
                class="col-span-2 h-8"
              />
            </div>
          }
        </div>
      </div>
    </div>
  </ng-template>
</ng-container>

Examples

Popover placement

Popovers on each logical side, with a close button inside. start and end follow the text direction.

import { Component } from '@angular/core';
import { Button } from '@/ui/button/button';
import {
  Popover,
  PopoverClose,
  PopoverPortal,
  PopoverTrigger,
  PopoverContent,
} from '@/ui/popover/popover';

/**
 * Popovers on each logical side, with a close button inside. `start` and
 * `end` follow the text direction.
 */
@Component({
  selector: 'example-popover-placement',
  imports: [
    Popover,
    PopoverTrigger,
    PopoverPortal,
    PopoverClose,
    PopoverContent,
    Button,
  ],
  template: `
    <div class="flex flex-wrap gap-2">
      @for (placement of placements; track placement.side) {
        <ng-container ndPopover>
          <button ndButton ndPopoverTrigger variant="outline">
            {{ placement.side }}
          </button>
          <ng-template ndPopoverPortal>
            <div
              ndPopoverContent
              class="w-56"
              [side]="placement.side"
              [align]="placement.align"
              [attr.aria-label]="'Placed ' + placement.side"
            >
              <p class="mb-3">Placed on the {{ placement.side }} side.</p>
              <button ndButton ndPopoverClose size="sm" variant="secondary">
                Done
              </button>
            </div>
          </ng-template>
        </ng-container>
      }
    </div>
  `,
})
export class PopoverPlacementExample {
  protected readonly placements = [
    { side: 'top', align: 'center' },
    { side: 'end', align: 'start' },
    { side: 'bottom', align: 'center' },
    { side: 'start', align: 'start' },
  ] as const;
}

Accessibility

Implements the dialog role pattern of the WAI-ARIA Authoring Practices.

Keyboard

Keys the component handles.

Key Action
EscapeCloses the popover and returns focus to the trigger.
TabMoves focus through the content; past the last element, the popover closes and focus moves to what follows the trigger.
ShiftTabMoves focus backwards through the content; before the first element, the popover closes and focus returns to the trigger.

Focus

  • On open: focus moves to the first focusable element in the content, or to the content element.
  • Focus is not trapped.
  • On close with Escape or PopoverClose: focus returns to the trigger.
  • On close because focus or a pointer left: focus stays where the user put it.

Right-to-left

side="start" and side="end" place the content on the inline start and end of the trigger, which swap in RTL; align="start" and align="end" swap likewise.

API reference

Popover

[ndPopover]

A popover (@ndop/primitives/popover): rich content anchored to its trigger, opened and closed with it. Put it on an ng-container; open is two-way bindable.

Popover binding Type Default
[defaultOpen]
Initial open state when open is not bound.
booleanfalse
[(open)]
Whether the popover is open. Two-way bindable with [(open)].
booleanfalse

PopoverTrigger

[ndPopoverTrigger]

The button that opens and closes its popover.

Manages aria-haspopup, aria-expanded, aria-controls.

Data attribute Values
data-slotpopover-trigger
data-state —

PopoverPortal

ng-template[ndPopoverPortal]

Marks the ng-template its popover renders while open.

PopoverClose

[ndPopoverClose]

A button that closes its popover.

Data attribute Values
data-slotpopover-close

PopoverContent

[ndPopoverContent]role dialog

The popover panel, placed next to its trigger.

PopoverContent binding Type Default
[class]
Extra classes, merged last so they win.
string''
[side]
Preferred side of the trigger; start and end swap in RTL.
'bottom' | 'top' | 'start' | 'end''bottom'
[align]
Preferred alignment along that side.
'start' | 'end' | 'center''center'
[sideOffset]
Gap from the trigger, in pixels.
number4
Data attribute Values
data-slotpopover-content
data-state —
data-side —
data-align —

PopoverHeader

[ndPopoverHeader]

Layout for the popover's title and description.

PopoverHeader binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotpopover-header

PopoverTitle

[ndPopoverTitle]

The popover's title. Point the content's aria-labelledby at it.

PopoverTitle binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotpopover-title

PopoverDescription

[ndPopoverDescription]

Supporting text under the popover's title.

PopoverDescription binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotpopover-description

Agent notes

Do

  • Name the content with aria-labelledby pointing at a heading inside it, or with aria-label.
  • Keep the content short and related to its trigger.
  • Use start and end rather than left and right, so the placement follows the text direction.

Don't

  • Don't use a popover for a decision that must block the page; use a dialog.
  • Don't put a popover's only copy of important information behind it; keep it reachable on the page too.