@popover-kit/svelte
v0.1.2
Published
A primitive, headless-friendly popover and dropdown toolkit for Svelte 5 — positioning engine, accessibility (focus trap, click-outside, Escape), dark theme and RTL support.
Maintainers
Readme
@popover-kit/svelte
A primitive, headless-friendly popover/tooltip/menu building block for Svelte 5 (runes), with SSR-safe internals, first-class dark theme and native RTL support via Tailwind CSS v4 + daisyUI v5, and optional Lucide icons.
- Pluggable — use the batteries-included
<Popover>component, or go fully headless and wirePopoverControllerto your own markup. - Configurable — placement (12 positions +
autoVertical), offset, animation, trigger interaction (click/hover/focus/manual), focus trap, click-outside, Esc-to-close — all optional props with sane defaults. - Overridable — every visual surface is a plain
.sveltecomponent shipped as source, styled with daisyUI'scard/btnprimitives and a handful of scoped CSS classes (pop-*) you can target or replace. - Zero-cost at rest — no listeners, observers, or focus traps are created until the popover actually opens.
Install
pnpm add @popover-kit/sveltePeer dependency: svelte@^5. No icon library dependency — the built-in
close button renders a plain "✕" character. Pass your own iconSnippet
prop to <PopoverContent> (e.g. a lucide-svelte icon) to override it.
Quick start
<script lang="ts">
import { Popover, PopoverTrigger, PopoverContent } from '@popover-kit/svelte';
</script>
<Popover placement="bottom-start" showArrow>
{#snippet trigger(ctx)}
<PopoverTrigger {ctx} class="btn-primary">Open</PopoverTrigger>
{/snippet}
{#snippet content(ctx)}
<PopoverContent {ctx} title="Hello 👋" showClose>
{#snippet body(ctx)}
<p class="text-sm">Placement: {ctx.resolvedPlacement}</p>
{/snippet}
</PopoverContent>
{/snippet}
</Popover>See demos/PageDemo.svelte for 12 runnable scenarios (placements,
alignments, autoVertical, hover/focus triggers, controlled mode, a
shared/injected controller, a fully headless example, and three
booking/e-commerce-flavored patterns: a Kayak-style search bar, a checkout
confirmation, and a product-discovery tooltip). Run it in a browser via
examples/svelte (see its README).
Dark theme & RTL
- Dark theme: the panel uses daisyUI's
card bg-base-100tokens, so it follows whateverdata-themeyou set on an ancestor — no extra config. - RTL: set
dir="rtl"on any ancestor (or the trigger's own element). Logical alignments (*-start/*-end) automatically resolve to the correct physical side based on computed writing direction;left/rightplacements stay physical, matching how most positioning libraries (and CSS itself) draw the distinction.
Headless usage
import { PopoverController } from '@popover-kit/svelte';
const ctrl = new PopoverController({ placement: 'bottom' });
ctrl.attachTrigger(triggerEl);
ctrl.attachPanel(panelEl);
ctrl.open();PopoverController is pure TypeScript (a .svelte.ts module using runes
for its reactive state) — no component required.
DropdownController — for CSS-anchored panels (no floating positioning)
PopoverController computes position: fixed coordinates, which is more
than you need for a panel that's already positioned with plain CSS (e.g.
position: relative on a wrapper + top-full on the panel) — an account
switcher, an inline menu, anything anchored by layout rather than JS.
DropdownController is a separate, smaller primitive for exactly that case:
open/close state, click-outside, Escape, and a focus trap — no
computePosition, no ResizeObserver, no scroll/resize tracking, and no
4-state animation lifecycle (Svelte's own {#if isOpen} + transition:
already keeps a panel mounted through its exit transition natively, so
there's nothing for the controller to coordinate there).
PopoverController and DropdownController both extend
OverlayControllerBase, which owns everything genuinely identical between
them — id generation, options-as-getters, click-outside/Esc/focus-trap
wiring — so the a11y behavior isn't duplicated or able to drift between the
two. OverlayControllerBase is exported too, for a third overlay type
(context menu, tooltip, ...) to extend the same way.
Wire either controller to real DOM elements with use:overlayTrigger /
use:overlayPanel — plain Svelte actions, no added DOM, no styling. This is
the same mechanism <Popover>'s own trigger wiring uses internally, so it's
exercised by the package's own components, not just offered for headless use:
<script lang="ts">
import { DropdownController, overlayTrigger, overlayPanel } from '@popover-kit/svelte';
const dropdown = new DropdownController({
hooks: {
// Runs right before the panel opens — e.g. refresh data on open.
beforeOpen: () => refreshList()
}
});
</script>
<button use:overlayTrigger={dropdown} onclick={() => dropdown.toggle()}>
Toggle
</button>
{#if dropdown.isOpen}
<div use:overlayPanel={dropdown} transition:fly={{ y: -5 }}>...</div>
{/if}No $effect/bind:this wiring needed — the actions attach on mount and
detach on unmount by themselves. PopoverController accepts the same
hooks.beforeOpen option and works with the same two actions.
License
MIT
