Rich content on click, built on the native Popover API — top layer and light dismiss for free, anchored with CSS anchor positioning.

Last updated

Extra detail shown in the panel.

Show code
<Popover>
<span slot="trigger">More info</span>
<p>Extra detail shown in the panel.</p>
</Popover>

Add

npx @ava-ui/apogee add popover

Depends on utils (the anchored-panel helper). No icons. Zero JS — the native Popover API (popover, popovertarget) plus CSS anchor positioning do the work.

Usage

---
import Popover from '@/components/apogee/Popover/Popover.astro'
---
<Popover placement="bottom-start" width="trigger">
  <span slot="trigger">Filters</span>
  <p>Panel content goes here.</p>
</Popover>

Anatomy

  • Trigger — the built-in <button popovertarget>; its label is the trigger slot (defaults to “Open”).
  • Anchor — alternatively, your own element in the anchor slot. Give it popovertarget={id} and pass the same id to the Popover.
  • Panel — the <div popover> anchored to the trigger; its content is the default slot. It paints in the top layer, so no ancestor can clip it.

API

@composable — exposed to Content Editors in AVA. Any other attribute passes through to the trigger (the built-in button, or the wrapper around your anchor element).

Prop Type Default Description
id string auto-generated Panel id. Set it when an anchor element needs popovertarget.
placement 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'start' | 'start-top' | 'start-bottom' | 'end' | 'end-top' | 'end-bottom' 'bottom' Side and alignment. start/end are logical and mirror in RTL. The panel flips to the opposite side when there is no room.
gap number 2 Space between trigger and panel, in spacing steps (2 = 0.5rem).
width 'trigger' | string — 'trigger' matches the trigger’s width; otherwise any CSS length.
maxHeight string — Caps the panel height; content scrolls past it.
padding 0 | 1 | 2 | 3 | 4 | 5 | 6 4 Panel padding in spacing steps.
elevation 'raised' | 'overlay' 'overlay' Surface and shadow of the panel.
panelClass string — Extra classes on the panel.
class string — Extra classes on the trigger, merged last.

Placement

All twelve values. Each panel flips to the opposite side when it would leave the viewport (position-try-fallbacks).

Opens on the top-start side.

Opens on the top side.

Opens on the top-end side.

Opens on the bottom-start side.

Opens on the bottom side.

Opens on the bottom-end side.

Opens on the start-top side.

Opens on the start side.

Opens on the start-bottom side.

Opens on the end-top side.

Opens on the end side.

Opens on the end-bottom side.

Show code
<Popover placement="top-start"><span slot="trigger">top-start</span>…</Popover>
<Popover placement="top"><span slot="trigger">top</span>…</Popover>
<Popover placement="top-end"><span slot="trigger">top-end</span>…</Popover>
<Popover placement="bottom-start"><span slot="trigger">bottom-start</span>…</Popover>
<Popover placement="bottom"><span slot="trigger">bottom</span>…</Popover>
<Popover placement="bottom-end"><span slot="trigger">bottom-end</span>…</Popover>
<Popover placement="start-top"><span slot="trigger">start-top</span>…</Popover>
<Popover placement="start"><span slot="trigger">start</span>…</Popover>
<Popover placement="start-bottom"><span slot="trigger">start-bottom</span>…</Popover>
<Popover placement="end-top"><span slot="trigger">end-top</span>…</Popover>
<Popover placement="end"><span slot="trigger">end</span>…</Popover>
<Popover placement="end-bottom"><span slot="trigger">end-bottom</span>…</Popover>

Edge flip

A trigger at the end of the row with placement="end" has no room on that side, so the panel opens on the start side instead.

Flipped to the start side.

Show code
<div class="flex w-full justify-end">
<Popover placement="end">
  <span slot="trigger">At the edge</span>
  <p>Flipped to the start side.</p>
</Popover>
</div>

Width and max height

width="trigger" matches the trigger; maxHeight scrolls the content. The panel is in the top layer, so an overflow: hidden ancestor cannot clip it.

  • Africa
  • Antarctica
  • Asia
  • Europe
  • North America
  • Oceania
  • South America
