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 { Placement } from '@vielzeug/floatit';
import { computed, createId, defineComponent, html, onMount, onSlotChange, signal, watch } from '@vielzeug/craftit';
import { autoUpdate, flip, offset, positionFloat, shift } from '@vielzeug/floatit';
import { reducedMotionMixin } from '../../styles';
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'];
}
import styles from './popover.css?inline';
export type BitPopoverEvents = {
/** Emitted when the popover closes */
close: undefined;
/** Emitted when the popover opens */
open: undefined;
};
/** 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
*
* @slot - The trigger element
* @slot content - Panel content
*
* @fires open - When the panel opens
* @fires close - When the panel closes
*
* @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
*
* @example
* ```html
* <bit-popover>
* <button>Open</button>
* <div slot="content">Panel content here</div>
* </bit-popover>
* ```
*/
export const POPOVER_TAG = defineComponent<BitPopoverProps, BitPopoverEvents>({
props: {
disabled: { default: false },
label: { default: undefined },
offset: { default: PANEL_OFFSET },
open: { default: undefined },
placement: { default: 'bottom' },
trigger: { default: 'click' },
},
setup({ emit, host, props }) {
const visible = signal(false);
const panelId = createId('popover');
let panelEl: HTMLElement | null = null;
let currentTrigger: HTMLElement | null = null;
let autoUpdateCleanup: (() => void) | null = null;
const triggers = computed<PopoverTrigger[]>(() => normalizeTriggers(props.trigger.value));
function updatePosition() {
if (!panelEl || !currentTrigger) return;
positionFloat(currentTrigger, panelEl, {
middleware: [offset(props.offset.value ?? PANEL_OFFSET), flip(), shift({ padding: 8 })],
placement: props.placement.value,
}).then((resolvedPlacement) => {
if (panelEl) panelEl.dataset.placement = resolvedPlacement;
});
}
/** Show the panel and start auto-updating its position. */
function showFloat() {
visible.value = true;
currentTrigger?.setAttribute('aria-expanded', 'true');
if (panelEl && !panelEl.matches(':popover-open')) panelEl.showPopover();
if (currentTrigger && panelEl) {
autoUpdateCleanup?.();
autoUpdateCleanup = autoUpdate(currentTrigger, panelEl, updatePosition);
}
updatePosition();
}
/** Hide the panel and stop auto-updating its position. */
function hideFloat() {
autoUpdateCleanup?.();
autoUpdateCleanup = null;
currentTrigger?.setAttribute('aria-expanded', 'false');
visible.value = false;
if (panelEl?.matches(':popover-open')) panelEl.hidePopover();
}
function open() {
if (props.open.value !== undefined) return;
if (props.disabled.value) return;
if (visible.value) return;
showFloat();
emit('open');
}
function close() {
if (props.open.value !== undefined) return;
if (!visible.value) return;
hideFloat();
emit('close');
}
function toggle() {
if (visible.value) close();
else open();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') close();
}
function handleClickOutside(e: MouseEvent) {
if (!visible.value) return;
const path = e.composedPath();
if (path.includes(host)) return;
if (panelEl && path.includes(panelEl)) return;
if (currentTrigger && path.includes(currentTrigger)) return;
close();
}
// 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();
}
onMount(() => {
const triggerSlot = host.shadowRoot?.querySelector<HTMLSlotElement>('slot:not([name])');
if (!triggerSlot) return;
const bindEvents = () => {
unbindEvents();
const el = triggerSlot.assignedElements({ flatten: true })[0] as HTMLElement | undefined;
if (!el) return;
currentTrigger = el;
el.setAttribute('aria-controls', panelId);
el.setAttribute('aria-haspopup', 'dialog');
el.setAttribute('aria-expanded', String(visible.value));
el.setAttribute('aria-disabled', String(Boolean(props.disabled.value)));
const t = triggers.value;
if (t.includes('click')) {
el.addEventListener('click', toggle);
document.addEventListener('click', handleClickOutside, { capture: true });
}
if (t.includes('hover')) {
el.addEventListener('pointerenter', open);
el.addEventListener('pointerleave', close);
panelEl?.addEventListener('pointerenter', open);
panelEl?.addEventListener('pointerleave', close);
}
if (t.includes('focus')) {
el.addEventListener('focusin', open);
el.addEventListener('focusout', handleFocusOut);
panelEl?.addEventListener('focusout', handleFocusOut);
}
document.addEventListener('keydown', handleKeydown);
};
const unbindEvents = () => {
if (!currentTrigger) return;
currentTrigger.removeAttribute('aria-controls');
currentTrigger.removeAttribute('aria-haspopup');
currentTrigger.removeAttribute('aria-expanded');
currentTrigger.removeAttribute('aria-disabled');
currentTrigger.removeEventListener('click', toggle);
currentTrigger.removeEventListener('pointerenter', open);
currentTrigger.removeEventListener('pointerleave', close);
currentTrigger.removeEventListener('focusin', open);
currentTrigger.removeEventListener('focusout', handleFocusOut);
panelEl?.removeEventListener('pointerenter', open);
panelEl?.removeEventListener('pointerleave', close);
panelEl?.removeEventListener('focusout', handleFocusOut);
document.removeEventListener('click', handleClickOutside, { capture: true });
document.removeEventListener('keydown', handleKeydown);
currentTrigger = null;
};
onSlotChange('default', bindEvents);
// Controlled mode
watch(props.open, (openVal) => {
if (openVal === undefined || openVal === null) return;
if (openVal) {
showFloat();
emit('open');
} else {
hideFloat();
emit('close');
}
});
watch(props.trigger, bindEvents);
watch(props.disabled, (isDisabled) => {
currentTrigger?.setAttribute('aria-disabled', String(Boolean(isDisabled)));
if (isDisabled) {
close();
}
});
return () => {
unbindEvents();
autoUpdateCleanup?.();
autoUpdateCleanup = 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.value ?? null}"
:aria-hidden="${() => String(!visible.value)}"
ref=${(el: HTMLElement) => {
panelEl = el;
}}>
<slot name="content"></slot>
</div>
`;
},
styles: [reducedMotionMixin, styles],
tag: 'bit-popover',
});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', () => console.log('popover opened'));
pop.addEventListener('close', () => console.log('popover closed'));
</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 | void | Fired when the panel opens |
close | void | 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.