Popover
Rich content in a floating panel, opened by a trigger, that keeps the page usable.
Basic
A form in a popover. Focus moves to the first field; Escape, a press outside or tabbing out close it.
Code
<Popover title="Dimensions" description="Set the dimensions for the layer.">
<Button slot="trigger">Open popover</Button>
<div class="field">
<label for="width">Width</label>
<input id="width" value={layer.width}>
</div>
...
</Popover>Usage
import { Popover, PopoverClose, PopoverTrigger, PopoverViewport } from "@elements/ui/popover";
<Popover title="Dimensions" description="Set the dimensions for the layer.">
<Button slot="trigger">Open popover</Button>
<label>Width <input value={layer.width}></label>
<label>Height <input value={layer.height}></label>
</Popover>
<!-- controlled: open is two-way -->
<Popover open={state.filters}>...</Popover>Examples
Header parts
PopoverHeader, PopoverTitle and PopoverDescription take rich content and name the dialog.
Code
<Popover>
<Button slot="trigger">Open popover</Button>
<PopoverHeader>
<PopoverTitle>Title</PopoverTitle>
<PopoverDescription>Description text here.</PopoverDescription>
</PopoverHeader>
</Popover>Label
Without a title, label names the dialog through aria-label.
Code
<Popover label="Text color" size="auto">
<Button slot="trigger">Text color</Button>
<Button size="sm">Ink</Button>
...
</Popover>Align
align lines the content up with the trigger's start, center or end.
Code
<Popover align="start">...</Popover>
<Popover align="center">...</Popover>
<Popover align="end">...</Popover>Side and arrow
side picks where it opens; it flips when there is no room. arrow points at the trigger.
Code
<Popover side="top" arrow size="auto">
<Button slot="trigger">Top</Button>
Opens above.
</Popover>RTL
In a right-to-left region, inline-start opens on the right and inline-end on the left, and align start lines up with the trigger's right edge.
Code
<div dir="rtl">
<Popover side="inline-start" size="auto">
<Button slot="trigger">بداية السطر</Button>
يفتح على اليمين.
</Popover>
<Popover side="inline-end" size="auto">...</Popover>
<Popover align="start" size="sm">...</Popover>
</div>Anchor
The anchor slot positions the content against another element than the trigger.
Code
<Popover>
<Button slot="trigger">Open</Button>
<span slot="anchor" class="anchor">Positioned here</span>
...
</Popover>Controlled
Bind open to state. It writes false back when it closes itself; onOpenChange reports only its own changes. Here Escape is ignored.
Code
<Button onclick={() => state.open = true}>Open from anywhere</Button>
<Popover open={state.open} onOpenChange={(open) => log(open)}
onEscapeKeyDown={(e) => e.preventDefault()}>
<Button slot="trigger">Trigger</Button>
<PopoverClose><Button>Done</Button></PopoverClose>
</Popover>Modal
modal traps focus, locks page scroll and blocks the page until it closes.
Code
<Popover modal title="Share">
<Button slot="trigger">Share</Button>
...
</Popover>Open on hover
openOnHover opens it after delay without taking focus; a click pins it open.
Code
<Popover openOnHover delay={200} closeDelay={150} title="Quick look">
<Button slot="trigger">Hover or click</Button>
...
</Popover>Nested
A popover opened from a popover. Escape closes the top one first.
Code
<Popover title="Filters">
<Button slot="trigger">Filters</Button>
<Popover side="right" title="Advanced">
<Button slot="trigger">Advanced</Button>
...
</Popover>
</Popover>Focus on open and close
initialFocus={false} opens without moving focus. initialFocus with a selector focuses that element, such as a static paragraph at the top of long content. finalFocus sends focus to another element on close. returnFocus={false} leaves it alone.
Code
<Popover initialFocus={false} title="Stays on the trigger">
<Button slot="trigger">Open, keep focus</Button>
...
</Popover>
<Popover finalFocus="#next-step" title="Rename">
<Button slot="trigger">Rename</Button>
<input aria-label="Name">
</Popover>
<Button attrs={{ id: "next-step" }}>Next step</Button>
<Popover returnFocus={false}>...</Popover>
<Popover initialFocus="#release-notes" title="Release notes">
<Button slot="trigger">What's new</Button>
<p id="release-notes" tabindex="-1">Version 2 adds shared folders.</p>
<input aria-label="Feedback">
</Popover>
<Popover onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}>...</Popover>Keep open
dismissOnOutside={false} ignores presses outside. onPointerDownOutside and onFocusOutside can each cancel their close with preventDefault.
Code
<Popover dismissOnOutside={false} title="Stays open">
<Button slot="trigger">Sticky</Button>
<PopoverClose><Button>Close</Button></PopoverClose>
</Popover>
<Popover onPointerDownOutside={(e) => e.preventDefault()}
onFocusOutside={(e) => e.preventDefault()}>
...
</Popover>Hide when detached
hideWhenDetached hides the content while its trigger is scrolled out of view, and shows it again when the trigger returns.
Scroll this box. The popover hides once the button leaves it.
Code
<div class="scroller">
<Popover hideWhenDetached size="auto">
<Button slot="trigger">Open, then scroll</Button>
Hidden while the trigger is out of view.
</Popover>
</div>Offsets and collisions
sideOffset and alignOffset move the content off its anchor. collisionPadding sets the room kept from the viewport edge; avoidCollisions={false} keeps it on its side with no flip or shift.
Code
<Popover align="start" sideOffset={12} alignOffset={8}>...</Popover>
<Popover align="end" collisionPadding={24}>...</Popover>
<Popover side="left" avoidCollisions={false}>...</Popover>Collision avoidance and position method
collisionAvoidance tunes how it stays in view: side "flip", "shift" (keep the side, slide into view) or "none"; align "flip", "shift" or "none"; fallbackAxisSide "end", "start" or "none" for the other axis. positionMethod="absolute" places it in page coordinates instead of fixed ones.
Code
<Popover side="top" collisionAvoidance={{ side: "none", align: "shift" }}>
<Button slot="trigger">Stays on top</Button>
Kept above the button even without room, slid along to stay in view.
</Popover>
<Popover align="start" collisionAvoidance={{ align: "flip", fallbackAxisSide: "none" }}>...</Popover>
<Popover side="top" collisionAvoidance={{ side: "shift" }}>...</Popover>
<Popover side="top" collisionAvoidance={{ fallbackAxisSide: "start" }}>...</Popover>
<Popover positionMethod="absolute">...</Popover>Function offsets
sideOffset and alignOffset also take a function of the anchor's and the content's sizes, the side and the alignment.
Code
<Popover sideOffset={(d) => d.anchor.height} size="auto">
<Button slot="trigger">One trigger height below</Button>
...
</Popover>Collision boundary, sticky and arrow padding
collisionBoundary keeps the content inside an element as well as the viewport. sticky="always" keeps it in the boundary after its anchor leaves. arrowPadding keeps the arrow off the corners.
Open one, then scroll this box.
Code
<div id="panel">
<Popover align="start" collisionBoundary="#panel" size="sm">...</Popover>
</div>
<Popover side="right" sticky="always" collisionBoundary="#list">...</Popover>
<Popover arrow align="start" arrowPadding={80}>...</Popover>Change reasons
onOpenChange gets a second argument: the reason for the change, the event, and cancel() to keep the current state.
Code
<!-- details.trigger is the trigger element that caused the change, if one did -->
<Popover onOpenChange={(open, details) => log(open, details.reason, details.trigger)}>
<Button slot="trigger">Open</Button>
...
</Popover>
<!-- details.cancel() keeps the current state -->
<Popover onOpenChange={(open, details) => details.reason === "escape-key" && details.cancel()}>
...
</Popover>Anchor selector
anchor takes a selector for an element anywhere on the page to position against.
Code
<Popover anchor="#toolbar-status" size="auto">
<Button slot="trigger">Status</Button>
Positioned under the status text.
</Popover>
<span id="toolbar-status">All saved</span>Sizes
size sets the width: auto fits the content, sm, md (the default) and lg are fixed steps.
Code
<Popover size="auto">...</Popover>
<Popover size="sm">...</Popover>
<Popover>...</Popover> <!-- md -->
<Popover size="lg">...</Popover>Constrain the content size
A tall popover stays inside the viewport and scrolls; with an arrow the arrow stays visible outside the scroll box.
Code
<Popover arrow size="sm" title="Recent files">
<Button slot="trigger">Recent files</Button>
<ul>...twenty files...</ul>
</Popover>Multiple triggers
PopoverTrigger with a handle opens one popover from anywhere on the page. It sits against the trigger pressed; pressing another moves it there. payload carries that trigger's data, and PopoverViewport cross-fades the content as it changes.
Code
<PopoverTrigger handle="file" payload="report.pdf">
<Button>report.pdf</Button>
</PopoverTrigger>
<PopoverTrigger handle="file" payload="budget.xlsx">
<Button>budget.xlsx</Button>
</PopoverTrigger>
<Popover handle="file" payload={state.file} size="auto" title="File">
<PopoverViewport>Share {state.file} with your team.</PopoverViewport>
</Popover>Multiple triggers, controlled
Bind open, triggerId and payload to open the popover on a chosen trigger from code; each binding is two-way.
Code
<Button onclick={() => { state.trigger = "share-b"; state.open = true; }}>Open on the second</Button>
<PopoverTrigger handle="share" payload="Design"><Button attrs={{ id: "share-a" }}>Design</Button></PopoverTrigger>
<PopoverTrigger handle="share" payload="Engineering"><Button attrs={{ id: "share-b" }}>Engineering</Button></PopoverTrigger>
<Popover handle="share" open={state.open} triggerId={state.trigger} payload={state.team}
onOpenChange={(open, details) => log(open, details.trigger?.id)}>
Invite {state.team}.
</Popover>Trap focus
modal="trap-focus" keeps Tab inside the popover, but the page still scrolls and a press outside still closes it.
Code
<Popover modal="trap-focus" title="Rename">
<Button slot="trigger">Rename</Button>
<input aria-label="Name">
<input aria-label="Description">
</Popover>Backdrop
backdrop dims the page under the open popover. A press on it closes the popover without reaching the page.
Code
<Popover backdrop title="Delete 3 files?">
<Button slot="trigger">Delete</Button>
...
</Popover>Disable anchor tracking
disableAnchorTracking places the content once when it opens; it stays where it is while the page scrolls, and is placed again when the window resizes.
Code
<Popover disableAnchorTracking size="auto">
<Button slot="trigger">Pinned in place</Button>
Placed once; stays put while the page scrolls.
</Popover>Keep mounted
keepMounted keeps the closed content in the page, hidden, so a field inside keeps what was typed.
Draft
Code
<Popover keepMounted title="Draft">
<Button slot="trigger">Draft</Button>
<input aria-label="Note"> <!-- keeps what was typed while closed -->
</Popover>Open on load
open={true} without a binding: the popover is open when the page loads, then owns its state.
Code
<Popover open={true} title="Tip">
<Button slot="trigger">Tip</Button>
Open when the page loads. The popover owns open from here.
</Popover>Tooltip on the trigger
A Tooltip around the trigger button labels it. The popover's aria, its position and the focus it returns on close land on the button inside.
Code
<Popover title="Filters" size="sm">
<Tooltip slot="trigger" content="Filter the list">
<Button slot="trigger">Filters</Button>
</Tooltip>
<label>Name <input></label>
</Popover>