Skip to content

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 open attribute for programmatic control
  • 🔧 Powered by floatit — efficient auto-updating position via @vielzeug/floatit
  • 🪟 Native Popover API — uses popover attribute for correct top-layer stacking
  • Accessible: role="dialog" on panel, configurable aria-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

PreviewCode
RTL

Trigger Modes

PreviewCode
RTL

Rich Content

The content slot accepts any HTML — forms, cards, images, custom layouts.

PreviewCode
RTL

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

PreviewCode
RTL

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

AttributeTypeDefaultDescription
placement'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'right' | 'right-start' | 'right-end''bottom'Preferred placement
triggerstring'click'Trigger mode(s) — click, hover, focus, comma-separated
openbooleanfalseControlled open state
offsetnumber8Gap in pixels between trigger and panel
disabledbooleanfalsePrevent the popover from opening
labelstringaria-label for the panel

Slots

SlotDescription
(default)The trigger element the panel is anchored to
contentContent rendered inside the floating panel

Events

EventDetailDescription
open{ reason: 'programmatic' | 'trigger' }Fired when the panel opens
close{ reason: 'programmatic' | 'trigger' | 'escape' | 'outside-click' }Fired when the panel closes

CSS Custom Properties

PropertyDescription
--popover-min-widthMinimum width of the floating panel
--popover-max-widthMaximum width of the floating panel

Accessibility

The popover component follows WAI-ARIA best practices.

bit-popover

Keyboard Navigation

  • Escape closes the popover and returns focus to the trigger.
  • Tab moves focus through interactive elements inside the panel.

Screen Readers

  • The panel uses role="dialog" when label is set, giving screen readers a concise title on open.
  • The trigger element receives aria-expanded and aria-controls reflecting the open state.
  • Provide a label attribute to give the panel an accessible name.

Focus Management

  • Focus moves into the panel on open (when trigger includes click or focus).
  • Focus returns to the trigger element on close.
  • Tooltip — lightweight non-interactive floating label
  • Menu — dropdown menu with keyboard navigation