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 control - 🔧 Powered by floatit — efficient auto-updating position via
@vielzeug/floatit - 🪟 Native Popover API — uses
popoverattribute for correct top-layer stacking - ♿ Accessible:
role="dialog"on panel, configurablearia-label
Source Code
View Source Code
ts
import type { OverlayCloseDetail, OverlayOpenDetail } from '@vielzeug/craftit/controls';
import { computed, createId, define, html, prop, signal, syncAria, watch, onMounted } from '@vielzeug/craftit';
import { createOverlayControl } from '@vielzeug/craftit/controls';
import { computePosition, flip, offset, type Placement, shift } from '@vielzeug/floatit';
import { disablableBundle } from '../../inputs/shared/bundles';
import { reducedMotionMixin } from '../../styles';
import styles from './popover.css?inline';
export type PopoverTrigger = 'click' | 'hover' | 'focus';
const PANEL_OFFSET = 8;
const VALID_TRIGGERS = new Set<PopoverTrigger>(['click', 'hover', 'focus']);
function normalizeTriggers(value: unknown): PopoverTrigger[] {
const parsed = String(value)
.split(',')
.map((item) => item.trim())
.filter((item): item is PopoverTrigger => VALID_TRIGGERS.has(item as PopoverTrigger));
// Keep behavior predictable for invalid input.
return parsed.length > 0 ? parsed : ['click'];
}
export type BitPopoverEvents = {
/** Emitted when the popover closes */
close: OverlayCloseDetail;
/** Emitted when the popover opens */
open: OverlayOpenDetail;
};
/** Popover component properties */
export type BitPopoverProps = {
/** 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 — comma-separated */
trigger?: string;
};
/**
* A floating informational or interactive panel anchored to a trigger element.
* Unlike tooltips, popovers support arbitrary interactive content via slots.
*
* @element bit-popover
*
* @attr {string} placement - Preferred placement (default: 'bottom')
* @attr {string} trigger - 'click' | 'hover' | 'focus' or comma-separated (default: 'click')
* @attr {boolean} open - Controlled open state
* @attr {number} offset - Gap in px between trigger and panel (default: 8)
* @attr {boolean} disabled - Disables the popover
* @attr {string} label - aria-label on the panel
*
* @fires open - When the panel opens with detail: { reason }
* @fires close - When the panel closes with detail: { reason }
*
* @slot - The trigger element
* @slot content - Panel content
*
* @cssprop --popover-min-width - Min width of the panel
* @cssprop --popover-max-width - Max width of the panel
* @cssprop --popover-max-height - Max height of the panel
*
* @part panel - Panel container.
* @example
* ```html
* <bit-popover>
* <button>Open</button>
* <div slot="content">Panel content here</div>
* </bit-popover>
* ```
*/
export const POPOVER_TAG = define<BitPopoverProps, BitPopoverEvents>('bit-popover', {
props: {
...disablableBundle,
label: undefined,
offset: PANEL_OFFSET,
open: undefined,
placement: prop.oneOf(
[
'top',
'top-start',
'top-end',
'bottom',
'bottom-start',
'bottom-end',
'left',
'left-start',
'left-end',
'right',
'right-start',
'right-end',
] as const,
'bottom',
),
trigger: 'click',
},
setup(props, { emit, host, slots }) {
const visible = signal(false);
const isDisabled = computed(() => Boolean(props.disabled.value));
const isControlled = computed(() => props.open.value !== undefined);
const runIfUncontrolled = (action: () => void) => {
if (isControlled.value) return;
action();
};
const panelId = createId('popover');
let panelEl: HTMLElement | null = null;
let currentTrigger: HTMLElement | null = null;
const triggers = computed<PopoverTrigger[]>(() => normalizeTriggers(props.trigger.value));
const overlay = createOverlayControl({
getBoundaryElement: () => host.el,
getPanelElement: () => panelEl,
getTriggerElement: () => currentTrigger,
isDisabled: () => isDisabled.value,
isOpen: () => visible.value,
onClose: (reason) => emit('close', { reason }),
onOpen: (reason) => emit('open', { reason }),
positioner: {
floating: () => panelEl,
reference: () => currentTrigger,
update: updatePosition,
},
restoreFocus: false,
setOpen: (next) => {
if (isControlled.value) return;
if (next) {
showFloat();
return;
}
hideFloat();
},
});
function updatePosition() {
if (!panelEl || !currentTrigger) return;
const resolvedPlacement = computePosition(currentTrigger, panelEl, {
middleware: [offset(props.offset.value ?? PANEL_OFFSET), flip(), shift({ padding: 8 })],
placement: props.placement.value,
});
panelEl.style.left = `${resolvedPlacement.x}px`;
panelEl.style.top = `${resolvedPlacement.y}px`;
panelEl.dataset.placement = resolvedPlacement.placement;
}
/** Show the panel and start auto-updating its position. */
function showFloat() {
visible.value = true;
if (panelEl && !panelEl.matches(':popover-open')) panelEl.showPopover();
updatePosition();
}
/** Hide the panel and stop auto-updating its position. */
function hideFloat() {
visible.value = false;
if (panelEl?.matches(':popover-open')) panelEl.hidePopover();
}
function open(reason: OverlayOpenDetail['reason'] = 'trigger') {
runIfUncontrolled(() => overlay.open({ reason }));
}
function close(reason: OverlayCloseDetail['reason'] = 'trigger') {
runIfUncontrolled(() => overlay.close({ reason, restoreFocus: false }));
}
function toggle() {
runIfUncontrolled(() => overlay.toggle());
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') close('escape');
}
function isPathInside(path: EventTarget[]): boolean {
return (
path.includes(host.el) ||
!!(panelEl && path.includes(panelEl)) ||
!!(currentTrigger && path.includes(currentTrigger))
);
}
function handleClickOutside(e: MouseEvent) {
if (!visible.value) return;
const path = e.composedPath();
if (isPathInside(path)) return;
close('outside-click');
}
// Don't close when focus moves from the trigger into the panel content.
function handleFocusOut(e: FocusEvent) {
const next = e.relatedTarget as Element | null;
if (next && panelEl?.contains(next)) return;
if (next && currentTrigger?.contains(next)) return;
close('trigger');
}
onMounted(() => {
const triggerSlot = host.el.shadowRoot?.querySelector<HTMLSlotElement>('slot:not([name])');
let triggerBinding: (() => void) | null = null;
if (!triggerSlot) return;
const bindEvents = () => {
triggerBinding?.();
triggerBinding = null;
const el = triggerSlot.assignedElements({ flatten: true })[0] as HTMLElement | undefined;
if (!el) {
currentTrigger = null;
return;
}
currentTrigger = el;
const removeAria = syncAria(el, {
controls: () => panelId,
disabled: () => String(isDisabled.value),
expanded: () => String(visible.value),
haspopup: 'dialog',
});
const cleanups: Array<() => void> = [];
const add = (
target: EventTarget,
event: string,
listener: EventListener,
options?: AddEventListenerOptions,
) => {
target.addEventListener(event, listener, options);
cleanups.push(() => target.removeEventListener(event, listener, options));
};
const t = triggers.value;
const hasTrigger = (trigger: PopoverTrigger) => t.includes(trigger);
if (hasTrigger('click')) {
add(el, 'click', toggle as EventListener);
add(document, 'click', handleClickOutside as EventListener, { capture: true });
}
if (hasTrigger('hover')) {
add(el, 'pointerenter', () => open('trigger'));
add(el, 'pointerleave', () => close('trigger'));
if (panelEl) {
add(panelEl, 'pointerenter', () => open('trigger'));
add(panelEl, 'pointerleave', () => close('trigger'));
}
}
if (hasTrigger('focus')) {
add(el, 'focusin', () => open('trigger'));
add(el, 'focusout', handleFocusOut as EventListener);
if (panelEl) add(panelEl, 'focusout', handleFocusOut as EventListener);
}
add(document, 'keydown', handleKeydown as EventListener);
triggerBinding = () => {
removeAria();
for (const cleanup of cleanups) cleanup();
currentTrigger = null;
};
};
watch(slots.elements(), bindEvents, { immediate: true });
// Controlled mode
watch(props.open, (openVal) => {
if (openVal === undefined || openVal === null) return;
if (openVal) {
showFloat();
emit('open', { reason: 'programmatic' });
} else {
hideFloat();
emit('close', { reason: 'programmatic' });
}
});
watch(props.trigger, bindEvents);
watch(props.disabled, (isDisabled) => {
if (isDisabled) close('programmatic');
});
return () => {
triggerBinding?.();
triggerBinding = null;
if (panelEl?.matches(':popover-open')) panelEl.hidePopover();
};
});
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(!visible.value)}"
ref=${(el: HTMLElement) => {
panelEl = el;
}}>
<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
<bit-popover>
<bit-button>Open popover</bit-button>
<div slot="content" style="padding: 1rem;">
<p>This is the popover content.</p>
</div>
</bit-popover>
<script type="module">
import '@vielzeug/buildit';
</script>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
<bit-popover id="my-popover" placement="bottom">
<bit-button id="trigger-btn">Open</bit-button>
<div slot="content" style="padding:1rem;">
<p>Controlled popover content.</p>
<bit-button id="close-btn" size="sm" variant="ghost">Close</bit-button>
</div>
</bit-popover>
<script type="module">
import '@vielzeug/buildit';
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
<bit-popover id="pop">
<bit-button>Toggle</bit-button>
<div slot="content" style="padding:0.75rem;">Panel content</div>
</bit-popover>
<script type="module">
import '@vielzeug/buildit';
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.
bit-popover
✅ Keyboard Navigation
Escapecloses the popover and returns focus to the trigger.Tabmoves focus through interactive elements inside the panel.
✅ Screen Readers
- 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 Management
- Focus moves into the panel on open (when
triggerincludesclickorfocus). - Focus returns to the trigger element on close.