Popover
A popover: a trigger opens a floating, elevated panel anchored to it — for transient detail, a small form, or a confirmation. Decoupled from any particular trigger — pass your own, commonly a Button rendered through the trigger.
Basic
A Popover.Trigger opens the Popover.Popup. Compose the trigger from a Button via render so it inherits the button's look, hover, and focus; the popup holds an optional Title and Description over any content. The popup portals and positions itself — no Portal/Positioner wiring needed for the common case.
"use client";
import { Button, Popover } from "@stridge/noctis";
export default function PopoverBasic() {
return (
<Popover.Root>
<Popover.Trigger render={<Button variant="secondary">Filters</Button>} />
<Popover.Popup>
<Popover.Title>Filter results</Popover.Title>
<Popover.Description>Narrow the list to what you care about.</Popover.Description>
</Popover.Popup>
</Popover.Root>
);
}
With actions
Drop a Popover.Close inside — rendered through a Button for a footer action — and it closes the popover and returns focus to the trigger. Several Closes can coexist (a Cancel and a destructive confirm), and focus is managed for you: it moves into the popup on open and back to the trigger on close.
"use client";
import { Button, Popover } from "@stridge/noctis";
import { type CSSProperties } from "react";
// Lift the width cap so the popup auto-sizes to fit the one-line message (it shrink-wraps to its
// content, so it grows only as wide as the text needs, never to the full cap). Keep the
// `--available-width` clamp so it still never overruns a narrow viewport.
const ROOMY = { "--noctis-popover-popup-max-width": "min(28rem, var(--available-width))" } as CSSProperties;
export default function PopoverWithActions() {
return (
<Popover.Root>
<Popover.Trigger render={<Button variant="danger">Delete project</Button>} />
<Popover.Popup style={ROOMY}>
<Popover.Title>Delete this project?</Popover.Title>
<Popover.Description>This permanently removes the project and everything in it.</Popover.Description>
<div className="mt-2 flex justify-end gap-2">
<Popover.Close
render={
<Button variant="secondary" size="sm">
Cancel
</Button>
}
/>
<Popover.Close
render={
<Button variant="danger" size="sm">
Delete
</Button>
}
/>
</div>
</Popover.Popup>
</Popover.Root>
);
}
Positioning
The popup opens below the trigger, aligned to its start, by default. Steer it with side and align (and sideOffset / alignOffset) on Popover.Popup — alignment is direction-aware, so start/end flip under RTL. Positioning is collision-aware: the popup flips to the opposite side and shifts to stay on screen, with collisionPadding (default 8) keeping it off the viewport edges. Noctis popovers sit flush against their anchor — there is no arrow/caret part (Base UI offers one; this system doesn't use it).
"use client";
import { Button, Popover } from "@stridge/noctis";
export default function PopoverPositioning() {
return (
<div className="flex flex-wrap items-center gap-3">
<Popover.Root>
<Popover.Trigger render={<Button variant="secondary">Align start</Button>} />
<Popover.Popup align="start">
<Popover.Title>Below, start-aligned</Popover.Title>
<Popover.Description>The default — opens beneath the trigger.</Popover.Description>
</Popover.Popup>
</Popover.Root>
<Popover.Root>
<Popover.Trigger render={<Button variant="secondary">Open upward</Button>} />
<Popover.Popup side="top" align="end">
<Popover.Title>Above, end-aligned</Popover.Title>
<Popover.Description>Steered with `side` and `align`.</Popover.Description>
</Popover.Popup>
</Popover.Root>
</div>
);
}
A form
A small form is the canonical popover — a quick edit anchored to what it changes. Give Popover.Popup an initialFocus ref so focus lands on the first field, not the popup, the moment it opens. A Popover.Close rendered through a submit Button (type="submit" form="…") closes the popover and returns focus to the trigger; on a real submit, keep it open and surface validation in place rather than closing optimistically.
"use client";
import { Button, Input, Popover } from "@stridge/noctis";
import { useRef } from "react";
export default function PopoverForm() {
const nameRef = useRef<HTMLInputElement>(null);
return (
<Popover.Root>
<Popover.Trigger render={<Button variant="secondary">Rename label</Button>} />
{/* initialFocus lands on the first field — not the popup — the considered first stop for a form (APG). */}
<Popover.Popup initialFocus={nameRef}>
<Popover.Title>Rename label</Popover.Title>
<Popover.Description>Give this label a clearer name.</Popover.Description>
<form id="rename" className="flex flex-col gap-2">
<label htmlFor="label-name" className="text-small text-secondary">
Name
</label>
<Input.Root>
<Input.Control
id="label-name"
ref={nameRef}
name="name"
defaultValue="Untitled"
aria-label="Label name"
/>
</Input.Root>
</form>
<div className="mt-1 flex justify-end gap-2">
<Popover.Close
render={
<Button variant="secondary" size="sm">
Cancel
</Button>
}
/>
{/* On a real submit, keep the popover open and show an error in place rather than closing optimistically. */}
<Popover.Close
render={
<Button variant="primary" type="submit" form="rename" size="sm">
Save
</Button>
}
/>
</div>
</Popover.Popup>
</Popover.Root>
);
}
Focus
Popover.Popup forwards Base UI's initialFocus and finalFocus. initialFocus chooses where focus lands on open — a ref (the first field of a form), true for the first tabbable element, or false for the popup itself; finalFocus chooses where it returns on close. With no focusable content, the popup itself receives focus, so a detail-only popover is still announced to screen readers. Leave both unset for the considered default: focus moves into the popup on open and back to the trigger on close.
Overflow
A popover taller than the screen never clips. The popup caps at the space Base UI leaves to the viewport edge (--available-height) and an inner viewport scrolls past it, with the scrollbar flush at the popup edge; keyboard focus inside stays in view as you tab. Short content is unaffected.
"use client";
import { Button, Popover } from "@stridge/noctis";
const TIMEZONES = [
"Pacific/Midway",
"Pacific/Honolulu",
"America/Anchorage",
"America/Los_Angeles",
"America/Denver",
"America/Chicago",
"America/New_York",
"America/Halifax",
"America/Sao_Paulo",
"Atlantic/Azores",
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Europe/Athens",
"Europe/Moscow",
"Asia/Tehran",
"Asia/Dubai",
"Asia/Karachi",
"Asia/Kolkata",
"Asia/Dhaka",
"Asia/Bangkok",
"Asia/Shanghai",
"Asia/Tokyo",
"Australia/Sydney",
"Pacific/Auckland",
];
export default function PopoverScrollable() {
return (
<Popover.Root>
<Popover.Trigger render={<Button variant="secondary">Pick a timezone</Button>} />
<Popover.Popup>
<Popover.Title>Timezone</Popover.Title>
<Popover.Description>The popup caps at the available height; this list scrolls past it.</Popover.Description>
<ul className="flex flex-col">
{TIMEZONES.map((tz) => (
<li key={tz}>
<button
type="button"
className="w-full rounded-control px-2 py-1.5 text-start text-regular text-secondary hover:bg-control-ghost-hover"
>
{tz}
</button>
</li>
))}
</ul>
</Popover.Popup>
</Popover.Root>
);
}
Match the trigger width
By default the popup floors at its own min-width and never reads narrower than 12rem. For a combobox-style panel that should be at least as wide as the control it drops from, override the public min-width with the trigger width on Popover.Popup: --noctis-popover-popup-min-width: var(--anchor-width). The max-width still caps the popup at the viewport, so wide content can't overrun.
"use client";
import { Button, Popover } from "@stridge/noctis";
import { type CSSProperties } from "react";
// Floor the popup at the trigger width, combobox-style — the popup never reads narrower than the
// control it drops from. The max-width still caps it at the viewport, so wide content can't overrun.
const MATCH_TRIGGER = { "--noctis-popover-popup-min-width": "var(--anchor-width)" } as CSSProperties;
const ASSIGNEES = ["Unassigned", "Ada Lovelace", "Grace Hopper", "Katherine Johnson"];
export default function PopoverMatchTriggerWidth() {
return (
<div className="flex w-full max-w-sm flex-col">
<Popover.Root>
<Popover.Trigger
render={
<Button variant="outline" className="w-full justify-between">
Assignee
</Button>
}
/>
<Popover.Popup style={MATCH_TRIGGER}>
<Popover.Title>Assign to</Popover.Title>
<ul className="flex flex-col">
{ASSIGNEES.map((name) => (
<li key={name}>
<button
type="button"
className="w-full rounded-control px-2 py-1.5 text-start text-regular text-secondary hover:bg-control-ghost-hover"
>
{name}
</button>
</li>
))}
</ul>
</Popover.Popup>
</Popover.Root>
</div>
);
}
Open on hover
Popover.Trigger forwards Base UI's openOnHover, delay, and closeDelay for hover-activated context cards — a profile or preview that appears as the pointer rests on a link. Treat hover-open as a pointer enhancement, never the sole affordance: the trigger stays a real button, so it still opens on Enter/Space and on focus, and the content stays reachable for keyboard and touch users.
Reviewed by .
"use client";
import { Popover } from "@stridge/noctis";
export default function PopoverHover() {
return (
<p className="text-regular text-secondary">
Reviewed by{" "}
<Popover.Root>
{/*
* openOnHover is a pointer enhancement, never the sole affordance: the trigger is still a
* real button, so it opens on Enter/Space and on focus too. delay/closeDelay keep the card
* from flickering as the pointer passes over.
*/}
<Popover.Trigger
openOnHover
delay={300}
closeDelay={100}
className="rounded-xs font-medium text-foreground underline decoration-dotted underline-offset-2 outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
@ada
</Popover.Trigger>
<Popover.Popup>
<Popover.Title>Ada Lovelace</Popover.Title>
<Popover.Description>Mathematician · Analytical Engine · last active 2h ago.</Popover.Description>
</Popover.Popup>
</Popover.Root>
.
</p>
);
}
Keyboard
| Key | Action |
|---|---|
| Enter / Space | On the trigger: open the popover and move focus into the popup (a keyboard-opened popover appears instantly, without the pointer-open animation). |
| Tab / Shift+Tab | Move focus through the popup's focusable elements (direction-aware under RTL). Non-modal: focus then leaves the popup and continues through the page. Modal: focus wraps within the popup. |
| Esc | Close the popover and return focus to the trigger. |
The popover is non-modal by default: the page stays interactive, Tab eventually moves past the popup into the rest of the page, and clicking outside closes it. Pass modal on Popover.Root for a focus-trapped, scroll-locked popover — focus wraps inside it and the rest of the page is hidden from assistive tech until it closes (Base UI isolates the page directly rather than via aria-modal). Either way, Escape and an outside click dismiss it and focus returns to the trigger.
Advanced
Two layouts reach past the convenience parts; both stay within Base UI, with no new Noctis API:
- Detached / multi-trigger — anchor a single popup to several triggers, or to an element that isn't the trigger, with
Popover.createHandle()(passed ashandletoPopover.Rootand eachPopover.Trigger) and thePopover.Positioneranchorprop. Drive the popup withPopover.Portal+Popover.Positionerdirectly instead ofPopover.Popupso you own the positioning container. - Modal backdrop — for a
modalpopover that should dim the page, add a<Popover.Backdrop>inside the portal and paint it with a sharp overlay scrim. Noctis ships no backdrop by default — a popover is a lightweight overlay — so reach for the Base UI part when a true modal dialog is what you want (and considerDialoginstead).
Anatomy
Compose a popover from its parts. Popover.Root owns the open state (it accepts every Base UI Popover.Root prop — open, defaultOpen, onOpenChange, modal).
Popover.Root— owns the open state; renders no element of its own. Non-modal by default; passmodalfor a focus-trapped, scroll-locked popover.Popover.Trigger— opens the popover. Style it directly or compose aButtonthroughrender.Popover.Popup— the floating, elevated, animated panel. Portals and positions itself; props:side(defaultbottom),align(defaultstart),sideOffset,alignOffset,collisionPadding.Popover.Portal+Popover.Positioner— the lower-level portal and positioning container, for when you need to wrap the popup yourself.Popover.Title— the popup's accessible name, linked viaaria-labelledby(renders an<h2>).Popover.Description— supporting copy under the title, linked viaaria-describedby(renders a<p>).Popover.Close— closes the popover; renders a bare button, composing with anyButtonthroughrender.
The popup is painted by a composed Surface at the menu elevation — the same anchored-overlay tier as Menu, so the two never drift — and its children are wrapped in an internal scrollable region (popover-viewport) that handles overflow. Every rendered part carries a data-slot (popover-trigger, popover-popup, popover-viewport, popover-title, popover-description, popover-close) for host-side styling — pair it with the Base UI state attributes (data-popup-open, data-side, data-starting-style, data-ending-style, data-instant).
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every popover in that region retunes — e.g. .marketing { --noctis-popover-popup-min-width: 18rem; } widens every popup opened beneath it. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI list just the props they pass through.
Popover.Root
No props of its own — forwards to the underlying Base UI part.