Popover
popoveruiClick-triggered floating panel anchored to a trigger element. Supports optional localStorage persistence and configurable dismissal (click-outside, escape, manual). Hand-rolled runes positioning with viewport clamping.
When to use which
Decision GuideDialogs capture immediate user focus for critical actions. Sheets slide in from screen edges for deep secondary forms, while Popovers provide lightweight contextual cards.
| Component | Role | Use when |
|---|---|---|
| Dialog Critical confirmations, destructive warnings, or focused single-purpose tasks requiring user resolution. | Modal Window | |
| Sheet Complex edit forms, filter panes, or shopping carts that retain underlying page context. | Off-Canvas Slide-Over | |
| Drawer Mobile bottom sheets, touch-friendly sliding panels, or secondary inspection trays. | Edge Panel / Bottom Sheet | |
| Popovercurrent Small contextual dropdowns, color pickers, date pickers, or inline configuration flyouts. | Contextual Floating Card |
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/popover.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/popover.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/popover.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/popover.jsonhttps://uipkge.dev/r/svelte/popover.jsonInstalls to:src/lib/components/ui/popover/Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
openControlled open state (`bind:open`). Omit for uncontrolled. | boolean | — | optional |
defaultOpen | boolean | false | optional |
modalModal popovers trap Tab focus while open. Default false. | boolean | false | optional |
persistPersist open state to localStorage. `true` auto-keys, a string sets the key. | string | boolean | false | optional |
closeBehavior | PopoverCloseBehavior | auto | optional |
onOpenChange | (open: boolean) => void | — | optional |
children | Snippet | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
PopoverContextValueinterface PopoverContextValue {
id: string
contentId: string
triggerId: string
isOpen: () => boolean
setOpen: (open: boolean) => void
toggle: () => void
isModal: () => boolean
getCloseBehavior: () => PopoverCloseBehavior
getTriggerEl: () => HTMLElement | null
getAnchorEl: () => HTMLElement | null
registerTrigger: (el: HTMLElement) => void
unregisterTrigger: (el: HTMLElement) => void
registerAnchor: (el: HTMLElement) => void
unregisterAnchor: (el: HTMLElement) => void
}Used by
Files installed (6)
src/lib/components/ui/popover/Popover.svelte2.8 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { PopoverCloseBehavior } from './context' export interface PopoverProps { /** Controlled open state (`bind:open`). Omit for uncontrolled. */ open?: boolean defaultOpen?: boolean /** Modal popovers trap Tab focus while open. Default false. */ modal?: boolean /** Persist open state to localStorage. `true` auto-keys, a string sets the key. */ persist?: string | boolean closeBehavior?: PopoverCloseBehavior onOpenChange?: (open: boolean) => void children?: Snippet } </script> <script lang="ts"> import { setContext } from 'svelte' import { POPOVER_CONTEXT_KEY, type PopoverContextValue } from './context' let { defaultOpen = false, open = $bindable(defaultOpen), modal = false, persist = false, closeBehavior = 'auto', onOpenChange, children, }: PopoverProps = $props() const autoId = $props.id() const storageKey = $derived( persist === false || persist === undefined ? null : persist === true ? `uipkge-popover-${autoId}` : persist, ) // Read the persisted value on the first client pass, then write on change. // Runs only in the browser ($effect never fires during SSR), matching the // Vue twin's onMounted read (no hydration mismatch from a server read). let didInitPersist = false $effect(() => { const key = storageKey if (key && typeof localStorage !== 'undefined') { if (!didInitPersist) { didInitPersist = true if (localStorage.getItem(key) === '1') { setOpen(true) return } } if (open) localStorage.setItem(key, '1') else localStorage.removeItem(key) } }) function setOpen(value: boolean) { open = value onOpenChange?.(value) } let triggerEl: HTMLElement | null = null let anchorEl: HTMLElement | null = null const ctx: PopoverContextValue = { id: autoId, contentId: `${autoId}-content`, triggerId: `${autoId}-trigger`, isOpen: () => open, setOpen, toggle: () => setOpen(!open), isModal: () => modal, getCloseBehavior: () => closeBehavior, getTriggerEl: () => triggerEl, getAnchorEl: () => anchorEl, // First registration wins: a Close trigger nested inside the content must // not steal the positioning anchor from the outer trigger. registerTrigger: (el) => { if (!triggerEl) triggerEl = el }, unregisterTrigger: (el) => { if (triggerEl === el) triggerEl = null }, registerAnchor: (el) => { if (!anchorEl) anchorEl = el }, unregisterAnchor: (el) => { if (anchorEl === el) anchorEl = null }, } setContext(POPOVER_CONTEXT_KEY, ctx) </script> <span data-uipkge="" data-slot="popover" class="contents"> {@render children?.()} </span>src/lib/components/ui/popover/PopoverAnchor.svelte1 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface PopoverAnchorProps extends HTMLAttributes<HTMLSpanElement> { ref?: HTMLSpanElement | null } </script> <script lang="ts"> import { getContext } from 'svelte' import { cn } from '$lib/utils' import { POPOVER_CONTEXT_KEY, type PopoverContextValue } from './context' const ctx = getContext<PopoverContextValue>(POPOVER_CONTEXT_KEY) let { class: className, children, ref = $bindable(null), ...restProps }: PopoverAnchorProps = $props() function registerAnchor(node: HTMLElement) { ctx.registerAnchor(node) return { destroy: () => ctx.unregisterAnchor(node), } } </script> <!-- Positioning needs a measurable box, so unlike the headless reka anchor this renders a shrink-wrap span. Content prefers it over the trigger. --> <span use:registerAnchor {...restProps} bind:this={ref} data-uipkge="" data-slot="popover-anchor" class={cn('inline-block', className)} > {@render children?.()} </span>src/lib/components/ui/popover/PopoverContent.svelte7.6 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { PopoverAlign, PopoverSide } from './context' export interface PopoverContentProps extends HTMLAttributes<HTMLDivElement> { side?: PopoverSide align?: PopoverAlign sideOffset?: number alignOffset?: number onPointerDownOutside?: (e: PointerEvent) => void onEscapeKeyDown?: (e: KeyboardEvent) => void ref?: HTMLDivElement | null } </script> <script lang="ts"> import { getContext } from 'svelte' import { cn } from '$lib/utils' import { POPOVER_CONTEXT_KEY, type PopoverContextValue } from './context' const ctx = getContext<PopoverContextValue>(POPOVER_CONTEXT_KEY) let { class: className, side = 'bottom', align = 'center', sideOffset = 4, alignOffset = 0, onPointerDownOutside, onEscapeKeyDown, ref = $bindable(null), children, ...restProps }: PopoverContentProps = $props() const isOpen = $derived(ctx.isOpen()) let contentEl = $state<HTMLDivElement | null>(null) // Hidden until first measured so the panel never flashes at 0,0. let positioned = $state(false) function portal(node: HTMLElement) { document.body.appendChild(node) return { // Svelte also detaches the node on {#if} teardown; remove() on a // detached node is a no-op, so double-removal is safe. destroy: () => node.remove(), } } function capture(node: HTMLDivElement) { contentEl = node return { destroy: () => { contentEl = null }, } } // Origin sits on the edge facing the trigger so zoom/fade animations grow // out of the anchor (parity with reka's transform-origin behavior). const transformOrigin = $derived.by(() => { const along = align === 'start' ? '0%' : align === 'end' ? '100%' : '50%' if (side === 'bottom') return `${along} 0%` if (side === 'top') return `${along} 100%` if (side === 'right') return `0% ${along}` return `100% ${along}` }) const VIEWPORT_MARGIN = 8 function updatePosition() { const el = contentEl const anchor = ctx.getAnchorEl() ?? ctx.getTriggerEl() if (!el || !anchor) return const r = anchor.getBoundingClientRect() const w = el.offsetWidth const h = el.offsetHeight let top: number let left: number if (side === 'top') { top = r.top - h - sideOffset left = align === 'start' ? r.left + alignOffset : align === 'end' ? r.right - w - alignOffset : r.left + r.width / 2 - w / 2 + alignOffset } else if (side === 'bottom') { top = r.bottom + sideOffset left = align === 'start' ? r.left + alignOffset : align === 'end' ? r.right - w - alignOffset : r.left + r.width / 2 - w / 2 + alignOffset } else if (side === 'left') { left = r.left - w - sideOffset top = align === 'start' ? r.top + alignOffset : align === 'end' ? r.bottom - h - alignOffset : r.top + r.height / 2 - h / 2 + alignOffset } else { left = r.right + sideOffset top = align === 'start' ? r.top + alignOffset : align === 'end' ? r.bottom - h - alignOffset : r.top + r.height / 2 - h / 2 + alignOffset } // Clamp into the viewport (hand-rolled substitute for collision flipping). left = Math.min(Math.max(VIEWPORT_MARGIN, left), Math.max(VIEWPORT_MARGIN, window.innerWidth - w - VIEWPORT_MARGIN)) top = Math.min(Math.max(VIEWPORT_MARGIN, top), Math.max(VIEWPORT_MARGIN, window.innerHeight - h - VIEWPORT_MARGIN)) el.style.top = `${top}px` el.style.left = `${left}px` positioned = true } function onDocumentPointerDown(e: PointerEvent) { const el = contentEl const target = e.target as Node | null if (!el || !target) return if (el.contains(target)) return if (ctx.getTriggerEl()?.contains(target)) return onPointerDownOutside?.(e) if (e.defaultPrevented) return // Parity with the Vue twin: esc/manual/none ignore outside pointer. const mode = ctx.getCloseBehavior() if (mode === 'esc' || mode === 'manual' || mode === 'none') return ctx.setOpen(false) } function trapTab(e: KeyboardEvent) { const el = contentEl if (!el) return const focusables = el.querySelectorAll<HTMLElement>( 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', ) if (focusables.length === 0) { e.preventDefault() return } const first = focusables[0]! const last = focusables[focusables.length - 1]! if (e.shiftKey && document.activeElement === first) { e.preventDefault() last.focus() } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault() first.focus() } } function onDocumentKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') { onEscapeKeyDown?.(e) if (e.defaultPrevented) return // Parity with the Vue twin: click-outside/manual/none suppress Escape. const mode = ctx.getCloseBehavior() if (mode === 'click-outside' || mode === 'manual' || mode === 'none') return ctx.setOpen(false) return } if (e.key === 'Tab' && ctx.isModal()) trapTab(e) } $effect(() => { if (!isOpen) return // Reads side/align/offsets (tracked) so repositioning follows prop changes. updatePosition() const raf = requestAnimationFrame(() => updatePosition()) const onScroll = () => updatePosition() window.addEventListener('scroll', onScroll, { capture: true, passive: true }) window.addEventListener('resize', onScroll) document.addEventListener('pointerdown', onDocumentPointerDown, true) document.addEventListener('keydown', onDocumentKeyDown, true) // Move focus into the panel on open (reka autofocus parity). contentEl?.focus({ preventScroll: true }) return () => { cancelAnimationFrame(raf) window.removeEventListener('scroll', onScroll, { capture: true }) window.removeEventListener('resize', onScroll) document.removeEventListener('pointerdown', onDocumentPointerDown, true) document.removeEventListener('keydown', onDocumentKeyDown, true) } }) // Return focus to the trigger on close (reka parity). let wasOpen = false $effect(() => { if (wasOpen && !isOpen) ctx.getTriggerEl()?.focus({ preventScroll: true }) wasOpen = isOpen }) </script> {#if isOpen} <div use:portal use:capture {...restProps} bind:this={ref} data-uipkge="" data-slot="popover-content" data-state="open" data-side={side} data-align={align} id={ctx.contentId} role="dialog" tabindex={-1} class={cn( 'bg-popover text-popover-foreground motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:ease-emphasized motion-safe:data-[state=open]:blur-in-2 motion-safe:data-[state=closed]:blur-out-2 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--uipkge-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden motion-safe:data-[state=closed]:duration-[var(--dur-exit)] motion-safe:data-[state=open]:duration-200', className, )} style:position="fixed" style:top="0" style:left="0" style:visibility={positioned ? 'visible' : 'hidden'} style:--uipkge-popover-content-transform-origin={transformOrigin} > {@render children?.()} </div> {/if}src/lib/components/ui/popover/PopoverTrigger.svelte2.4 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLButtonAttributes } from 'svelte/elements' export interface PopoverTriggerProps extends HTMLButtonAttributes { /** Render your own element with the trigger's props instead of emitting a * <button> — the Svelte counterpart of React's `asChild`. Spread `props` * onto your element. Positioning anchors to the wrapping span. */ child?: Snippet<[{ props: Record<string, unknown> }]> ref?: HTMLButtonElement | null } </script> <script lang="ts"> import { getContext } from 'svelte' import { cn } from '$lib/utils' import { POPOVER_CONTEXT_KEY, type PopoverContextValue } from './context' const ctx = getContext<PopoverContextValue>(POPOVER_CONTEXT_KEY) let { class: className, type = 'button', child, children, ref = $bindable(null), onclick, ...restProps }: PopoverTriggerProps = $props() const isOpen = $derived(ctx.isOpen()) // In child mode the node is the `contents` wrapper span, which has no box and // can't take focus — register the consumer's element (its first child) // instead, so positioning, outside-click and focus return use the real // trigger, as with React's asChild. function registerTrigger(node: HTMLElement) { const el = (child ? (node.firstElementChild as HTMLElement | null) : null) ?? node ctx.registerTrigger(el) return { destroy: () => ctx.unregisterTrigger(el), } } function handleClick(e: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) { onclick?.(e) if (e.defaultPrevented) return ctx.toggle() } const mergedProps = $derived({ 'data-uipkge': '', 'data-slot': 'popover-trigger', id: ctx.triggerId, 'aria-haspopup': 'dialog' as const, 'aria-expanded': isOpen, 'aria-controls': ctx.contentId, 'data-state': isOpen ? 'open' : 'closed', class: cn(className), onclick: handleClick, ...restProps, }) </script> {#if child} <!-- `contents` keeps this wrapper out of layout, so the consumer's element sits in its parent's flex/grid exactly like React's asChild. --> <span use:registerTrigger data-uipkge="" data-slot="popover-trigger" class="contents"> {@render child({ props: mergedProps })} </span> {:else} <button use:registerTrigger bind:this={ref} {type} {...mergedProps}>{@render children?.()}</button> {/if}src/lib/components/ui/popover/context.ts0.8 kB
export type PopoverCloseBehavior = 'auto' | 'click-outside' | 'esc' | 'manual' | 'none' export type PopoverSide = 'top' | 'right' | 'bottom' | 'left' export type PopoverAlign = 'start' | 'center' | 'end' export interface PopoverContextValue { id: string contentId: string triggerId: string isOpen: () => boolean setOpen: (open: boolean) => void toggle: () => void isModal: () => boolean getCloseBehavior: () => PopoverCloseBehavior getTriggerEl: () => HTMLElement | null getAnchorEl: () => HTMLElement | null registerTrigger: (el: HTMLElement) => void unregisterTrigger: (el: HTMLElement) => void registerAnchor: (el: HTMLElement) => void unregisterAnchor: (el: HTMLElement) => void } export const POPOVER_CONTEXT_KEY = Symbol('uipkge-popover')src/lib/components/ui/popover/index.ts0.4 kB
export { default as Popover, type PopoverProps } from './Popover.svelte' export { default as PopoverAnchor, type PopoverAnchorProps } from './PopoverAnchor.svelte' export { default as PopoverContent, type PopoverContentProps } from './PopoverContent.svelte' export { default as PopoverTrigger, type PopoverTriggerProps } from './PopoverTrigger.svelte' export type { PopoverCloseBehavior, PopoverSide, PopoverAlign } from './context'
Raw manifest:https://uipkge.dev/r/svelte/popover.json