Components
Popover
A popup anchored to its trigger: a native popover opened and closed by the trigger's popovertarget, placed with CSS anchor positioning, so it works in server HTML with no JavaScript. A 3.2 kB script moves focus into it and back, dismisses it as Base UI does (not on a right-click), keeps aria-expanded current and places it in browsers without anchor positioning. bind:open (or open and onOpenChange) keeps a signal in sync in islands. A trigger can open it on hover (openOnHover), and a modal popover is a modal <dialog> that makes the page behind it inert.
Notifications
You are all caught up.
import { Popover } from "@lumiose/ui"; // or "@lumiose/ui/popover"
import "@lumiose/ui/popover.css"; // places the popup next to its trigger
import "@lumiose/ui/themes/default.css"; // optional: the default theme#Anatomy
<Popover.Root>
<Popover.Trigger />
<Popover.Popup>
<Popover.Title />
<Popover.Description />
<Popover.Close />
</Popover.Popup>
</Popover.Root>| Part | Renders |
|---|---|
Root |
No element. Holds the state and the ids the other parts share. |
Trigger |
<button type="button" popovertarget="…" aria-haspopup="dialog">, with an anchor-name in its style. |
Popup |
<div popover="auto" role="dialog" data-side="…" data-align="…">, labelled by its Title and described by its Description; popover="manual" once the script opens it. |
Title |
<h2>, which names the popup. |
Description |
<p>, which describes it. |
Close |
<button type="button" popovertarget="…" popovertargetaction="hide">. |
With modal, the Trigger is a <button command="show-modal" commandfor="…">, the Popup a
<dialog closedby="any" data-side="…" data-align="…"> and the Close a
<button command="close" commandfor="…">.
The popup is a non-modal dialog, as in Base UI: the page around it stays usable (unless modal). A popover may
contain another Popover.Root: the inner popup opens on top, and closing the outer one closes
both.
#API
#Popover.Root
| Prop | Type | Default | |
|---|---|---|---|
open |
boolean |
Opens or closes the popover when it changes, in the browser. Server HTML always starts closed: a popover opens only from its trigger or script. | |
onOpenChange |
(open: boolean) => void |
Called after the popover opens or closes, whatever did it: the trigger, a close button, Escape, a click outside, another popover or open. |
|
bind:open |
Signal<boolean> |
Stands in for open and onOpenChange. |
|
modal |
boolean | "trap-focus" |
false |
A modal popover: the page behind it is inert and does not scroll, focus stays inside, and a press outside or Escape closes it. "trap-focus" only keeps Tab inside. |
#Popover.Trigger
| Prop | Type | Default | |
|---|---|---|---|
openOnHover |
boolean |
false |
A mouse or pen resting on the trigger opens the popover too, and it closes once the pointer has left the trigger and the popup. Not for a modal popover. |
delay |
number |
300 |
With openOnHover: how long the pointer rests on the trigger before the popover opens, in ms. |
closeDelay |
number |
0 |
With openOnHover: how long the popover stays open after the pointer leaves, in ms (at least 100 ms to cross the gap to the popup). |
A hover opens the popover without moving focus, and a click on the trigger then keeps it open
until it is dismissed. Touch and the keyboard open it with a click (a tap, Enter or Space), as
without openOnHover. On the way from the trigger to the popup, the pointer may take its time:
while it moves inside the triangle from where it left the trigger to the popup's near side (Base
UI's safe polygon), the popover waits; a stop or a turn away lets it close.
#Popover.Popup
| Prop | Type | Default | |
|---|---|---|---|
side |
"top" | "bottom" | "left" | "right" | "inline-start" | "inline-end" |
"bottom" |
The trigger's side the popup goes on. inline-start and inline-end follow dir. |
align |
"start" | "center" | "end" |
"center" |
How the popup lines up with the trigger along that side. |
sideOffset |
number |
0 |
The gap between the trigger and the popup, in CSS pixels. |
alignOffset |
number |
0 |
A shift along the side, in CSS pixels: away from the aligned edge, or right/down when centred. |
The popup flips to the opposite side when it does not fit, and goes below (or above) the trigger
when a left or right popup fits on neither side. A popup too tall for the room on both sides shrinks to the room beside its trigger
(below, or above when below has less than 8rem), and scrolls; without anchor positioning the
script writes that room as --lu-available-height, which the default theme's caps use. It hides while its trigger is
scrolled out of view, of the page or of a scroller around it (data-anchor-hidden on the popup,
where the script runs), and comes back with it.
#The other parts
Each part takes the props of the element it renders (class, style, aria-*, data-*, event
handlers, ref) and passes them through. Your props override the defaults (type,
aria-haspopup, role, aria-labelledby, aria-describedby). A Title's or a Description's
id names it, and the popup points at it; the Popup keeps its own id, which its trigger points
at (see Ids), and popover and popovertarget stay the parts' own. A style you pass keeps the anchor name.
Trigger:aria-expandedcomes from the browser forpopovertarget, and from the client script once it runs.Popup: native events work as on any popover:on:toggleandon:beforetoggle. A popup without aTitleneeds anaria-label.
#Example
import { Popover } from "@lumiose/ui";
export function Notifications() {
return (
<Popover.Root>
<Popover.Trigger class="lu-button">Notifications</Popover.Trigger>
<Popover.Popup class="lu-popover" align="start">
<Popover.Title>Notifications</Popover.Title>
<Popover.Description>You are all caught up.</Popover.Description>
<footer>
<Popover.Close class="lu-button">Close</Popover.Close>
</footer>
</Popover.Popup>
</Popover.Root>
);
}In an island, bound to a signal: bind:open={open} is short for open={open()} plus
onOpenChange={(next) => open.set(next)}. A popover opened by the signal still anchors to its
trigger and treats it as its invoker.
Filters
All items
import { Popover } from "@lumiose/ui";
import { signal } from "lumiose";
export function Filters() {
const open = signal(false);
return (
<>
<Popover.Root bind:open={open}>
<Popover.Trigger class="lu-button">Filters</Popover.Trigger>
<Popover.Popup class="lu-popover" side="right" sideOffset={8}>
<Popover.Title>Filters</Popover.Title>
</Popover.Popup>
</Popover.Root>
<p>{open() ? "Choosing filters…" : "All items"}</p>
</>
);
}A profile card that opens on hover, and a modal popover with a form:
Ada Lovelace
Wrote the first published program.
import { Popover } from "@lumiose/ui";
export function HoverAndModal() {
return (
<>
<Popover.Root>
<Popover.Trigger class="lu-button" openOnHover delay={200}>
Ada Lovelace
</Popover.Trigger>
<Popover.Popup class="lu-popover" sideOffset={8}>
<Popover.Title>Ada Lovelace</Popover.Title>
<Popover.Description>Wrote the first published program.</Popover.Description>
</Popover.Popup>
</Popover.Root>
<Popover.Root modal>
<Popover.Trigger class="lu-button">Rename</Popover.Trigger>
<Popover.Popup class="lu-popover" align="start" sideOffset={8}>
<Popover.Title>Rename the file</Popover.Title>
<label>
Name <input class="lu-input" value="report.pdf" />
</label>
<footer>
<Popover.Close class="lu-button">Done</Popover.Close>
</footer>
</Popover.Popup>
</Popover.Root>
</>
);
}#Styling
With the default theme, put lu-popover on Popup: a surface with the title, the
description and a <footer> styled, a 4px gap to the trigger, and a fade. Its colours come from the
theme's --lu- properties (UI components).
With your own CSS, style the popup through class and its state: :popover-open ([open]
for a modal popover, whose ::backdrop is transparent until you style it),
[data-side] and [data-align] (the requested placement; a flip does not change them), and the
trigger through [aria-expanded]. Two custom properties set the offsets, and the props override
them: --lu-side-offset and --lu-align-offset.
.popover {
--lu-side-offset: 6px;
padding: 0.75rem;
border: 1px solid #d4d4d8;
border-radius: 0.5rem;
box-shadow: 0 10px 30px -10px rgb(0 0 0 / 0.35);
}@lumiose/ui/popover.css holds the placement, in the lu.core layer: for each data-side and
data-align, the CSS anchor positioning that puts the popup next to its trigger, with flips and
position-visibility: anchors-visible. Tooltip shares it.
#Keyboard and focus
| Key | Does |
|---|---|
| Enter, Space | On the trigger: opens or closes the popover. |
| Tab | Moves through the popup, then on to the element after the trigger. When the trigger is the last element, focus leaves the popup. |
| Shift+Tab | From the start of the popup, back to the trigger. The popover stays open. |
| Escape | Closes the popover on top. With nested popovers, or a popover in a dialog, one Escape closes one layer. An Escape that ends an input method's composition closes nothing. |
- On open, focus moves to the popup's first tab stop (the checked radio of a group, not an item a roving widget takes out of the order), or to the popup itself when it has none. Opened by touch, the popup focuses itself, so no virtual keyboard opens.
- On close, focus returns to the trigger without scrolling, unless a click outside landed on another control, which keeps it. Escape and the close buttons return it too.
- Tab past the popup closes it, so it never covers the control that takes focus; Shift+Tab back to its trigger keeps it open, and so does focus moving into a toast.
- Closing it, whatever closed it (a bound
openset to false), closes the popovers, menus and dialogs open inside it. - A click outside closes the popover; a right-click or a second finger does not. A press inside a nested popover does not close its parent, and opening another popover closes the first.
- Popovers opened from a dialog belong inside its
Popup: everything outside a modal dialog is inert. - Opened on hover, the popover leaves focus where it is, and closing it moves none (unless focus had gone into it); it stays open while the pointer is on the trigger or the popup, and Escape closes it without moving the pointer (WCAG 1.4.13).
- A modal popover keeps Tab inside it (the page behind it is inert), and Escape, a press outside or a close button closes it; focus returns to the trigger.
modal="trap-focus"keeps Tab and Shift+Tab going around the popup's tab stops (a radio group is one stop, as a roving toolbar is), and nothing else: the page scrolls, and a press outside lands where it is pressed (and closes the popover).
#Without JavaScript, and the client script
The trigger, the close buttons, Escape, the click outside and the placement are HTML and CSS
features: a popover in a server-rendered page works before any script loads, and with none. So
does a modal popover, through invoker commands and closedby (where a browser lacks them,
Dialog's script, which a modal popover loads, stands in). Opening on hover needs the
scripts.
open, onOpenChange and bind:open need the component to run in an island.
A page that renders a Popover loads its script once, with the dismissal and placement modules it
shares with Menu, Combobox and Tooltip: 3.2 kB (brotli) without the Lumiose runtime. A trigger
with openOnHover adds 1.0 kB, the hover timing it shares with
Preview Card; a page without one goes without it. An island loads the same
modules. The scripts cover every Popover on the page:
- focus on open (the first focusable element, or the popup) and its return on close;
- dismissal, shared with Menu and Combobox: a trigger's click makes the popup a manual popover, which the script closes on a click outside (main button only, so a right-click or a second finger does not close it, as in Base UI), on Escape and other close requests (the Android back gesture), and when another popover opens outside it. Until the script loads, the browser's light dismiss closes it on any press outside;
aria-expandedon the triggers;- the placement in browsers without CSS anchor positioning, which it keeps current on scroll and resize;
- opening on hover: the delays, the grace and the safe triangle to cross the gap to the popup, and keeping it open after a click on the trigger;
modal="trap-focus"'s Tab loop.
No Backdrop part: an open popover is in the top layer, so .lu-popover::backdrop (or a modal
popover's dialog::backdrop) styles the layer under it, without an element of its own.
#Browser support
- Popover and
popovertarget: every current browser (Baseline 2024). - Invoker commands and
closedby(a modal popover): Chrome 135, Firefox 144, Safari 26.2 for commands; Chrome 134 and Firefox 141 forclosedby. Elsewhere Dialog's script stands in. - CSS anchor positioning: Chrome and Edge 125, Firefox 147, Safari 26. Elsewhere the client script places the popup; without JavaScript there it opens in the middle of the viewport.
- Safari and Firefox do not follow a trigger moved by a CSS
transform.
#Related
- UI components: the approach, styling, the default theme and the other components.
- Tooltip: a short tip on hover or focus, with the same placement props.
- Dialog: a modal popup.