Lynx/Modules/Zero/Popover
@sigx/lynx-zero · Beta · Component library

Popover#

A non-modal panel anchored to its trigger. The popup renders in the overlay outlet and is positioned in the outlet's own space, flipping and clamping against the safe frame. A tap outside closes it, and a pan beside it still scrolls the page.

The anatomy is zero's popover scope, shared with the web. See Popover in @sigx/zero for the contract. This page covers what is different on Lynx.

Import#

TSX
import { Popover } from '@sigx/lynx-zero';

Popover is a compound: Popover.Root, Popover.Trigger, Popover.Popup, Popover.Title, Popover.Description and Popover.Close.

Usage#

Like every overlay, Popover needs a ZeroRoot above it for the outlet:

TSX
import { component } from '@sigx/lynx';
import { Popover, ZeroRoot } from '@sigx/lynx-zero';

export const Help = component(() => {
    return () => (
        <ZeroRoot>
            <Popover.Root placement="bottom-start" color="primary">
                <Popover.Trigger><text>What is this?</text></Popover.Trigger>
                <Popover.Popup>
                    <Popover.Title>Sync</Popover.Title>
                    <Popover.Description>Changes upload when you are back online.</Popover.Description>
                    <Popover.Close><text>×</text></Popover.Close>
                </Popover.Popup>
            </Popover.Root>
        </ZeroRoot>
    );
});

A tap on Popover.Trigger toggles the popup. Bind the open state with model, or seed it with defaultOpen. openChange fires either way. Popover.Title and Popover.Description are <text> parts. The description is the popup's muted body line under the title.

Placement#

placement is the preferred side and alignment: top, bottom, left or right, optionally with -start or -end. The default is bottom. offset is the gap to the anchor in px, 4 by default. When the preferred side does not fit, the popup flips, and the side it actually rendered on is stamped as the popup's placement (a class and data-placement), just as the web behavior writes it.

What the platform changes#

Positioned in the outlet's space#

The popup and the safe frame are measured together with the anchor (boundingClientRect). The popup is flipped and clamped against the safe frame, so it never lands in a status-bar or home-indicator strip or under a header. The outlet itself is not measured: it sits at the page root's origin with the window's size.

A transform, such as a screen sliding in on a navigation push, fires no layout event. So an open popup keeps re-measuring until its anchor and the frame hold still inside the window. A popup opened at mount (defaultOpen, or a cold deep link) therefore lands at its anchor instead of clamped against an edge, and it follows its anchor in measured steps while its screen slides. Each burst is capped at about two seconds of motion.

Only an open popup measures. A closed trigger takes no measurement and runs no settle loop, and every re-measure loop on the page ticks on one shared clock. A screen with dozens of popovers costs about what one does.

Light dismiss, and pans pass through#

Lynx has no document-level outside-press listener, so the popover renders its own transparent outside surface behind the popup. A tap on it dismisses through the shared layer stack, innermost first, so a popover inside a dialog closes before the dialog. The surface is taken out of native hit-testing, so a pan beside the popup scrolls the page, as on the web. The popup follows its anchor through the scroll and its fling.

Parts not taken#

The arrow part is painted by the web target only. The separate anchor part is not taken: the popup always anchors to Popover.Trigger.

Press feedback#

The trigger and Close scale on the main thread when touched and carry the pressed flag. See Press feedback.

Props#

Popover.Root#

PropTypeDefaultDescription
modelboolean—Two-way binding of the open state.
defaultOpenbooleanfalseThe initial open state when there is no model.
placementLynxPlacement'bottom'Preferred side and alignment: top, bottom, left, right, each optionally -start / -end.
offsetnumber4Gap between the anchor and the popup, in px.
color / size / variantstringskin defaultThe design system's variant axes, stamped on every part.
EventPayloadDescription
openChange (onOpenChange)booleanThe open state changed.

Popover.Trigger#

PropTypeDefaultDescription
disabledbooleanfalseIgnores taps and shows no press.
classstring—Extra classes.

Popover.Popup, Popover.Title, Popover.Description#

class and the default slot.

Popover.Close#

PropTypeDefaultDescription
disabledbooleanfalseIgnores taps, shows no press, and is announced disabled.
labelstring"Close"Accessible name.
classstring—Extra classes.

Anatomy on Lynx#

PartElementStatesFlags
triggerview (button trait, the anchor)open, closeddisabled, pressed
popupview (in the outlet, absolutely positioned)open— (plus the resolved placement)
titletext——
descriptiontext——
closeview (button trait)—disabled, pressed

Building your own anchored overlay#

createAnchorPosition is the positioning half Popover and Select are built on. Call it in setup, wire anchorRef and anchorLayoutChange on the anchor, and floatingRef, floatingLayoutChange and style() on the popup. Pass isOpen so a closed overlay takes no measurements and runs no settle loop:

TSX
const position = createAnchorPosition({
    placement: 'bottom-start',
    offset: 8,
    isOpen: () => state.open,
});

Without isOpen, the overlay is treated as always open. A custom overlay rendered through useOverlayPortal() must spread OVERLAY_ROOT_STYLE into its root's inline style, or its taps fall through to the page. See the usage guide for the outlet's rules.

See also#