Theme
On this page (8)
Component Skill — components/popover/component-skill.md

Native basis

popover attribute - native Popover API with CSS anchor positioning for placement.

Web Platform APIs

popover attributepopovertargetposition-areaposition-try-fallbacks@starting-style

Classes

.popover.popover-header.popover-title.popover-description.popover-content

Attributes

data-side="top|right|bottom|left"data-align="start|center|end"

§Default

Click the trigger to open a popover below (default side). Uses native Popover API - no JS for open/close.

§With Form

Popover containing form fields - the canonical shadcn popover pattern.

§Align

Use data-align on the popover to control horizontal alignment: start, center (default), or end.

§Side

Use data-side to position the popover on a specific side of the trigger: top, right, bottom (default), or left.

§Density

Set data-density on the component root to scale its content padding. A whitespace policy, not a zoom: only padding scales (ratio 0.75 / 1 / 1.25), typography stays identical. comfortable matches the unsized default.

§States

Named states via the shared State API, driven per popover through the bound api:

  • default - hidden (the authored state; toggled by popovertarget)
  • open - shown via the native showPopover()

The first demo carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/popover-{state}.png.

Machine contract - verified against popover.schema.json by bun run verify:

StateTypeValuesDefaultDescription
openbooleantrue, falsefalseShown - driven through the component's own State API.

§CSS view file

Uses position-area for anchor positioning, @starting-style for enter animations, and position-try-fallbacks for automatic viewport-overflow flipping. Supports data-side and data-align attributes.

@layer components {
  .popover {
    position: fixed;
    inset: auto;
    margin: 0;
    border: 1px solid var(--border);
    border-radius: var(--radius-xl);
    background-color: var(--popover);
    color: var(--popover-foreground);
    padding: 1rem;
    /* -- Density --------------------------------------------------
       data-density on the .popover root scales the content padding
       (0.75 / 1 / 1.25 of the 1rem default); comfortable matches the
       unsized default. */
    &[data-density="compact"]     { padding: 0.75rem; }
    &[data-density="comfortable"] { padding: 1rem; }
    &[data-density="spacious"]    { padding: 1.25rem; }
    box-shadow: var(--shadow-md);
    width: 20rem;
    opacity: 0;
    translate: 0 -4px;
    transition: opacity 150ms ease, translate 150ms ease, display 150ms allow-discrete;
    /* -- Default: bottom center -- */
    position-area: bottom;
    margin-top: 4px;
    position-try-fallbacks: flip-block;
    &:popover-open {
      opacity: 1;
      translate: 0 0;
    }
    /* ----- Side variants ----- */
    &[data-side="top"] {
      position-area: top;
      margin-top: 0;
      margin-bottom: 4px;
      translate: 0 4px;
      &:popover-open { translate: 0 0; }
    }
    &[data-side="left"] {
      position-area: left;
      margin-top: 0;
      margin-right: 4px;
      translate: 4px 0;
      position-try-fallbacks: flip-inline;
      &:popover-open { translate: 0 0; }
    }
    &[data-side="right"] {
      position-area: right;
      margin-top: 0;
      margin-left: 4px;
      translate: -4px 0;
      position-try-fallbacks: flip-inline;
      &:popover-open { translate: 0 0; }
    }
    /* ----- Align variants (combined with side) ----- */
    &[data-align="start"] {
      &:not([data-side="left"]):not([data-side="right"]) { position-area: bottom left; }
      &[data-side="top"] { position-area: top left; }
      &[data-side="left"] { position-area: left top; }
      &[data-side="right"] { position-area: right top; }
    }
    &[data-align="end"] {
      &:not([data-side="left"]):not([data-side="right"]) { position-area: bottom right; }
      &[data-side="top"] { position-area: top right; }
      &[data-side="left"] { position-area: left bottom; }
      &[data-side="right"] { position-area: right bottom; }
    }
  }
  @starting-style {
    .popover:popover-open { opacity: 0; translate: 0 -4px; }
    .popover[data-side="top"]:popover-open { opacity: 0; translate: 0 4px; }
    .popover[data-side="left"]:popover-open { opacity: 0; translate: 4px 0; }
    .popover[data-side="right"]:popover-open { opacity: 0; translate: -4px 0; }
  }
  .popover-header { margin-bottom: 0.75rem; }
  .popover-title { margin: 0; font-size: 0.875rem; font-weight: 500; color: var(--foreground); }
  .popover-description { margin: 0.25rem 0 0; font-size: 0.8125rem; color: var(--muted-foreground); }
  .popover-content { font-size: 0.875rem; }
  @media (prefers-reduced-motion: reduce) {
    .popover {
      transition: none;
    }
  }
}

§JavaScript view file

Assigns unique CSS anchor names per trigger–popover pair. Opening, closing, and light-dismiss are handled entirely by the native popovertarget attribute.

// -- Popover --------------------------------------------------
// CSS anchor positioning for popover components, plus the named-state API
// so agents/tests can drive open/closed by name (AGENTS.md "State API").
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals, safeShowPopover } from '../../shared/state-api.js';
const df$ = defussGlobals();
const popoverStates = ['default', 'open'];
/**
 * UI side of setState: 'default' hides, 'open' shows. Open/close mechanics
 * stay native (Popover API); this only dispatches to show/hidePopover().
 */
function triggerStateChange(popover, stateName, _config) {
  switch (stateName) {
    case 'default':
      try { popover.hidePopover(); } catch { /* already closed */ }
      break;
    case 'open':
      // deferred show (safeShowPopover): showPopover() mid-exit (right after
      // light dismiss) crashes the headless renderer; exclusion stays native.
      safeShowPopover(popover);
      break;
  }
}
/** Registry-level API; pass the popover element explicitly. Unknown names throw. */
export const popoverApi = {
  setState(popover, stateName, config = {}) {
    if (!popoverStates.includes(stateName)) {
      throw new Error(`popover: unknown state "${stateName}" (supported: ${popoverStates.join(', ')})`);
    }
    triggerStateChange(popover, stateName, config);
    // state lives on the ELEMENT, not the module (multiple popovers per page)
    popover.dataset.stateName = stateName;
    popover._stateConfig = config;
  },
  getState(popover) {
    return { name: popover.dataset.stateName || 'default', config: popover._stateConfig ?? {} };
  },
};
df$.popoverApi = popoverApi;
df$.popoverStates = popoverStates;
function init() {
  document.querySelectorAll('[popovertarget]:not([data-init])').forEach((trigger) => {
    const id = trigger.getAttribute('popovertarget');
    const popover = document.getElementById(id);
    // Ownership boundary (AGENTS.md "Each component owns its dialog", popover
    // edition): only claim triggers whose target is a .popover panel. Stamping
    // every [popovertarget] starved sibling components - navigation-menu's
    // triggers got claimed here, then skipped (panel isn't .popover), and
    // nav-menu's own :not([data-init]) scan never anchored them.
    if (!popover || !popover.classList.contains('popover')) return;
    trigger.dataset.init = '';
    // CSS anchor positioning - unique name per trigger-popover pair
    const anchorId = `--popover-${id}`;
    trigger.style.anchorName = anchorId;
    popover.style.positionAnchor = anchorId;
  });
  // bind-scope the api per popover instance: `$('#demo').api.setState('open')`
  document.querySelectorAll('.popover[popover]:not([data-init])').forEach((popover) => {
    popover.dataset.init = '';
    popover.api = {
      setState: (stateName, config) => popoverApi.setState(popover, stateName, config),
      getState: () => popoverApi.getState(popover),
    };
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub