Theme
Design your own
On this page (9)
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.

§API

Generated from popover.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type PopoverState = 'default' | 'open' - setState(name, config) takes the config of the state it names.

StateDescription
default
Closed.

No config.

open
Open, anchored to its trigger.

No config.

Every element

MemberDescription
el.api.setState<S extends PopoverState>(name: S, config?: PopoverStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?PopoverStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: PopoverState; config: PopoverStateConfigs[PopoverState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: PopoverState; config: PopoverStateConfigs[PopoverState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: PopoverState; config: PopoverStateConfigs[PopoverState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: PopoverState; config: PopoverStateConfigs[PopoverState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: PopoverState; config: PopoverStateConfigs[PopoverState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.popoverApi.setState<S extends PopoverState>(el: HTMLElement, name: S, config?: PopoverStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?PopoverStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.popoverApi.getState(el: HTMLElement): { name: PopoverState; config: PopoverStateConfigs[PopoverState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: PopoverState; config: PopoverStateConfigs[PopoverState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.popoverApi.render(state: { name: PopoverState; config: PopoverStateConfigs[PopoverState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: PopoverState; config: PopoverStateConfigs[PopoverState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.popoverApi.store(el: HTMLElement): Store<{ name: PopoverState; config: PopoverStateConfigs[PopoverState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: PopoverState; config: PopoverStateConfigs[PopoverState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.popoverApi.commit<S extends PopoverState>(el: HTMLElement, name: S, config?: PopoverStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?PopoverStateConfigs[S]its config
df$.shadcn.popoverStates: PopoverState[]The declared states, 'default' first: default, open.

§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, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const popoverStates = ['default', 'open'];
// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each
// described, and every config field typed, described and named in the code.
/** setState() configs per state - the popover's states take none. */
export interface PopoverStateConfigs {
  /** Closed. */
  default: {};
  /** Open, anchored to its trigger. */
  open: {};
}
/**
 * The markup of a state: none - 'open' lives in the top layer
 * (:popover-open), not in an attribute, so every state renders the authored
 * markup. render() stays the State API's markup function all the same.
 */
function applyMarkup(_el, _stateName) {}
/**
 * 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 = componentState({
  component: 'popover',
  states: popoverStates,
  apply: (popover, state) => triggerStateChange(popover, state.name, state.config),
  markup: (el, state) => applyMarkup(el, state.name),
});
df$.popoverApi = popoverApi;
df$.popoverStates = popoverStates;
function init() {
  dfDollar('[popovertarget]:not([data-init])').toArray().forEach((trigger) => {
    const id = trigger.getAttribute('popovertarget');
    const popover = dfDollar('#' + CSS.escape(id)).get(0);
    // 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')`
  dfDollar('.popover[popover]:not([data-init])').toArray().forEach((popover) => {
    popover.dataset.init = '';
    // el.store + el.api (AGENTS.md "State through stores")
    bindComponent(popover, popoverApi);
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

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