Show code
<div class="w-64 overflow-hidden rounded-md border border-neutral p-4">
<Popover width="trigger" maxHeight="8rem" placement="bottom-start">
  <span slot="trigger">Pick a region (long list)</span>
  <ul class="flex flex-col gap-1">
    <li>Africa</li><li>Antarctica</li><li>Asia</li><li>Europe</li>
    <li>North America</li><li>Oceania</li><li>South America</li>
  </ul>
</Popover>
</div>

Custom trigger

Put your own element in the anchor slot with popovertarget set to the Popover’s id. The element must be a <button> (or any element with invoker support) so it works with zero JS.

Show code
<Popover id="pop-profile" placement="bottom-end">
<Button slot="anchor" popovertarget="pop-profile" variant="ghost" color="neutral">Jane Doe</Button>
<p class="font-medium">Jane Doe</p>
<p class="text-neutral-faded">jane@example.com</p>
</Popover>

Padding, elevation and gap

Tight panel.

Lighter shadow.

Touches the trigger.

One rem away.

Show code
<Popover padding={0}><span slot="trigger">padding 0</span><div class="h-24 w-56 rounded-lg bg-neutral-faded" /></Popover>
<Popover padding={2}><span slot="trigger">padding 2</span><p>Tight panel.</p></Popover>
<Popover elevation="raised"><span slot="trigger">raised</span><p>Lighter shadow.</p></Popover>
<Popover gap={0}><span slot="trigger">gap 0</span><p>Touches the trigger.</p></Popover>
<Popover gap={4}><span slot="trigger">gap 4</span><p>One rem away.</p></Popover>

States

  • Closed → open — a fade + slight scale (--duration-fast, --ease-standard), using @starting-style for the entry transition; driven entirely by the browser’s :popover-open pseudo-class.
  • Out of room — the panel flips to the opposite side, then the opposite alignment, with no JS.
  • Unsupported anchor positioning — the panel falls back to the popover API’s default centered position, still fully usable. Anchor positioning is Baseline since January 2026.

Examples

A short list inside the panel:

<Popover placement="bottom-start" padding={1}>
  <span slot="trigger">Share</span>
  <ul class="flex flex-col gap-0.5">
    <li><a href="/copy-link" class="block rounded px-2 py-1.5 hover:bg-neutral-faded-highlighted">Copy link</a></li>
    <li><a href="/email" class="block rounded px-2 py-1.5 hover:bg-neutral-faded-highlighted">Email</a></li>
  </ul>
</Popover>

Accessibility

Key Action
Enter Space On the trigger, toggle the panel.
Esc Closes the panel and returns focus to the trigger (native).
  • The trigger is a real <button popovertarget>; the browser sets aria-expanded on it.
  • Clicking outside closes the panel — the browser’s native light-dismiss behaviour (popover="auto", the implicit default), no JS involved.
  • The panel paints in the native top layer, above everything else, with no z-index needed.
  • The panel has no implicit ARIA role — if its content behaves like a dialog or menu, add role="dialog" / role="menu" (and a label) yourself.
  • Opening doesn’t move focus into the panel — there’s no autofocus. Add one to a focusable child if the panel needs immediate keyboard interaction.
  • The open/close fade is disabled under prefers-reduced-motion: reduce.

Tokens

Token Used for
--border-neutral Trigger + panel border
--background-page Trigger background
--background-elevation-overlay / -raised Panel background (elevation)
--foreground-neutral Trigger + panel text
--background-neutral-faded-highlighted Trigger hover background
--outline-color-focus Focus ring
--duration-fast / --ease-standard Open/close transition
--radius-md Trigger corner radius
--radius-lg Panel corner radius

Do / Don’t

  • Do keep the panel’s first interactive element reachable by Tab — there’s no focus trap, so keyboard order just follows DOM order.
  • Do use width="trigger" for panels that act like a picker under a field.
  • Don’t put a page’s primary content only in a Popover — a stray click or Esc dismisses it.
  • Don’t forget popovertarget on an anchor element — without it the panel never opens.

Content

  • Trigger label is a short verb or noun for the panel’s purpose (“Filters”, “Share”, “More info”).
  • Panel copy stays short — it’s a lightweight aside, not a page.