<bpdm/ui />
Overlay

Popover

A click-triggered floating panel — portaled, collision-aware, in React (@bpdm/ui) and Angular (@bpdm/ng).

The Popover is a click-triggered floating panel built on Radix — portaled (so it escapes overflow: hidden), collision-aware, and theme-aware. Pass a trigger and the panel content. Use PopoverClose inside to add a close/confirm button. In Angular it's the [bpdmPopover] directive pointing at an <ng-template>.

Usage

popover-usage.tsx
import { Popover } from '@bpdm/ui/popover';
import { Button } from '@bpdm/ui/button';

export function Basic() {
  return (
    <Popover trigger={<Button variant="secondary" appearance="outline">Open popover</Button>}>
      <p>A floating panel anchored to the trigger — click outside or press Esc to close.</p>
    </Popover>
  );
}
basic.ts
import { Component } from '@angular/core';
import { BpdmPopover, BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'basic',
  imports: [BpdmPopover, BpdmButton],
  templateUrl: './basic.html',
})
export class Basic {}
basic.html
<button bpdmButton variant="secondary" appearance="outline" [bpdmPopover]="panel">Open popover</button>

<ng-template #panel>
  <p>A floating panel anchored to the trigger — click outside or press Esc to close.</p>
</ng-template>

Placements

The side prop (top / right / bottom / left) sets which edge it opens from; align (start / center / end) shifts it along that edge. It's collision-aware, so it flips to stay in view.

popover-placements.tsx
import { Popover } from '@bpdm/ui/popover';
import { Button } from '@bpdm/ui/button';

export function Placements() {
  return (
    <>
      <Popover side="top" trigger={<Button>Top</Button>}><p>Opens on the top.</p></Popover>
      <Popover side="right" trigger={<Button>Right</Button>}><p>Opens on the right.</p></Popover>
      <Popover side="bottom" trigger={<Button>Bottom</Button>}><p>Opens on the bottom.</p></Popover>
      <Popover side="left" trigger={<Button>Left</Button>}><p>Opens on the left.</p></Popover>
    </>
  );
}
placements.ts
import { Component } from '@angular/core';
import { BpdmPopover, BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'placements',
  imports: [BpdmPopover, BpdmButton],
  templateUrl: './placements.html',
})
export class Placements {}
placements.html
<button bpdmButton bpdmPopoverSide="top" [bpdmPopover]="p1">Top</button>
<button bpdmButton bpdmPopoverSide="right" [bpdmPopover]="p2">Right</button>
<button bpdmButton bpdmPopoverSide="bottom" [bpdmPopover]="p3">Bottom</button>
<button bpdmButton bpdmPopoverSide="left" [bpdmPopover]="p4">Left</button>

<ng-template #p1><p>Opens on the top.</p></ng-template>
<ng-template #p2><p>Opens on the right.</p></ng-template>
<ng-template #p3><p>Opens on the bottom.</p></ng-template>
<ng-template #p4><p>Opens on the left.</p></ng-template>

Border

The panel has a subtle border by default so its edge reads clearly against any background. Set bordered={false} for a borderless panel that relies on the shadow alone.

popover-border.tsx
import { Popover } from '@bpdm/ui/popover';
import { Button } from '@bpdm/ui/button';

export function Borderless() {
  return (
    <>
      <Popover trigger={<Button>Bordered</Button>}>
        <p>Default — a subtle border defines the edge.</p>
      </Popover>
      <Popover bordered={false} trigger={<Button>Borderless</Button>}>
        <p>Borderless — relies on the shadow alone.</p>
      </Popover>
    </>
  );
}
border.ts
import { Component } from '@angular/core';
import { BpdmPopover, BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'border-example',
  imports: [BpdmPopover, BpdmButton],
  templateUrl: './border.html',
})
export class BorderExample {}
border.html
<button bpdmButton [bpdmPopover]="bordered">Bordered</button>
<button bpdmButton [bpdmPopover]="plain" [bpdmPopoverBordered]="false">Borderless</button>

<ng-template #bordered><p>Default — a subtle border defines the edge.</p></ng-template>
<ng-template #plain><p>Borderless — relies on the shadow alone.</p></ng-template>

With a form

Give the panel a fixed width and put fields inside; PopoverClose dismisses it on Save / Cancel.

popover-form.tsx
import { Popover, PopoverClose } from '@bpdm/ui/popover';
import { Button } from '@bpdm/ui/button';
import { Input } from '@bpdm/ui/input';

export function UpdateName() {
  return (
    <Popover width={260} trigger={<Button>Update name</Button>}>
      <div className="flex flex-col gap-3">
        <p className="text-sm font-medium">Display name</p>
        <Input defaultValue="Aria Lindqvist" aria-label="Display name" />
        <div className="flex justify-end gap-2">
          <PopoverClose asChild>
            <Button variant="secondary" appearance="ghost">Cancel</Button>
          </PopoverClose>
          <PopoverClose asChild>
            <Button>Save</Button>
          </PopoverClose>
        </div>
      </div>
    </Popover>
  );
}
update-name.ts
import { Component } from '@angular/core';
import { BpdmPopover, BpdmPopoverClose, BpdmButton, BpdmInput } from '@bpdm/ng';

@Component({
  selector: 'update-name',
  imports: [BpdmPopover, BpdmPopoverClose, BpdmButton, BpdmInput],
  templateUrl: './update-name.html',
})
export class UpdateName {}
update-name.html
<button bpdmButton [bpdmPopover]="panel" [bpdmPopoverWidth]="260">Update name</button>

