Popover
The generic anchored surface: a floating panel tethered to a trigger, an element, or a point.
Live demo
Popover is the substrate the rest of the anchored family is built from. Tooltip describes; Menu commands; Dialog interrupts.
Popover holds anything — a filter form, a profile card, a color picker, a date grid, a list of results — and answers the one question all of them share: where does the panel go, and how does it leave. It owns no content. The trigger is yours (slot it, or point for at an element anywhere in the document) and the body is yours (the default slot). What Popover owns is the surface around them: the panel, the arrow, the placement, the dismissal, and the focus.
The panel renders in the top layer — no z-index war, no clipping by an ancestor's overflow — and it gets there through whichever platform door the posture actually needs. A non-modal panel is a native Popover, so light-dismiss and Escape come from the platform rather than a document listener, and the page stays live behind it. Placement flips: it prefers the side you name and takes the opposite one when the panel does not fit, and it flips the cross-axis alignment start ↔ end before it ever slides the panel off its anchor, so a panel near the right edge right-aligns the way a native menu does. The optional arrow is a real node in the fragment (not a CSS pseudo-element, so a mod can reshape it), and it tracks the anchor's center whatever the alignment and the viewport clamp did — a start-aligned panel still points at the middle of its trigger.
There are three ways to open it, one API. show() opens against the declared anchor — the slotted trigger, or the for element. openAt(x, y) opens at a bare viewport point (a click, a right-click, a caret position), exactly as Menu's context mode does. openFrom(element, opts) opens against any element you hand it, which is the hook a component that owns its own trigger reaches for. All three take the same options, and all three place the panel synchronously, so it never paints a frame at the origin before it lands.
Focus has two postures. Non-modal (the default) is an ordinary disclosure: Tab walks out of the panel, and a click anywhere else dismisses it. Add modal and the same panel opens as a modal <dialog> instead — so the background is made inert by the platform, exactly as it is for Dialog and Sheet, and the panel's aria-modal is a fact rather than a decoration. That distinction is the whole reason the modal posture is not just a scrim: the Popover API alone never makes the background inert, so a scrim and a JavaScript focus trap would still leave a screen reader free to browse straight out of a panel insisting it cannot be left. On top of the platform's inertness the modal posture adds what a dialog does not give for free — a pointer landing outside dismisses and is swallowed, so the button it happened to hit does not also fire.
focus-on-open decides where focus lands — first (the default), panel, or none for a surface whose caret must stay in an input it doesn't own. Closing hands focus back to wherever it came from, and the close event says why (escape, dismiss, select, api). A select event bubbling out of the panel closes it, so an option list, a menu, or a command list dismisses the surface it was chosen from with nothing wired.
When to use
How this component composes with the rest of the set.
Props
20 props, straight from the manifest.
| Prop | Type | Default | Bindings | Description |
|---|---|---|---|---|
Appearance
Variants
flush
The panel drops its padding so slotted content reaches the edges: a list, a menu, an image, a calendar grid.
States
open
The panel while it is in the top layer through the Popover API — the non-modal posture. Fades in from @starting-style; the closed panel is not rendered at all.
open-modal
The same panel in the top layer through <dialog>.showModal() — the modal posture, where the [open] the dialog reflects is the state, not :popover-open. Style both if you restyle the open panel.
modal
The modal posture: the panel's backdrop takes the scrim, the platform holds the page behind it inert, and clicks outside are swallowed rather than passed through.
placed
The side the panel actually landed on, after any flip: data-placement on the panel (and data-align for the cross axis). The arrow's side, and any consumer styling keyed on direction, read from these.
panel-focus
The panel itself holding keyboard focus (focus-on-open="panel", or a body with nothing focusable in it).
Anatomy
The named parts that make up the component, with their selectors.
popover
The wrapper holding the trigger and its panel. display: contents, so the popover puts nothing in flow but the trigger — and a triggerless (point-anchored) popover takes no layout at all, wherever it is declared.
trigger
The box around the consumer's own trigger, and the rect the panel anchors to. Hidden entirely when nothing is slotted into it.
panel
The floating surface: a <dialog popover> rendered in the top layer, so it escapes ancestor clipping and stacking. It opens as a native popover when non-modal and through showModal() when modal is set, which is what makes the modal posture's inertness the platform's rather than a claim. Carries data-placement and data-align with the side and alignment it actually landed on.
arrow
The beak: a real node in the fragment, not a pseudo-element, so a mod can reshape it. Tethered to the anchor's center, bounded so it never rounds the panel's corner. Off unless arrow is set.
content
The scroll box holding the consumer's slotted body. Overflows on its own so a long panel scrolls without the arrow being clipped along with it.
Tokens & coverage
What the component consumes, checked live against what the algorithm produces.
Live coverage check against the xtyle-default register
(derive(xtyleDefault, { anchors }) →
coverComponent(manifest, register)). Every token this component
consumes must be a key the algorithm produces.
--border-thick
--border-thin
--duration-fast
--ease-standard
--elevation-3
--fg-1
--font-sans
--leading-normal
--radius-md
--ring
--scrim
--space-2
--space-3
--space-8
--surface-overlay
--surface-overlay-border
--text-sm
Slots
The consumer's own trigger — a Button, an avatar, an icon button. The popover wires its click, stamps aria-haspopup / aria-expanded / aria-controls on it (on the real control inside a wrapper element, where assistive tech will actually find them), and anchors the panel to it. Leave it empty and the popover renders nothing until openAt / openFrom opens it.
The panel's body. Anything at all: a form, a card, a list, a chart. It is your content; Popover only frames it.
Accessibility
Code
A trigger-anchored panel
The common shape: slot a trigger, slot a body, and let Popover place and dismiss it.
<xtyle-popover label="Filters" arrow placement="bottom" align="start">
<button slot="trigger" type="button">Filters</button>
<form>
<label><input type="checkbox" checked /> Open</label>
<label><input type="checkbox" /> Merged</label>
<button type="submit">Apply</button>
</form>
</xtyle-popover>
<script>
const popover = document.querySelector("xtyle-popover");
popover.addEventListener("open", () => console.log("opened"));
popover.addEventListener("close", (e) => console.log("closed:", e.detail.reason));
</script>
<script lang="ts">
import { Popover, Button, Checkbox } from "@xtyle/svelte";
</script>
<Popover label="Filters" arrow placement="bottom" align="start" onclose={(e) => console.log(e.detail.reason)}>
{#snippet trigger()}
<Button>Filters</Button>
{/snippet}
<Checkbox label="Open" checked />
<Checkbox label="Merged" />
</Popover>
---
import Popover from "@xtyle/astro/Popover.astro";
import Button from "@xtyle/astro/Button.astro";
---
<Popover label="Filters" arrow placement="bottom" align="start">
<Button slot="trigger">Filters</Button>
<p>Anything at all lives in the panel — it is your content, not the component's.</p>
</Popover>
The modal posture
modal opens the same panel as a modal <dialog>, so the platform holds the page behind it inert — pointer, Tab, and browse mode alike — and a click outside is swallowed rather than passed through.
<!-- `modal` opens the panel as a modal <dialog>: the platform makes the page behind it
inert, so `aria-modal` is a fact. Tab, the pointer, and a screen reader in browse mode
all stay inside — and a click outside is swallowed, not passed through to what it hit. -->
<xtyle-popover label="Confirm delete" modal arrow>
<button slot="trigger" type="button">Delete…</button>
<p>This cannot be undone. The page behind this panel is out of play until you answer.</p>
<button id="confirm" type="button">Delete</button>
<button id="cancel" type="button">Cancel</button>
</xtyle-popover>
<script>
const popover = document.querySelector("xtyle-popover");
for (const id of ["#confirm", "#cancel"]) {
document.querySelector(id).addEventListener("click", (event) => {
// a bubbling `select` closes the panel — the same discipline Menu and Dialog use
event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { id } }));
});
}
popover.addEventListener("close", (e) => console.log("closed:", e.detail.reason));
</script>
<script lang="ts">
import { Popover, Button, Text } from "@xtyle/svelte";
let popover: { hide(): void };
</script>
<Popover bind:this={popover} label="Confirm delete" modal arrow>
{#snippet trigger()}
<Button tone="danger">Delete…</Button>
{/snippet}
<Text size="sm">This cannot be undone. The page behind this panel is out of play until you answer.</Text>
<Button tone="danger" size="sm" onclick={() => popover.hide()}>Delete</Button>
<Button tone="neutral" size="sm" onclick={() => popover.hide()}>Cancel</Button>
</Popover>
---
import Popover from "@xtyle/astro/Popover.astro";
import Button from "@xtyle/astro/Button.astro";
---
<Popover label="Confirm delete" modal arrow>
<Button slot="trigger" tone="danger">Delete…</Button>
<p>This cannot be undone. The page behind this panel is out of play until you answer.</p>
</Popover>
Opened at a point
No trigger at all: openAt(x, y) anchors the panel to a bare viewport point (a click, a right-click, a caret). The host takes no layout until it opens.
<div id="canvas">Click anywhere in here.</div>
<!-- no trigger slot: the host takes no layout, and the popover opens at a point -->
<xtyle-popover id="peek" label="Point details" arrow></xtyle-popover>
<script>
const peek = document.querySelector("#peek");
peek.innerHTML = "<p>Opened right where you clicked.</p>";
document.querySelector("#canvas").addEventListener("click", (event) => {
peek.openAt(event.clientX, event.clientY);
});
</script>
<script lang="ts">
import { Popover } from "@xtyle/svelte";
let peek: { openAt(x: number, y: number): void };
</script>
<div onclick={(e) => peek.openAt(e.clientX, e.clientY)}>Click anywhere in here.</div>
<Popover bind:this={peek} label="Point details" arrow>
<p>Opened right where you clicked.</p>
</Popover>
---
import Popover from "@xtyle/astro/Popover.astro";
---
<div id="canvas">Click anywhere in here.</div>
<Popover id="peek" label="Point details" arrow>
<p>Opened right where you clicked.</p>
</Popover>
<script>
const peek = document.querySelector("#peek");
document.querySelector("#canvas").addEventListener("click", (event) => {
peek.openAt(event.clientX, event.clientY);
});
</script>
As a substrate
The shape a sibling component builds on: anchor to an element it owns with openFrom, keep focus in its input, name the panel's real role, and let a bubbling select close it.
<!-- the shape a Combobox / CommandPalette builds on: an anchor the sibling owns,
a flush panel, no focus theft, and the panel role its content actually is -->
<input id="filter" role="combobox" aria-autocomplete="list" placeholder="Filter…" />
<xtyle-popover id="results" for="filter" panel-role="listbox" flush align="start" gap="4"></xtyle-popover>
<script>
const input = document.querySelector("#filter");
const results = document.querySelector("#results");
results.innerHTML = "<ul role='none'><li role='option'>alpha</li><li role='option'>beta</li></ul>";
input.addEventListener("input", () => {
// anchor to the element the sibling owns; the caret never leaves the input
results.openFrom(input, { focus: "none" });
results.reposition(); // the list resized as it filtered
});
// a `select` bubbling out of the panel closes it — the same discipline Menu and Dialog use
results.addEventListener("close", (e) => console.log("closed:", e.detail.reason));
</script>
<script lang="ts">
import { Popover, Field } from "@xtyle/svelte";
let anchor: HTMLElement;
let results: { openFrom(el: HTMLElement, opts?: { focus?: "none" }): void; reposition(): void };
function onInput() {
results.openFrom(anchor, { focus: "none" });
results.reposition();
}
</script>
<div bind:this={anchor}><Field label="Filter" oninput={onInput} /></div>
<Popover bind:this={results} panel-role="listbox" flush align="start" gap={4}>
<ul role="none"><li role="option">alpha</li><li role="option">beta</li></ul>
</Popover>
---
import Popover from "@xtyle/astro/Popover.astro";
import Field from "@xtyle/astro/Field.astro";
---
<Field id="filter" label="Filter" />
<Popover id="results" for="filter" panelRole="listbox" flush align="start" gap={4}>
<ul role="none"><li role="option">alpha</li><li role="option">beta</li></ul>
</Popover>
<script>
const input = document.querySelector("#filter");
const results = document.querySelector("#results");
input.addEventListener("input", () => results.openFrom(input, { focus: "none" }));
</script>