Click a trigger to toggle its popover. Click outside or press Esc to close.
Choose an action for this item.
The position helper auto-flips when the preferred edge doesn't fit.
This action moves the item to trash. You can restore it within 30 days.
/*
* Popover — overlay
*
* Click-triggered floating panel anchored to a trigger element.
* Non-modal: clicks outside dismiss it but interactions behind the
* popover remain available. Per the answered design question:
* reuses runtime/position.ts (no external Floating UI dependency).
*
* <button type="button" class="button -secondary"
* aria-haspopup="dialog" aria-expanded="false"
* aria-controls="pop-1">More</button>
*
* <div class="popover" id="pop-1" hidden role="dialog" aria-labelledby="pop-1-title">
* <header class="popover__header">
* <h3 id="pop-1-title" class="popover__title">Quick actions</h3>
* </header>
* <div class="popover__body">…</div>
* </div>
*/
@layer malevich.components {
.popover {
position: fixed;
z-index: 1000;
max-inline-size: var(--popover-max-width);
background-color: var(--color-surface-float);
color: var(--color-ink-strong);
border: var(--border-width-hairline) solid var(--color-border-default);
border-radius: var(--radius-tooltip);
box-shadow: var(--shadow-float);
/* Hidden by default — runtime toggles via [hidden] attribute. */
opacity: 0;
transform: translateY(calc(-1 * var(--space-inset-element-s)));
transition:
opacity var(--motion-fast) var(--motion-easing-default),
transform var(--motion-fast) var(--motion-easing-default);
}
.popover[data-state="open"] {
opacity: 1;
transform: translateY(0);
}
.popover[hidden] {
display: none;
}
.popover__header {
padding: var(--space-inset-block-s) var(--space-inset-block-m);
border-block-end: var(--border-width-hairline) solid var(--color-border-muted);
}
.popover__title {
margin: 0;
font-family: var(--font-heading-subsection-family);
font-size: var(--font-heading-subsection-size);
font-weight: var(--font-heading-subsection-weight);
line-height: var(--font-heading-subsection-line-height);
letter-spacing: var(--font-heading-subsection-letter-spacing);
color: var(--color-ink-strong);
}
.popover__body {
padding: var(--space-inset-block-m);
font-family: var(--font-body-support-family);
font-size: var(--font-body-support-size);
font-weight: var(--font-body-support-weight);
line-height: var(--font-body-support-line-height);
letter-spacing: var(--font-body-support-letter-spacing);
color: var(--color-ink-regular);
}
.popover__footer {
padding: var(--space-inset-block-s) var(--space-inset-block-m);
border-block-start: var(--border-width-hairline) solid var(--color-border-muted);
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-gap-elements-s);
}
@media (prefers-reduced-motion: reduce) {
.popover {
transform: none;
transition: opacity var(--motion-fast) var(--motion-easing-default);
}
.popover[data-state="open"] {
transform: none;
}
}
}
// Popover — runtime.
//
// Discovers triggers with aria-controls pointing at a .popover, toggles
// the popover on click, positions it via runtime/position.ts, and
// closes on Escape / outside-click. Non-modal: focus is not trapped;
// the trigger remains in the tab order.
import { position, type Placement } from "../../runtime/position.js";
const READY_ATTR = "data-malevich-ready";
export interface PopoverInitOptions {
/** Preferred placement; flips if it doesn't fit. Default: bottom. */
placement?: Placement;
/** Distance from anchor in px. Default: 8. */
gap?: number;
}
function getPlacement(trigger: HTMLElement, fallback: Placement): Placement {
const raw = trigger.getAttribute("data-popover-placement");
if (raw === "top" || raw === "right" || raw === "bottom" || raw === "left") {
return raw;
}
return fallback;
}
export function initPopover(
trigger: HTMLElement,
options: PopoverInitOptions = {},
): void {
if (trigger.getAttribute(READY_ATTR) === "true") return;
const targetId = trigger.getAttribute("aria-controls");
if (!targetId) return;
const popover = document.getElementById(targetId);
if (!popover) return;
const placement = options.placement ?? "bottom";
const gap = options.gap ?? 8;
let isOpen = false;
function show(): void {
popover!.hidden = false;
// Force layout before positioning so size is correct.
void popover!.offsetWidth;
const placed = position(
popover!,
trigger,
getPlacement(trigger, placement),
gap,
);
popover!.style.top = `${placed.top}px`;
popover!.style.left = `${placed.left}px`;
popover!.setAttribute("data-state", "open");
popover!.setAttribute("data-placement", placed.placement);
trigger.setAttribute("aria-expanded", "true");
isOpen = true;
document.addEventListener("click", onDocumentClick, true);
document.addEventListener("keydown", onKeyDown);
window.addEventListener("resize", reposition);
window.addEventListener("scroll", reposition, true);
}
function reposition(): void {
if (!isOpen) return;
const placed = position(
popover!,
trigger,
getPlacement(trigger, placement),
gap,
);
popover!.style.top = `${placed.top}px`;
popover!.style.left = `${placed.left}px`;
popover!.setAttribute("data-placement", placed.placement);
}
function hide(): void {
if (!isOpen) return;
popover!.removeAttribute("data-state");
popover!.hidden = true;
trigger.setAttribute("aria-expanded", "false");
isOpen = false;
document.removeEventListener("click", onDocumentClick, true);
document.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", reposition);
window.removeEventListener("scroll", reposition, true);
}
function onTriggerClick(event: MouseEvent): void {
event.stopPropagation();
isOpen ? hide() : show();
}
function onDocumentClick(event: MouseEvent): void {
const target = event.target as Node | null;
if (!target) return;
if (popover!.contains(target)) return;
if (trigger.contains(target)) return;
hide();
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape") {
hide();
trigger.focus();
}
}
// Initial ARIA state
if (!trigger.hasAttribute("aria-expanded")) {
trigger.setAttribute("aria-expanded", "false");
}
if (!popover.hasAttribute("role")) {
popover.setAttribute("role", "dialog");
}
popover.hidden = true;
trigger.addEventListener("click", onTriggerClick);
trigger.setAttribute(READY_ATTR, "true");
}
export function initPopovers(root: ParentNode = document): void {
for (const trigger of root.querySelectorAll<HTMLElement>(
"[aria-haspopup='dialog'][aria-controls]",
)) {
initPopover(trigger);
}
}
# Popover — AGENTS.md
> Auto-generated from `popover.docs.md` + the modifier applicability matrix.
> Edit those source files; this file regenerates on build.
## Identity
- **Component:** `popover`
- **Layer:** overlays
- **Status:** stable
- **Last updated:** 2026-05-19
## Summary
- Quick action menu attached to a button.
## Modifier applicability
Per ADR-0007. Modifiers attach via `data-{category}="value"` attributes.
| Category | Accepts |
|---|---|
| `background` | — |
| `surface` | `float` |
| `effect` | `ring` |
| `shader` | — |
| `rhythm` | `compact`, `regular` |
| `motion` | any value |
| `behavior` | `dismissible` |
## Anatomy
```html
<button type="button" class="button -secondary"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="pop-1">
More
</button>
<div class="popover" id="pop-1" role="dialog" aria-labelledby="pop-1-title">
<header class="popover__header">
<h3 id="pop-1-title" class="popover__title">Quick actions</h3>
</header>
<div class="popover__body">
<button class="button -ghost">Duplicate</button>
<button class="button -ghost">Archive</button>
<button class="button -ghost -danger">Delete</button>
</div>
</div>
```
The runtime (`initPopover`, auto-registered for triggers with
`aria-haspopup="dialog"` + `aria-controls`) handles:
- Click to toggle.
- Position via `runtime/position.ts` (auto-flip if doesn't fit).
- Click-outside / Escape closes.
- Reposition on scroll and resize.
- Sync `aria-expanded` on the trigger.
The popover element starts `hidden`; the runtime toggles `hidden`,
`data-state="open"`, and inline `top`/`left` styles. Authors do NOT
position the popover manually.
## Tokens consumed
- `--color-surface-float` — background
- `--color-ink-strong`, `--color-ink-regular`
- `--color-border-default`, `--color-border-muted`
- `--shadow-float` — elevation
- `--radius-tooltip` — corner radius
- `--space-inset-block-{s,m}`, `--space-inset-element-s`
- `--space-gap-elements-s`
- `--border-width-hairline`
- `--font-heading-subsection-*`, `--font-body-support-*`
- `--motion-fast`, `--motion-easing-default`
### Component-tier (defined inline)
- `--popover-max-width` — overridable max inline-size
## Accessibility contract
- The trigger carries `aria-haspopup="dialog"` and `aria-controls`
pointing at the popover id. The runtime toggles `aria-expanded`.
- The popover element has `role="dialog"` (runtime adds it if
missing) and should reference its label via `aria-labelledby`.
- The popover is **non-modal** — focus is not trapped. Users can
Tab out of the popover; that's intentional. For modal cases use
Dialog.
- Escape closes the popover and returns focus to the trigger.
- Background is not `inert`. Background interactions remain available.
## Guidance
### Do
- Use real `<button>` triggers — keyboard activation comes for free.
- Set `aria-haspopup="dialog"`, `aria-controls`, and let the runtime
manage `aria-expanded`.
- Add `aria-labelledby` referencing the popover's title.
### Don't
- Don't position the popover manually with CSS. The runtime sets
`top` / `left`.
- Don't use Popover for ephemeral hint text. Tooltip is the right
primitive there.
- Don't trap focus inside the popover. If you need modal focus
trapping, you actually want a Dialog.