Popover
Floating content panel anchored to a trigger.
Playground
<script lang="ts">
import { ChevronDownIcon, Popover } from '@urbicon-ui/blocks';
</script>
<Popover
offsetDistance={4}
placement="bottom-start"
>
{#snippet trigger()}
<div
class="bg-surface-base border-border-default hover:border-border-emphasis flex w-80 cursor-pointer items-center justify-between rounded-lg border px-3 py-2.5 text-sm transition-colors"
>
<span class="text-text-secondary">Choose an option…</span>
<ChevronDownIcon class="text-text-tertiary h-4 w-4" />
</div>
{/snippet}
<div class="divide-border-subtle divide-y">
{#each ['Design tokens', 'Component variants', 'Documentation'] as option (option)}
<div
class="text-text-primary hover:bg-surface-hover cursor-pointer px-3 py-2 transition-colors first:rounded-t-md last:rounded-b-md"
>
{option}
</div>
{/each}
</div>
</Popover>01 Purpose
Popover is a floating panel anchored to a trigger. Reach for it for contextual surfaces like pickers, inline help or a profile panel that sit next to the element the user acted on without blocking the page. You supply the trigger button and the panel content. The popover positions itself, flips when space runs out, and opens and closes on its own.
| Reach for | When you need | Focus |
|---|---|---|
| Popover (this) | A contextual panel by the trigger: a picker, inline help, a profile panel. | Non-modal; focus flows past. |
| Tooltip | A short hover/focus description tied to aria-describedby. | Non-interactive. |
| Menu | A list of selectable actions with arrow-key navigation. | Roving focus. |
| Dialog / Drawer | A blocking, modal flow: confirmation, form, edge sheet. | Focus trapped. |
Inside flowing text: a trigger that
sits in a paragraph needs inline, which makes its wrapper
a <span> so a <div> panel cannot break the surrounding <p>.
02 Examples
Rich content
trigger snippet, and the panel is whatever markup you put inside.<Popover placement="bottom-end">
{#snippet trigger()}
<button aria-label="Open user menu">JD</button>
{/snippet}
<div class="w-64">
<!-- profile header, actions, sign-out -->
</div>
</Popover>Controlled open state
bind:open lets outside code read and drive the panel.<script>
let open = $state(false);
</script>
<Popover bind:open>
{#snippet trigger()}
<Button>{open ? 'Viewing' : 'View'} status</Button>
{/snippet}
<div class="p-3">...</div>
<Button onclick={() => (open = false)}>Dismiss</Button>
</Popover>03 Customization
Primary-framed callout
class frames the panel in the primary token, a border-primary edge and a soft primary ring, to mark a branded or high-priority surface. Only the border and ring change. The fill, radius and motion stay as they are.<Popover class="border-primary ring-primary/35 ring-2">
{#snippet trigger()}
<Button intent="primary">Upgrade plan</Button>
{/snippet}
<div class="w-60 p-1">
<div class="text-primary-emphasis text-sm font-semibold">Go Premium</div>
<p class="text-text-secondary mt-1 mb-3 text-xs leading-relaxed">
Unlock advanced analytics, priority support and unlimited projects.
</p>
<Button intent="primary" size="sm" class="w-full">Start free trial</Button>
</div>
</Popover>This is one of five ways to restyle a block. See Customization for class, slotClasses, unstyled, preset and provider-level overrides.
04 Accessibility
ARIA attributes
The trigger's first interactive element receives aria-haspopup="dialog" and aria-expanded reflecting the open state. The floating
panel carries role="dialog" by default. aria-modal is passed through as an attribute only.
The popover never traps focus, so leave it unset and reach for Dialog when a flow is genuinely modal.
Keyboard
Enter / Space toggle the popover when the trigger is focused. Escape closes it and returns focus to the trigger. Tab moves through the focusable content inside.
Focus & dismissal
Clicking outside dismisses the popover automatically. Set closeOnClickOutside to false to pin it open until you toggle open yourself. For a flow that must hold focus until
dismissed, use Dialog or ConfirmDialog instead.
Motion & reduced motion
The panel fades and scales in over about 150 ms. Override the timing per instance with transitionDuration / transitionEasing, and under prefers-reduced-motion the motion collapses to near-instant.
05 API Reference
26 propsProp | Type | Default | Description | |
|---|---|---|---|---|
children required | Snippet | — | Popover body rendered inside the floating panel. | |
autoTrigger | boolean | — | When true (default), the trigger wrapper handles click and keyboard to toggle the popover. Set to false to manage open yourself. | |
class | string | — | Extra classes merged onto the floating panel element. | |
closeOnClickOutside | boolean | — | Whether the popover closes on outside click / pointer interaction.
Default true. Set to false to pin the popover open until the
consumer explicitly toggles open. | |
closeOnEscape | boolean | — | Whether the popover closes on Escape key. Default true.
Set to false for cases where Escape should be intercepted by an
inner widget (e.g. an editable cell that wants to revert on Escape). | |
inline | boolean | false | Render in a way that is legal inside **phrasing content** — a paragraph or a
heading. Set this when the trigger sits in flowing text, as a citation chip
does inside a markdown paragraph.
A <label> is phrasing content too, but think twice there: after mount the
panel is a DOM descendant of the label, so a click on non-interactive panel
content forwards activation to the labelled control. Top-layer promotion
changes where the panel *paints*, not where it sits in the tree.
Two things change. The trigger wrapper becomes a <span> instead of a
<div>, and the panel is not rendered on the server at all — it appears
after mount. Both are needed together: a <div> start tag closes an open
<p> regardless of how deeply it is nested, so wrapping the trigger alone
leaves the panel to break the paragraph instead.
Withholding the panel costs nothing the server render was providing: it is
hidden and inert until opened, and opening requires the client anyway.
Without it, an SSR'd page with a popover in a paragraph emits invalid HTML.
The parser repairs it by closing the <p> early, which makes the server DOM
differ from the component tree — Svelte logs node_invalid_placement_ssr
and then hydration_mismatch, and the layout visibly shifts on hydration.
**Cost:** the panel is absent from the prerendered HTML, so a non-rendering
crawler never sees its content and the first paint has nothing to show if
the popover is meant to open immediately. Leave it off for popovers in
block context, where the default markup is already valid. | |
offsetDistance | number | — | Gap in px between the trigger edge and the popover. | |
onClickOutside | () => void | — | Fires after an outside click closes the popover. Use for analytics
or to clear ephemeral state on dismiss. Does NOT control whether the
popover closes — that is governed by closeOnClickOutside. | |
onEscape | () => void | — | Fires after Escape closes the popover. Use for analytics or to clear
ephemeral state on dismiss. Does NOT control whether the popover
closes — that is governed by closeOnEscape. | |
onOpenChange | (open: boolean) => void | — | Fires when the popover opens or closes from user interaction (click, escape, click-outside). Receives the new open state. | |
open | boolean | — | Controlled open state. Supports bind:open. | |
placement | Placement | — | Where the popover appears relative to the trigger. All standard
Placement values (side plus optional -start/-end alignment) are
supported. | |
preset | string | — | Apply a named preset registered via <BlocksProvider presets={{ Popover: {...} }}>.
Prefer this over class overrides when the requested look falls outside the
semantic intent palette — presets keep hover/active/dark-mode logic coherent
and make the custom look reusable across the project. | |
shiftPadding | number | — | Minimum px padding from viewport edges when the popover shifts to stay visible. | |
size variant | lgmdsm | md | Controls the dimensions, padding, and text size of the Popover. Affects the component's physical footprint. Available options: lg, md, sm. | |
slotClasses | Partial<Record<'base', string>> | — | Per-slot class overrides. Available slots: base (the floating panel). | |
syncMinWidth | boolean | — | Match the popover's *minimum* width to the trigger width while still letting content grow the panel beyond it. Useful for menu-style overlays where items longer than the trigger should not get truncated. Ignored when syncWidth is true (hard width wins). | |
syncWidth | boolean | — | Match the popover width to the trigger width. Useful for select/autocomplete patterns where the floating panel should align with the input. | |
transitionDuration | number | — | Override the enter/exit fade duration in ms. Defaults to the shared token
--blocks-popover-duration (150ms; collapses to 1ms under
prefers-reduced-motion). | |
transitionEasing | string | — | Override the enter/exit easing as a CSS <easing-function> (e.g.
'linear', 'ease-out', 'cubic-bezier(0.4,0,0.2,1)'). Defaults to the
token --blocks-popover-easing. A CSS string, not a JS easing fn, because
the popover motion is a pure CSS transition. | |
trigger | Snippet | — | Trigger element that anchors and toggles the popover. Receives click/keyboard handlers when autoTrigger is true. | |
triggerElement | HTMLElement | — | External trigger element ref. Use instead of the trigger snippet when the trigger lives outside the Popover tree. Supports bind:triggerElement. | |
unstyled | boolean | — | Strip all default tv() classes (including the enter/exit motion). Combine with class or slotClasses for full custom styling; the panel always carries data-state="open" | "closed", so custom motion can rebuild on that hook (see popoverMotion in popover.variants.ts for the reference implementation). | |
usePortal | boolean | true | Render the floating panel into the browser's top layer via the native
popover attribute, so it cannot be clipped by overflow: auto ancestors.
Set to false when the popover is itself embedded inside another floating
surface (Dialog, Drawer, another Popover) — nested top-layer rendering
stacks unpredictably across browsers and stealing focus from the parent
surface is usually unwanted. In-flow mode positions the panel absolutely
relative to the trigger's offset parent. | |
...HTMLAttributes<HTMLDivElement> inherited | HTMLAttributes | — | HTML attributes (excluding: 'children') | |
...PopoverVariants variant | VariantProps | — | Styling variants from PopoverVariants |
06 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
PopoverProps | interface | props | 0 | — | |
PopoverVariants | type | variant | 1 | — | |
Placement | type | helper | 1 | — | |
Side | type | helper | 0 | — | |
Alignment | type | helper | 0 | — |
07 Installation
Import
import { Popover } from '@urbicon-ui/blocks';