Popover
A click-triggered disclosure panel for rich or interactive content, such as a filter form, a settings menu, or extra detail. It is non-modal, with no focus trap and no backdrop: reach for Modal when you need a focus-trapped dialog, or Dropdown when you need a menu of actions.
Import
import { Popover } from "@hyzer-labs/ui"Demo
Click the trigger to open the panel. Controls inside work normally, and Tab moves through them and back out again, with no focus trap. Click outside, or press Escape, to close.
let showFinished = $state(true);
let showUpcoming = $state(true);
<Popover triggerLabel="Filters">
{#snippet triggerIcon()}<IconSettings />{/snippet}
<Stack gap="sm">
<Checkbox name="finished" label="Finished rounds" bind:checked={showFinished} />
<Checkbox name="upcoming" label="Upcoming tee times" bind:checked={showUpcoming} />
</Stack>
</Popover>placement combines a side with a start/end/center alignment. It flips automatically
if the preferred side would overflow the viewport.
Panel content
Panel content
Panel content
Panel content
Panel content
Panel content
Panel content
Panel content
<Popover triggerLabel="bottom-start" placement="bottom-start"><p>Panel content</p></Popover>
<!-- …one per placement: a side ('top' | 'bottom' | 'left' | 'right'),
optionally suffixed -start or -end, e.g. 'top-end' -->The trigger snippet is an escape hatch: it hands you an attrs bag to
spread onto any element. Here that is a ghost-variant Button with its own
icon and label, in place of the default trigger.
Panel content
<Popover placement="bottom-end">
{#snippet trigger(attrs)}
<Button {...attrs} variant="ghost">
{#snippet iconStart()}<IconUser />{/snippet}
Account
</Button>
{/snippet}
<p>Panel content</p>
</Popover>You can still use the page behind this panel.
Props
| Name | Type | Default | Note |
|---|---|---|---|
open | boolean | false | $bindable. |
placement | 'top' | 'bottom' | 'left' | 'right' | '<side>-start' | '<side>-end' | 'bottom-start' | A bare side centers on the trigger; -start/-end add alignment. left/right follow the trigger's writing direction, so RTL flips the physical side. See Positioning for how a placement resolves. |
offset | number | 8 | Gap from the trigger, in px. |
autoFocus | boolean | false | true moves focus to the panel's first focusable element when it opens. Off by default, because a disclosure should not steal focus. |
dismissible | boolean | true | false turns off closing on an outside click. Escape still closes the panel either way; there is no opt-out. |
label | string | — | The panel's accessible name, used when it has no visible heading of its own. |
onopen | () => void | — | |
onclose | () => void | — | |
triggerLabel | string | — | Visible label for the default composed-Button trigger. Ignored when `trigger` is provided. |
triggerProps | PopoverTriggerProps | {} | Trigger appearance pass-through. Resolves to variant: 'outline', intent: 'neutral'. |
triggerIcon | Snippet | — | Icon for the default Button trigger. |
trigger | Snippet<[TriggerAttrs]> | — | Escape hatch. Wins over triggerLabel/triggerProps/triggerIcon. Receives an attrs bag (id, aria-expanded, aria-controls, popovertarget, onclick) to spread onto your own element: a link, an avatar, a custom control. |
children | Snippet | — | Required. The panel content. Interactive controls are supported. |
class | string | — | Merged after the hz-popover class. |
Theme hooks
What this component promises your CSS. The reference theme styles exactly these — from @layer hz-theme, so your unlayered rules win. See Styling Components for the how.
Root class: .hz-popover
Data attributes
| Hook | Values | Styles |
|---|---|---|
data-open | present while open | On the root — the Dropdown parity hook. |
data-state (panel) | 'open' | 'closed' | On .hz-popover-panel — mirrors the open prop; the hook to animate the entrance against. |
data-placement | Placement | On .hz-popover-panel — the requested placement string, e.g. "bottom-start". |
data-side | 'top' | 'bottom' | 'left' | 'right' | On .hz-popover-panel — the resolved, physical side. See Positioning for the caret recipe. |
data-align | 'start' | 'center' | 'end' | On .hz-popover-panel — the resolved cross-axis alignment (RTL-aware for a top/bottom placement). |
Custom properties
| Hook | Values | Styles |
|---|---|---|
--hz-z-popover | <number> — default 200 | Matters mainly on the non-top-layer fallback path (older browsers) — a real top-layer panel stacks above everything regardless of z-index. |
Part classes
| Hook | Values | Styles |
|---|---|---|
.hz-popover-trigger | on a Button | Merged onto the default composed Button trigger — absent when the `trigger` snippet escape hatch is used instead, since that renders your own element. |
.hz-popover-panel | child element | The disclosure panel surface. |
.hz-popover-content | child element | The scroll container inside the panel — the panel itself stays overflow-visible so a caret you draw can protrude. See Positioning for the recipe. |
Accessibility
The trigger carries aria-expanded and aria-controls pointing at the panel. That is the APG Disclosure pattern, not a dialog. The panel itself is a plain region by default, or a labeled role="group" when you pass label (for a panel with no visible heading). It is never aria-modal, never traps focus, and never renders a backdrop. Interactive content inside flows through the normal tab order and back out.
Escape always closes the panel and returns focus to the trigger, even with dismissible: false. There is no opt-out, the same rule Modal follows for its own Escape handling. Clicking outside the panel closes it too, unless dismissible: false. Unlike Escape, an outside click leaves focus wherever the click landed rather than pulling it back.
By default (autoFocus: false) opening the panel does not move focus at all, because a disclosure should not take focus uninvited. Set autoFocus when the panel's first control really is the next thing someone wants, such as a search field.
References: APG Disclosure (Show/Hide) pattern · MDN: Popover API · WCAG 2.2 SC 1.4.13: Content on Hover or Focus