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
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>
);
}import { Component } from '@angular/core';
import { BpdmPopover, BpdmButton } from '@bpdm/ng';
@Component({
selector: 'basic',
imports: [BpdmPopover, BpdmButton],
templateUrl: './basic.html',
})
export class Basic {}<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.
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>
</>
);
}import { Component } from '@angular/core';
import { BpdmPopover, BpdmButton } from '@bpdm/ng';
@Component({
selector: 'placements',
imports: [BpdmPopover, BpdmButton],
templateUrl: './placements.html',
})
export class Placements {}<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.
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>
</>
);
}import { Component } from '@angular/core';
import { BpdmPopover, BpdmButton } from '@bpdm/ng';
@Component({
selector: 'border-example',
imports: [BpdmPopover, BpdmButton],
templateUrl: './border.html',
})
export class BorderExample {}<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.
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>
);
}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 {}<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:
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>
);
}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 {}<!-- 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
| Prop | Type | Default | Description |
|---|---|---|---|
trigger | ReactNode | — | The element that opens it (React). Angular: the host element carrying [bpdmPopover] (which points at the panel <ng-template>). |
children | ReactNode | — | Panel content (React). Angular: the <ng-template> referenced by [bpdmPopover]. |
side | top | right | bottom | left | bottom | Edge to open from. Angular: bpdmPopoverSide. |
align | start | center | end | center | Alignment along the side. Angular: bpdmPopoverAlign. |
sideOffset | number | 8 | Gap from the trigger, in px. Angular: bpdmPopoverOffset. |
width | number | string | fit-content | Fixed panel width. Angular: bpdmPopoverWidth. |
showArrow | boolean | false | Draw an arrow at the trigger. Angular: bpdmPopoverShowArrow. |
bordered | boolean | true | Draw a border around the panel; false for borderless. Angular: bpdmPopoverBordered. |
modal | boolean | false | Trap focus + block outside interaction. Angular: bpdmPopoverModal. |
ariaLabel | string | — | Accessible name for the panel's dialog role; translatable. Angular: bpdmPopoverAriaLabel. |
open / defaultOpen | boolean | — | Controlled / uncontrolled open. Angular: two-way [(bpdmPopoverOpen)]. |
onOpenChange | (open: boolean) => void | — | React — fires on open/close. Angular: covered by [(bpdmPopoverOpen)]. |
className | string | — | React — 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), andaria-controlspointing 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 viaariaLabelor a visible heading in the content. Esccloses the popover; focus moves into the panel on open and returns to the trigger on keyboard or programmatic close.modaltraps 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.