PopoverATM
Rich content in a floating panel, triggered by a button. Uses the native Popover API.
On this page (9)
§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. |
§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.
| State | Description |
|---|---|
default | Closed. No config. |
open | Open, anchored to its trigger. No config. |
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
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.
Returns | |||||||||
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 | |||||||||
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.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
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
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
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.
Returns | ||||||||||||
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.
Returns | ||||||||||||
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.
Returns | ||||||||||||
df$.shadcn.popoverApi.store(el: HTMLElement): Store<{ name: PopoverState; config: PopoverStateConfigs[PopoverState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
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.
| ||||||||||||
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