Popover
Floating content panel anchored to a trigger with precise positioning, portal rendering, and size syncing.
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 When to use
Popover is a floating panel anchored to a trigger element. Use it for
contextual surfaces — action menus, date pickers, inline help — that should appear next to the
element the user just interacted with. Placement comes from the library's built-in
zero-dependency floating engine (flip, shift, offset), with native popover="auto" for the open/close lifecycle. Not modal.
Pick a different overlay if you need:
- A modal sheet that opens from the edge of the viewport (focus trap, backdrop) → Drawer.
- A hover-only description tied to
aria-describedby→ Tooltip. - A list of selectable actions or items with full keyboard semantics → Menu.
- A centered, blocking modal (confirmation, short form) → Dialog.
Inside a paragraph
A popover whose trigger sits in flowing text — a citation marker in a sentence, a term with a
definition — needs inline. By default the trigger wrapper and the panel are both <div>s, and a <div> start tag closes an open <p> however deeply it is nested. On a server-rendered page the browser repairs
that by ending the paragraph early, so the DOM it builds no longer matches the component tree and
hydration reports a mismatch.
inline makes the wrapper a <span> and withholds the panel from the
server render, adding it on mount. The cost is that the panel's content is absent from the prerendered
HTML, so a non-rendering crawler never sees it — which is why it is opt-in rather than the default.
There is nothing to see in the playground: the difference is entirely in the server output.
02 Examples
Placements
{#each [{ label: 'Top', placement: 'top' }, { label: 'Top Start', placement: 'top-start' }, { label: 'Top End', placement: 'top-end' }, { label: 'Bottom', placement: 'bottom' }, { label: 'Left', placement: 'left' }, { label: 'Right', placement: 'right' }] as const as { label, placement } (placement)}
<Popover {placement}>
{#snippet trigger()}
<Button variant="outlined" size="sm">{label}</Button>
{/snippet}
<div class="text-text-secondary px-3 py-2 whitespace-nowrap">
Placed at <span class="text-text-primary font-medium">{placement}</span>
</div>
</Popover>
{/each}Rich Content – User Profile
<Popover placement="bottom-end">
{#snippet trigger()}
<div class="avatar-trigger">JD</div>
{/snippet}
{#snippet children()}
<div class="w-64">
<!-- profile card content -->
</div>
{/snippet}
</Popover>Controlled State
<script>
let open = $state(false);
</script>
<Popover bind:open>
{#snippet trigger()}
<Button>{open ? 'Viewing' : 'View'} Status</Button>
{/snippet}
{#snippet children()}
<div class="p-3">...</div>
<Button onclick={() => open = false}>Dismiss</Button>
{/snippet}
</Popover>03 Customization
Gradient Action Panel
<Popover
slotClasses={{
base: 'bg-linear-to-br from-violet-600 to-indigo-700 border-none shadow-xl shadow-violet-500/20 text-white'
}}
size="md"
>
{#snippet trigger()}
<Button intent="primary">Upgrade Plan</Button>
{/snippet}
<div class="w-64 p-4">
<div class="mb-1 text-sm font-bold text-white">Go Premium</div>
<p class="mb-3 text-xs leading-relaxed text-white/75">
Unlock advanced analytics, priority support, and unlimited projects.
</p>
<Button
unstyled
class="w-full rounded-lg bg-white px-3 py-2 text-center text-sm font-semibold text-violet-700 transition-all hover:bg-white/90"
>
Start Free Trial
</Button>
</div>
</Popover>Glass Morphism (unstyled)
<Popover
unstyled
class="w-56 rounded-2xl border border-white/20 bg-white/10 p-4 text-white shadow-2xl backdrop-blur-xl"
>
{#snippet trigger()}
<Button
unstyled
class="rounded-full border border-white/30 bg-white/10 px-5 py-2.5 text-sm font-medium text-white backdrop-blur-sm transition-all hover:bg-white/20"
>
Quick Actions
</Button>
{/snippet}
<div class="space-y-1">
{#each ['New Project', 'Import Data', 'Invite Team'] as action (action)}
<div
class="cursor-pointer rounded-lg px-3 py-2 text-sm text-white/90 transition-colors hover:bg-white/10"
>
{action}
</div>
{/each}
</div>
</Popover>A branded panel that several popovers share belongs in a BlocksProvider preset (presets.Popover), applied via preset — see Customization.
04 Accessibility
ARIA Attributes
The trigger wrapper sets aria-haspopup="dialog" and aria-expanded reflecting the current open state. The
floating panel receives 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 the popover and returns focus to the trigger. Tab moves through focusable content inside the popover.
Click Outside
Clicking outside the popover or its trigger closes it automatically. Set closeOnClickOutside to false to pin the popover open until you toggle open yourself; the onClickOutside callback fires after an outside click has
dismissed it.
Focus Management
The popover never traps focus — it is a non-modal surface, and Tab moves on past its
content. Closing with Escape returns focus to the trigger. For forms or critical actions
that must hold focus until dismissed, use Dialog or ConfirmDialog instead.
Motion & Reduced Motion
The panel fades and scales in over the --blocks-popover-duration / --blocks-popover-easing tokens (150 ms by default,
CSS-native via @starting-style and discrete
transitions — Menu inherits it). Override per instance with transitionDuration / transitionEasing; under prefers-reduced-motion both collapse to near-instant
automatically. With unstyled, rebuild motion on the
panel's data-state attribute.
05 API Reference
Prop | 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';