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 orbit — efficient auto-updating position via @vielzeug/orbit
  • Accessible: role="dialog" on panel, configurable aria-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

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
<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

PreviewCode
RTL

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

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.

sg-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