Popover
A floating interactive panel anchored to a trigger element. Unlike a tooltip, a popover can contain any interactive content (forms, menus, rich text) via slots.
Features
12 Placements — top/bottom/left/right with start/end/center variants; auto-flips near viewport edges 3 Trigger modes: click (default), hover, focus — comma-separated for combinations Controlled open state — use the openattribute for programmatic controlPowered by orbit — efficient auto-updating position via @vielzeug/orbitAccessible: role="dialog"on panel, configurablearia-label
Source Code
View Source Code
ts
import { createStableId, define, html, prop, syncAria } from '@vielzeug/craft';
import { type Placement } from '@vielzeug/orbit';
import { computed } from '@vielzeug/ripple';
import { type DialogCloseReason, type OverlayOpenReason, parseStringTriggers } from '../../headless';
import { disablableBundle } from '../../shared';
import { reducedMotionMixin } from '../../styles';
import { useFloatingTrigger } from '../shared/use-floating-trigger';
import styles from './popover.css?inline';
export type PopoverTrigger = 'click' | 'focus' | 'hover';
const PANEL_OFFSET = 8;
const VALID_TRIGGERS = new Set<PopoverTrigger>(['click', 'focus', 'hover']);
const DEFAULT_POPOVER_TRIGGERS: PopoverTrigger[] = ['click'];
const normalizeTriggers = (value: unknown): PopoverTrigger[] =>
parseStringTriggers(String(value ?? ''), VALID_TRIGGERS, DEFAULT_POPOVER_TRIGGERS);
export type SgPopoverEvents = {
/** Emitted when the popover closes */
close: { reason: DialogCloseReason };
/** Emitted when the popover opens */
open: { reason: OverlayOpenReason };
};
/** Popover component properties */
export type SgPopoverProps = {
/** Disable the popover */
disabled?: boolean;
/** Accessible label for the panel */
label?: string;
/** Gap between trigger and panel in px */
offset?: number;
/** Controlled open state */
open?: boolean;
/** Preferred placement relative to the trigger */
placement?: Placement;
/** Which trigger(s) open/close the popover */
trigger?: string;
};
/**
* A floating panel anchored to a trigger element.
*
* @element sg-popover
* @attr {string} placement - Preferred placement (default: 'bottom')
* @attr {string} trigger - 'click' | 'hover' | 'focus' (default: 'click')
* @attr {boolean} open - Controlled open state
* @attr {number} offset - Gap in px (default: 8)
* @attr {boolean} disabled - Disables the popover
* @attr {string} label - aria-label on the panel
* @fires open - When the panel opens. detail: { reason: string }
* @fires close - When the panel closes. detail: { reason: string }
* @slot - The trigger element
* @slot content - Panel content
* @part panel - Panel container.
*
* @example
* ```html
* <sg-popover placement="bottom" trigger="click">
* <sg-button>Open info</sg-button>
* <div slot="content">
* <p>Popover body content goes here.</p>
* </div>
* </sg-popover>
* ```
*/
export const POPOVER_TAG = 'sg-popover' as const;
define<SgPopoverProps, SgPopoverEvents>(POPOVER_TAG, {
props: {
...disablableBundle,
label: prop.string(),
offset: prop.number(PANEL_OFFSET),
open: prop.json(undefined as boolean | undefined),
placement: prop.oneOf(
[
'bottom',
'bottom-end',
'bottom-start',
'left',
'left-end',
'left-start',
'right',
'right-end',
'right-start',
'top',
'top-end',
'top-start',
] as const,
'bottom',
),
trigger: prop.string('click'),
},
setup(props, { el, emit, onCleanup, onMounted, slots }) {
const shadowRoot = el.shadowRoot;
const isDisabled = computed(() => Boolean(props.disabled.value));
const panelId = createStableId('popover');
const triggers = computed<PopoverTrigger[]>(() => normalizeTriggers(props.trigger.value));
let panelEl: HTMLElement | null = null;
const floating = useFloatingTrigger({
bindTriggerAria: (triggerEl) =>
syncAria(
triggerEl,
{
controls: () => panelId,
disabled: () => String(isDisabled.value),
expanded: () => String(floating.visible.value),
haspopup: 'dialog',
},
{ autoCleanup: false },
),
disabled: isDisabled,
getPanel: () => panelEl,
offset: props.offset,
onCleanup,
onClose: (reason) => emit('close', { reason }),
onOpen: (reason) => emit('open', { reason }),
openProp: props.open as typeof props.open & { value: boolean | undefined },
placement: computed(() => props.placement.value as Placement),
slot: () => shadowRoot?.querySelector<HTMLSlotElement>('slot:not([name])') ?? null,
slotElements: slots.elements(),
triggers,
});
onMounted(() => floating.mount());
return html`
<slot></slot>
<div
class="panel"
part="panel"
id="${panelId}"
role="dialog"
aria-modal="false"
popover="manual"
:aria-label="${props.label}"
:aria-hidden="${() => String(!floating.visible.value)}"
ref=${(ref: HTMLElement) => {
panelEl = ref;
}}>
<slot name="content"></slot>
</div>
`;
},
styles: [reducedMotionMixin, styles],
});Basic Usage
Wrap the trigger element in the default slot and place panel content in the content slot.
html
<sg-popover>
<sg-button>Open popover</sg-button>
<div slot="content" style="padding: 1rem;">
<p>This is the popover content.</p>
</div>
</sg-popover>Placement
Trigger Modes
Rich Content
The content slot accepts any HTML — forms, cards, images, custom layouts.
Controlled Open State
Use the open attribute to programmatically show or hide the popover.
html
<sg-popover id="my-popover" placement="bottom">
<sg-button id="trigger-btn">Open</sg-button>
<div slot="content" style="padding:1rem;">
<p>Controlled popover content.</p>
<sg-button id="close-btn" size="sm" variant="ghost">Close</sg-button>
</div>
</sg-popover>
<script type="module">
import '@vielzeug/sigil';
const popover = document.getElementById('my-popover');
document.getElementById('trigger-btn').addEventListener('click', () => {
popover.setAttribute('open', '');
});
document.getElementById('close-btn').addEventListener('click', () => {
popover.removeAttribute('open');
});
</script>Disabled
Listening to Events
html
<sg-popover id="pop">
<sg-button>Toggle</sg-button>
<div slot="content" style="padding:0.75rem;">Panel content</div>
</sg-popover>
<script type="module">
import '@vielzeug/sigil';
const pop = document.getElementById('pop');
pop.addEventListener('open', (e) => console.log('popover opened because:', e.detail.reason));
pop.addEventListener('close', (e) => console.log('popover closed because:', e.detail.reason));
</script>API Reference
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
placement | 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'right' | 'right-start' | 'right-end' | 'bottom' | Preferred placement |
trigger | string | 'click' | Trigger mode(s) — click, hover, focus, comma-separated |
open | boolean | false | Controlled open state |
offset | number | 8 | Gap in pixels between trigger and panel |
disabled | boolean | false | Prevent the popover from opening |
label | string | — | aria-label for the panel |
Slots
| Slot | Description |
|---|---|
| (default) | The trigger element the panel is anchored to |
content | Content rendered inside the floating panel |
Events
| Event | Detail | Description |
|---|---|---|
open | { reason: 'programmatic' | 'trigger' } | Fired when the panel opens |
close | { reason: 'programmatic' | 'trigger' | 'escape' | 'outside-click' } | Fired when the panel closes |
CSS Custom Properties
| Property | Description |
|---|---|
--popover-min-width | Minimum width of the floating panel |
--popover-max-width | Maximum width of the floating panel |
Accessibility
The popover component follows WAI-ARIA best practices.
sg-popover
Escapecloses the popover and returns focus to the trigger.Tabmoves focus through interactive elements inside the panel.
- The panel uses
role="dialog"whenlabelis set, giving screen readers a concise title on open. - The trigger element receives
aria-expandedandaria-controlsreflecting the open state. - Provide a
labelattribute to give the panel an accessible name.
- Focus moves into the panel on open (when
triggerincludesclickorfocus). - Focus returns to the trigger element on close.