PopoverATM
Rich content in a floating panel, triggered by a button. Uses the native Popover API.
On this page (8)
§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 bypopovertarget)open- shown via the nativeshowPopover()
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:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
open | boolean | true, false | false | Shown - 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