<ng-template #panel>
  <div class="flex flex-col gap-3">
    <p class="text-sm font-medium">Display name</p>
    <input bpdmInput value="Aria Lindqvist" aria-label="Display name" />
    <div class="flex justify-end gap-2">
      <button bpdmButton variant="secondary" appearance="ghost" bpdmPopoverClose>Cancel</button>
      <button bpdmButton bpdmPopoverClose>Save</button>
    </div>
  </div>
</ng-template>

Internationalization

The Popover carries no built-in copy of its own — the panel content, any labels, and any close / confirm button are entirely author-supplied. So you localise a Popover by localising the content you pass into it; there is no messages prop to override (unlike the Dialog or Drawer).

The one string the component itself exposes is the panel's accessible name, ariaLabel (React ariaLabel, Angular [bpdmPopoverAriaLabel]). The panel is a dialog, so give it a name — either set ariaLabel or include a visible heading in the content. ariaLabel is the natural place to pass a translated label, and it's the only piece of Popover text you localise through a prop.

Under dir="rtl" the Popover is direction-aware with no extra prop: align="start" / align="end" and the collision-flip side mirror automatically (React via Radix, Angular via the CDK overlay's directionality), and the panel's padding is symmetric. side stays a physical edge you choose explicitly.

The example below keeps its content in English but passes a translated ariaLabel (and a short translated close label) — confining the localised copy to your own content:

popover-i18n.tsx
import { Popover, PopoverClose } from '@bpdm/ui/popover';
import { Button } from '@bpdm/ui/button';

export function AccountMenu() {
  return (
    <Popover
      // a translated accessible name for the panel's `dialog` role
      ariaLabel="Kontomenü"
      trigger={<Button variant="secondary" appearance="outline">Account</Button>}
    >
      <div className="flex flex-col gap-2">
        <p className="text-sm font-medium">Signed in as Aria</p>
        <PopoverClose asChild>
          <Button appearance="ghost">Schließen</Button>
        </PopoverClose>
      </div>
    </Popover>
  );
}
account-menu.ts
import { Component } from '@angular/core';
import { BpdmPopover, BpdmPopoverClose, BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'account-menu',
  imports: [BpdmPopover, BpdmPopoverClose, BpdmButton],
  templateUrl: './account-menu.html',
})
export class AccountMenu {}
account-menu.html
<!-- bpdmPopoverAriaLabel is a translated accessible name for the panel's dialog role -->
<button
  bpdmButton
  variant="secondary"
  appearance="outline"
  [bpdmPopover]="panel"
  bpdmPopoverAriaLabel="Kontomenü"
>
  Account
</button>

<ng-template #panel>
  <div class="flex flex-col gap-2">
    <p class="text-sm font-medium">Signed in as Aria</p>
    <button bpdmButton appearance="ghost" bpdmPopoverClose>Schließen</button>
  </div>
</ng-template>

API

PropTypeDefaultDescription
triggerReactNodeThe element that opens it (React). Angular: the host element carrying [bpdmPopover] (which points at the panel <ng-template>).
childrenReactNodePanel content (React). Angular: the <ng-template> referenced by [bpdmPopover].
sidetop | right | bottom | leftbottomEdge to open from. Angular: bpdmPopoverSide.
alignstart | center | endcenterAlignment along the side. Angular: bpdmPopoverAlign.
sideOffsetnumber8Gap from the trigger, in px. Angular: bpdmPopoverOffset.
widthnumber | stringfit-contentFixed panel width. Angular: bpdmPopoverWidth.
showArrowbooleanfalseDraw an arrow at the trigger. Angular: bpdmPopoverShowArrow.
borderedbooleantrueDraw a border around the panel; false for borderless. Angular: bpdmPopoverBordered.
modalbooleanfalseTrap focus + block outside interaction. Angular: bpdmPopoverModal.
ariaLabelstringAccessible name for the panel's dialog role; translatable. Angular: bpdmPopoverAriaLabel.
open / defaultOpenbooleanControlled / uncontrolled open. Angular: two-way [(bpdmPopoverOpen)].
onOpenChange(open: boolean) => voidReact — fires on open/close. Angular: covered by [(bpdmPopoverOpen)].
classNamestringReact — extra classes on the panel (override padding, width, etc.). Angular: bpdmPopoverClass.

Use PopoverClose (React) / bpdmPopoverClose (Angular) on a button inside the panel to dismiss it.

Accessible name — ariaLabel

The panel renders with role="dialog", so it needs an accessible name. There's no messages object — the single translatable string is ariaLabel (React ariaLabel, Angular [bpdmPopoverAriaLabel]), which names the panel for assistive tech. Set it (or provide a visible heading inside the content), and pass a translated value for localised UIs.

Accessibility

  • The trigger exposes aria-haspopup="dialog", aria-expanded (reflecting the open state), and aria-controls pointing at the panel while it's open — so assistive tech announces the trigger↔panel relationship.
  • The panel is a dialog; give it an accessible name via ariaLabel or a visible heading in the content.
  • Esc closes the popover; focus moves into the panel on open and returns to the trigger on keyboard or programmatic close.
  • modal traps focus and blocks outside interaction like a mini-dialog; non-modal (the default) lets you tab out of the panel and closes on outside-click — without yanking focus back to the trigger.
  • The panel is portaled, so it escapes overflow: hidden / stacking-context clipping, and it's collision-aware — it flips and shifts to stay in view while staying wired to the trigger for assistive tech.
  • Everything mirrors under dir="rtl" with no extra prop — the alignment and the collision-flip side flip automatically.

On this page