@svelte-put/popover
GithubCompatible with or powered directly by Svelte runes.
Introduction
The Popover API, which landed in baseline in 2024, provides a better standard for creating "floating" elements such as tooltips or popups. This package essentially provides a very minimal reactive wrapper around the Popover API to make it easier to set up and reuse in Svelte.
We are, however, still waiting for browsers to implement the CSS Anchor Positioning AP as a standard to tether elements together. @svelte-put/popover
is therefore often more useful when integrated with a positioning library such as Floating UI. You can skip to Recipes for examples of such integration.
Installation
npm install --save-dev @svelte-put/popover
pnpm add -D @svelte-put/popover
yarn add -D @svelte-put/popover
Quick Start
The example below shows the most minimal setup for @svelte-put/popover
.
<script>
import { Popover } from '@svelte-put/popover';
const popover = new Popover();
</script>
<button class="c-btn" {...popover.control.attributes} use:popover.control.actions>
Open me Popover
</button>
<div
class="fixed inset-0 m-auto p-6 backdrop:bg-black backdrop:opacity-50"
{...popover.target.attributes}
use:popover.target.actions
>
<p>Popover content. Click backdrop to dismiss</p>
</div>
A bit of terminology
@svelte-put/popover
uses the terms control
and target
to refer to the elements that trigger the popover and the popover itself, respectively.
The Popover Object
1 Spreading popover.[control|target].attributes
helps set up SSR-friendly attributes for the popover. These attributes correspond directly to those required for the Popover API. The rendered HTML looks something like this:
<button popovertarget="some-generated-id" popovertargetaction="show">
Open me Popover
</button>
<div popover="auto" id="some-generated-id" inert>
<p>Popover content</p>
</div>
2 On the other hand, use:popover.[control|target].action
sets up additional event listeners for runtime enhancements as discussed in the following Checking if Open and Toggling sections.
Checking if Open
You can now watch for the popover's closed/open state with the rune-powered open
attribute on Popover
instance:
<p>Popover is {popover.open ? 'open' : 'closed'}</p> <!-- popover.open is reactive -->
Toggling
For toggling the popover programmatically, a Popover
instance exposes several methods, which map closely to their Popover API counterparts:
const popover = new Popover();
popover.show(); // Show the popover
popover.hide(); // Hide the popover
popover.toggle(); // Toggle the popover
Configuration
const popover = new Popover({ /** optional init */ });
The Popover
constructor take an init object with the following interface. Notice that everything is optional.
interface PopoverInit {
/**
* unique id for the control & target element pair.
* If not provided, a random one is generated using either
* crypto.randomUUID if available, or Math.random otherwise.
*/
id?: string;
/** whether to set `inert` on target element when it is hidden */
inertWhenHidden?: boolean;
/** additional events to trigger target element */
triggers?: {
/** show popover on mouse hover. Disabled by default */
hover?: TriggerConfig | boolean;
/** show popover when control element has focus. Disabled by default */
focus?: TriggerConfig | boolean;
},
plugins?: PopoverPlugin[] | PopoverPlugin; // discussed more in ##Plugin section
}
interface TriggerConfig {
/** Whether to enable this trigger */
enabled: boolean;
/**
* Milliseconds until popover is auto-dismissed after trigger becomes invalid.
* Defaults to `1000`.
* Set to `0` to disable auto-dismiss
*/
timeoutMs: number;
/**
* Milliseconds to delay showing popover after trigger becomes valid.
* Defaults to `0` (no delay).
*/
delayMs: number;
}
Plugin
@svelte-put/popover
can be extended with one or more plugins, passed to the init object as discussed in Configuration. A plugin has the following interface:
/**
* Plugin to extend Popover functionality.
* @param {PopoverConfig} config - Popover "resolved" configuration
*/
type PopoverPlugin = (config: PopoverConfig) => ({
name?: string;
control?: {
attributes?: HTMLButtonAttributes;
actions?: Action<HTMLButtonElement, Popover>[];
};
target?: {
attributes?: HTMLAttributes<HTMLElement>;
actions?: Action<HTMLElement, Popover>[];
};
});
Defined attributes
and actions
will be added to the respective element. See Recipes for more examples.
Recipes
Building Tooltips with Floating-UI
The example below shows one possible implementation of reusable inline text with tooltip, built in integration with Floating UI
<script>
import HintedText from './HintedText.svelte';
</script>
{#snippet hint()}
<span class="m-0"
>Built with ♡ using <code>@svelte-put/popover </code> and <code>@floating-ui</code></span
>
{/snippet}
<p class="m-0">
<span>Hover on</span>
<HintedText {hint} class="hl-info cursor-help">this text ⓘ</HintedText>
<span>for some hinting action (or focus it using keyboard).</span>
</p>
<script lang="ts">
import { Popover, type PopoverPlugin } from '@svelte-put/popover';
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
import { compute } from './compute';
import './tooltip.css';
let {
children,
hint,
class: cls,
...rest
}: HTMLButtonAttributes & { children: Snippet; hint: Snippet } = $props();
let controlEl: HTMLButtonElement;
let targetEl: HTMLElement;
let arrowEl: HTMLSpanElement;
let cleanup = () => {};
const tooltipPlugin: PopoverPlugin = () => ({
name: 'tooltip',
target: {
attributes: {
role: 'tooltip',
onbeforetoggle: (e) => {
if (e.newState !== 'open') return cleanup();
cleanup = compute(controlEl, targetEl, arrowEl);
},
},
actions: [
(node) => {
node.classList.toggle('enhanced', true);
},
],
},
});
const popover = new Popover({
triggers: {
hover: true,
focus: true,
},
plugins: tooltipPlugin,
});
</script>
<button
bind:this={controlEl}
class="inline-block {cls}"
use:popover.control.actions
{...popover.control.attributes}
{...rest}>{@render children()}</button
>
<span
class="text-hint"
bind:this={targetEl}
{...popover.target.attributes}
use:popover.target.actions
>
<span class="arrow" bind:this={arrowEl}></span>
{@render hint()}
</span>
import { arrow, autoUpdate, computePosition, offset, shift, flip } from '@floating-ui/dom';
/**
* @param {HTMLButtonElement} controlEl
* @param {HTMLElement} targetEl
* @param {HTMLElement} arrowEl
*/
export function compute(controlEl, targetEl, arrowEl) {
return autoUpdate(controlEl, targetEl, async () => {
const arrowLen = arrowEl.offsetWidth;
const floatingOffset = Math.sqrt(2 * arrowLen ** 2) / 2;
if (!controlEl) return;
const { x, y, middlewareData, placement } = await computePosition(controlEl, targetEl, {
placement: 'top',
middleware: [
offset(floatingOffset),
flip({
fallbackAxisSideDirection: 'start',
crossAxis: false,
}),
shift(),
arrow({
element: arrowEl,
}),
],
});
targetEl.style.left = `${x}px`;
targetEl.style.top = `${y}px`;
const staticSide = /** @type {const} */ ({
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
})[placement.split('-')[0]];
if (middlewareData.arrow && staticSide) {
const { x, y } = middlewareData.arrow;
Object.assign(arrowEl.style, {
left: x != null ? `${x}px` : '',
top: y != null ? `${y}px` : '',
right: '',
bottom: '',
[staticSide]: `${-arrowLen / 2}px`,
transform: 'rotate(45deg)',
});
}
});
}
.text-hint[popover] {
position: absolute;
display: block; /* required for animation to work */
max-width: 100dvw;
max-width: 100vw;
padding: 0.25rem 0.5rem;
color: var(--color-fg);
opacity: 0;
background-color: var(--color-bg-100);
border: 1px solid var(--color-outline);
box-shadow: 0 0 8px var(--color-bg-100);
transition-timing-function: var(--default-transition-timing-function);
transition-duration: 250ms;
transition-property: opacity;
&.enhanced {
z-index: 1;
top: 0;
left: 0;
overflow: visible;
/* Avoid layout interference */
width: max-content;
height: fit-content;
margin: 0;
}
&:popover-open {
opacity: 1;
transition-duration: 120ms;
}
& > .arrow {
pointer-events: none;
position: absolute;
z-index: -1;
width: calc(var(--spacing) * 3);
height: calc(var(--spacing) * 3);
background-color: inherit;
border-color: var(--color-outline);
border-style: solid;
border-right-width: 1px;
border-bottom-width: 1px;
}
}
Happy popovering!