Skip to main contentSkip to docs navigation

Rich overlays with a title, body, and directional arrow, positioned by Floating UI.

@layer components
Requires JavaScript
Depends onFloating UI, Tooltip
Context tokens

Introduction

Popovers are overlays that display a .popover-header title, a .popover-body body, and a directional arrow. The Popover plugin extends Tooltip — it shares the same Floating UI positioning engine, placement logic, and lifecycle events, and adds a content slot and a default trigger of click instead of hover focus.

Key behaviors:

  • Elements with data-cx-toggle="popover" initialize automatically on first click, focus, or hover — no JavaScript is required for the data API.
  • A popover with both title and content empty is never shown.
  • The default placement is right; the default trigger is click.
  • Calling show on a hidden element (inline style.display: none) throws an error.
  • Popovers on disabled elements must be attached to a wrapper element.
  • Hide a popover before removing its trigger element from the DOM.
  • Trigger elements inside a shadow DOM are supported.

By default, this component uses the built-in content sanitizer, which strips out any HTML elements that are not explicitly allowed. See the sanitizer section in our JavaScript documentation for more details.

Chassis CSS respects user accessibility preferences by automatically disabling animations when the prefers-reduced-motion media query is detected. See the reduced motion guidelines in our accessibility documentation for implementation details.

Basic structure

Add data-cx-toggle="popover" to a button or link, set data-cx-title for the header, data-cx-content for the body, or both. At least one must be non-empty for the popover to appear.

HTML
<button type="button" class="button primary"
  data-cx-toggle="popover"
  data-cx-title="Popover title"
  data-cx-content="Body content goes here.">
Toggle popover
</button>

A native title attribute on the trigger is moved to data-cx-original-title on initialization — suppressing the browser's built-in tooltip — and used as the popover's header title when data-cx-title is not set.

The plugin generates the following markup and appends it to document.body (or the configured container):

HTML
<div class="popover cx-popover-auto" role="tooltip">
  <div class="popover-arrow"></div>
  <h3 class="popover-header">Popover title</h3>
  <div class="popover-body">Body content goes here.</div>
</div>

The cx-popover-auto class reads the data-cx-placement attribute written by Floating UI after each position computation and applies the styles of the matching directional class (cx-popover-top, cx-popover-end, cx-popover-bottom, or cx-popover-start) to position the arrow. The trigger element receives aria-describedby pointing at the generated element's id while the popover is visible; the attribute is removed on hide.

Placement

Set data-cx-placement on the trigger to control which side the popover appears on. The logical values start and end resolve to the inline-start and inline-end sides and adapt to the document's text direction. The physical values left, right, top, and bottom always place the popover on that side regardless of direction. The default is right.

HTML
<button type="button" class="button secondary"
  data-cx-toggle="popover"
  data-cx-content="Top"
  data-cx-placement="top">
top
</button>
<button type="button" class="button secondary"
  data-cx-toggle="popover"
  data-cx-content="Bottom"
  data-cx-placement="bottom">
bottom
</button>
<button type="button" class="button secondary"
  data-cx-toggle="popover"
  data-cx-content="Start"
  data-cx-placement="start">
start
</button>
<button type="button" class="button secondary"
  data-cx-toggle="popover"
  data-cx-content="End"
  data-cx-placement="end">
end
</button>
<button type="button" class="button secondary"
  data-cx-toggle="popover"
  data-cx-content="Left"
  data-cx-placement="left">
left
</button>
<button type="button" class="button secondary"
  data-cx-toggle="popover"
  data-cx-content="Right"
  data-cx-placement="right">
right
</button>

When the preferred placement would cause the popover to overflow its boundary, Floating UI's flip middleware selects a fallback from ['top', 'right', 'bottom', 'left']. Override the fallback list with the fallbackPlacements option.

Responsive placement

The placement value accepts breakpoint prefixes to change direction at different viewport widths. The syntax is the base placement followed by breakpoint:placement tokens, where breakpoints are small, medium, large, xlarge, and 2xlarge. The plugin re-evaluates placement whenever the viewport crosses a registered breakpoint.

HTML
<button type="button" class="button secondary"
  data-cx-toggle="popover"
  data-cx-content="Changes with viewport"
  data-cx-placement="top medium:right large:bottom">
top → medium:right → large:bottom
</button>

Advanced features

The popover plugin supports HTML content, focus-based dismissal, and a wrapper pattern for attaching popovers to disabled elements.

HTML content

Set data-cx-html="true" to render HTML tags inside the title and body. Content passes through the built-in sanitizer unless a custom sanitizeFn is provided. Use plain text for any content derived from user input.

HTML
<button type="button" class="button primary"
  data-cx-toggle="popover"
  data-cx-title="<em>Formatted</em> title"
  data-cx-content="Body with <strong>bold</strong> text."
  data-cx-html="true">
HTML content
</button>

Focus-based dismissal

Set data-cx-trigger="focus" to dismiss the popover when the trigger loses focus — useful for contextual help that closes as soon as the user moves on.

Focus-based dismissal requires an <a> element with a tabindex attribute rather than a <button>. In some browsers, clicking a <button> does not transfer keyboard focus to it, which breaks the focus-out dismiss behavior.

HTML
<a tabindex="0" class="button primary" href="#"
  data-cx-toggle="popover"
  data-cx-trigger="focus"
  data-cx-title="Dismissible popover"
  data-cx-content="Loses focus to dismiss.">
Dismissible popover
</a>

Disabled elements

Disabled elements do not receive click or focus events. Wrap a disabled element in a <span>, place the popover attributes on the wrapper, and add tabindex="0" to keep the wrapper reachable by keyboard.

HTML
<span class="d-inline-block" tabindex="0"
  data-cx-toggle="popover"
  data-cx-trigger="hover focus"
  data-cx-content="Attached to the wrapper.">
<button class="button primary" type="button" disabled>Disabled button</button>
</span>

Container

When a parent's stacking context or overflow clips the popover, pass a container option to append the generated element to a different DOM node. Setting container to document.body is common for popovers inside complex layouts:

JavaScript
import { Popover } from '@chassis-ui/css'

new Popover(document.querySelector('.my-trigger'), { container: document.body })

For popovers inside a <dialog>, set container to the dialog element to keep stacking context and focus management correct:

JavaScript
new Popover(document.querySelector('.dialog-trigger'), {
  container: document.querySelector('dialog')
})

Theming

Override popover appearance by assigning a custom class with data-cx-custom-class and redefining the component's custom properties on that class.

.custom-popover {
  --cx-popover-max-width: 200px;
  --cx-popover-border-color: var(--cxd-subtle-bg);
  --cx-popover-header-bg: var(--cxd-subtle-bg);
  --cx-popover-header-color: var(--cx-white);
  --cx-popover-body-padding-x: 1rem;
  --cx-popover-body-padding-y: .5rem;
}
HTML
<button type="button" class="button secondary"
  data-cx-toggle="popover"
  data-cx-placement="right"
  data-cx-custom-class="custom-popover"
  data-cx-title="Custom popover"
  data-cx-content="Themed via CSS custom properties.">
Custom popover
</button>

Accessibility

The Popover plugin manages aria-describedby automatically: the trigger element receives an aria-describedby pointing at the generated popover's id while the popover is visible, and the attribute is removed on hide. The generated element carries role="tooltip".

If a trigger has a native title attribute, the plugin moves it to data-cx-original-title on initialization — suppressing the browser's built-in tooltip and using it as the popover's header when data-cx-title is not set.

The default trigger is click. Pressing Enter or Space on a focusable trigger fires the click event, so keyboard users can open and close popovers without additional configuration. Apply popovers only to natively interactive elements — buttons and links — that already receive keyboard focus.

Avoid placing interactive content — links, inputs, or buttons — inside the popover body. The popover is appended to document.body by default, placing it outside the trigger's position in the tab order. Keyboard users reach interactive content inside the popover only after cycling through all remaining page content. If interactive content is necessary, set container to an element near the trigger to preserve a logical tab sequence.

JavaScript API

The Popover plugin extends Tooltip and inherits its full interface. Floating UI must be available, either via the bundled distribution or an import map. Import the Popover class directly:

JavaScript
import { Popover } from '@chassis-ui/css'

Triggers

The data-attribute API initializes and configures popovers without writing JavaScript. The plugin reads all data-cx-* attributes on the trigger element:

AttributeDescription
data-cx-toggle="popover"Marks the element as a popover trigger. The plugin initializes on first click, focus, or hover.
data-cx-titleTitle text rendered in .popover-header. Optional when data-cx-content is set. A native title attribute is moved to data-cx-original-title on init.
data-cx-contentBody text rendered in .popover-body. Optional when data-cx-title is set.
data-cx-placementPreferred placement. Logical: start, end (adapt to text direction). Physical: top, bottom, left, right. Default: right. Accepts responsive prefixes.
data-cx-triggerEvents that show and hide the popover: click, hover, focus, manual. Default: click.
data-cx-containerCSS selector or DOM element to append the generated popover to. Default resolves to document.body.
data-cx-custom-classExtra class names added to the popover element.
data-cx-htmlWhen true, renders HTML in title and body content.

For security reasons, sanitize, sanitizeFn, and allowList cannot be set via data-cx-* attributes or data-cx-config. Use JavaScript constructor options instead.

Initialization

Instantiate a Popover explicitly when programmatic API access is needed before the first user interaction:

JavaScript
import { Popover } from '@chassis-ui/css'

const popover = new Popover(document.getElementById('myTrigger'), {
  placement: 'top',
  trigger: 'hover focus'
})

Popovers reposition automatically when a parent has overflow: auto or overflow: scroll. To constrain the shift boundary, pass an HTMLElement to the boundary option:

JavaScript
const popover = new Popover('#example', {
  boundary: document.body
})

Methods

The following methods are available on each Popover instance:

All Chassis CSS component methods are asynchronous and initiate CSS transitions. Methods return immediately when the transition begins, not when it completes. Calling methods on components that are already transitioning will be ignored to prevent conflicts. Learn more about Chassis JavaScript patterns.

MethodDescription
disablePrevents the popover from appearing until re-enabled.
disposeHides and destroys the instance, removing stored data from the trigger element. Popovers created with a selector option cannot be individually destroyed on descendant triggers.
enableRestores the popover after disable. Popovers are enabled by default.
getInstanceStatic. Returns the existing Popover instance bound to a DOM element, or null.
getOrCreateInstanceStatic. Returns the existing Popover instance, or creates and returns a new one.
hideHides the popover. Returns before the hidden.cx.popover event fires.
setContentUpdates the popover's title and body content after initialization.
showShows the popover. Returns before the shown.cx.popover event fires. A popover with empty title and content is never shown.
toggleToggles the popover. Returns before the show or hide event fires.
toggleEnabledToggles between enabled and disabled states.
updateRecalculates the popover's position.

setContent accepts an object keyed by selector within the popover template. Recognized keys are .popover-header and .popover-body. Each value may be a string, Element, function, or null:

JavaScript
import { Popover } from '@chassis-ui/css'

const popover = Popover.getInstance('#myTrigger')

popover.setContent({
  '.popover-header': 'Updated title',
  '.popover-body': 'Updated content'
})

Options

Options are set via data-cx-* attributes or passed to the constructor as an object. Attribute names use the kebab-case form of the option name — data-cx-custom-class, not data-cx-customClass. Attribute values are parsed to their native types:"true"true, "0"0, and valid JSON strings to objects.

Use data-cx-config to pass multiple options as a JSON string:data-cx-config='{"delay":200}'. Individual data-cx-* attributes take precedence over data-cx-config. You can also use JSON values in individual attributes, such as data-cx-delay='{"show":100,"hide":200}'.

When initializing components, Chassis merges configurations from multiple sources in this priority order: default settings, data-cx-config values, individual data-cx-*attributes, and finally any JavaScript object options. Values defined later in this sequence override earlier ones.

NameTypeDefaultDescription
allowListobjectDefault valueAllowed tags and attributes for HTML content. Tags and attributes absent from this list are stripped by the content sanitizer.
Exercise caution when extending this list. See OWASP's XSS Prevention Cheat Sheet.
animationbooleantrueApplies a CSS fade transition.
boundarystring, element'clippingParents'Overflow constraint boundary for Floating UI's shift middleware. The default string 'clippingParents' is translated to 'clippingAncestors' internally. Pass an HTMLElement to constrain to a specific element. See Floating UI shift docs.
containerstring, element, falsefalseElement to append the popover to. The value false resolves to document.body at runtime. Set explicitly to keep the popover in a specific stacking context.
contentstring, element, function''Content for .popover-body. A function is called with the trigger element as its argument.
customClassstring, function''Additional class names applied to the popover on show. Pass a space-separated string for multiple classes, or a function that returns a string.
delaynumber, object0Show and hide delay in milliseconds. A number applies to both; an object sets them independently: { "show": 500, "hide": 100 }.
fallbackPlacementsarray['top', 'right', 'bottom', 'left']Ordered fallback placements for Floating UI's flip middleware. Must use physical values (top, right, bottom, left); start and end are not resolved here. See Floating UI flip docs.
floatingConfignull, object, functionnullOverrides the default Floating UI computePosition configuration. A function receives the default config object and must return a new config. See Floating UI computePosition docs.
htmlbooleanfalseRenders HTML tags in title and body content. When false, content is inserted via innerText. Use false for user-generated content to prevent XSS.
offsetarray, string, function[0, 8]Distance between the popover and its trigger [crossAxis, mainAxis]. Pass a comma-separated string in data attributes: data-cx-offset="10,20". A function receives placement and rects and must return a two-number array. See Floating UI offset docs.
placementstring, function'right'Preferred placement. Logical values start and end adapt to text direction. Physical values top, bottom, left, and right always place the popover on that side. Accepts responsive prefixes ('top medium:right'). A function receives the popover element and the trigger element and must return a placement string.
sanitizebooleantrueEnables content sanitization for template, content, and title.
Disabling sanitization removes XSS protection. See OWASP's XSS Prevention Cheat Sheet. Vulnerabilities arising solely from disabled sanitization are outside Chassis CSS's security scope.
sanitizeFnnull, functionnullCustom sanitization function. Replaces the built-in sanitizer when provided.
selectorstring, falsefalseCSS selector for event delegation. Enables attaching popovers to dynamically added descendants matching the selector.
templatestring'<div class="popover" role="tooltip"><div class="popover-arrow"></div><h3 class="popover-header"></h3><div class="popover-body"></div></div>'Base HTML for the generated popover. The title is injected into .popover-header; content into .popover-body; the arrow element must carry .popover-arrow. The outermost element must carry .popover and role="tooltip".
titlestring, element, function''Popover title for .popover-header. A function is called with the trigger element as its argument.
triggerstring'click'Events that show and hide the popover: click, hover, focus, manual. Pass multiple values separated by spaces. manual cannot be combined with other triggers and requires calling show, hide, or toggle programmatically.

floatingConfig function

When floatingConfig is a function, it receives the default Floating UI configuration object and must return a new config:

JavaScript
import { Popover } from '@chassis-ui/css'

const popover = new Popover(element, {
  floatingConfig(defaultConfig) {
    return { ...defaultConfig, strategy: 'fixed' }
  }
})

Events

Each lifecycle transition fires a namespaced event on the trigger element:

JavaScript
import { Popover } from '@chassis-ui/css'

const trigger = document.getElementById('myTrigger')

trigger.addEventListener('hidden.cx.popover', () => {
  Popover.getInstance(trigger).dispose()
})
EventDescription
hide.cx.popoverFires immediately when hide is called.
hidden.cx.popoverFires after the popover is fully hidden, once CSS transitions complete.
inserted.cx.popoverFires after show.cx.popover when the popover element has been added to the DOM.
show.cx.popoverFires immediately when show is called.
shown.cx.popoverFires after the popover is fully visible, once CSS transitions complete.

CSS

The Popover component can be customized at both runtime (via custom properties) and compile time (via Sass variables).

Custom properties

The Popover component exposes CSS custom properties to control its appearance at runtime.

--zindex: var(--popover-zindex, #{$zindex-popover});
--max-width: var(--popover-max-width, #{$popover-max-width});
@include map-font($popover-font, popover);
--fg-color: var(--popover-fg-color, #{$popover-fg-color});
--bg-color: var(--popover-bg-color, #{$popover-bg-color});
--border-width: var(--popover-border-width, #{$popover-border-width});
--border-color: var(--popover-border-color, #{$popover-border-color});
--border-radius: var(--popover-border-radius, #{$popover-border-radius});
--inner-border-radius: var(--popover-inner-border-radius, #{$popover-inner-border-radius});
--box-shadow: var(--popover-box-shadow, #{$popover-box-shadow});
--header-padding-x: var(--popover-padding-x, #{$popover-header-padding-x});
--header-padding-y: var(--popover-padding-y, #{$popover-header-padding-y});
@include map-font($popover-header-font, popover-header, header);
--header-fg-color: var(--popover-header-fg-color, #{$popover-header-fg-color});
--header-bg-color: var(--popover-header-bg-color, #{$popover-header-bg-color});
--body-padding-x: var(--popover-body-padding-x, #{$popover-body-padding-x});
--body-padding-y: var(--popover-body-padding-y, #{$popover-body-padding-y});
--body-color: var(--popover-body-color, #{$popover-body-color});
--arrow-width: var(--popover-arrow-width, #{$popover-arrow-width});
--arrow-height: var(--popover-arrow-height, #{$popover-arrow-height});
--arrow-border: var(--popover-arrow-border, var(--border-color));

Sass variables

The Popover component uses Sass variables in scss/config/_defaults.scss to define its defaults.

$popover-font:                      $font-text-medium-normal;
$popover-fg-color:                  var(--fg-main);
$popover-bg-color:                  var(--bg-main);
$popover-max-width:                 17.25rem; //276px;
$popover-border-width:              var(--border-width-medium);
$popover-border-color:              var(--border-subtle);
$popover-border-radius:             var(--border-radius-medium);
$popover-inner-border-radius:       calc(#{$popover-border-radius} - #{$popover-border-width});
$popover-box-shadow:                var(--box-shadow-medium);

$popover-header-font:               $font-text-medium-strong;
$popover-header-fg-color:           var(--fg-main);
$popover-header-bg-color:           var(--bg-even);
$popover-header-padding-y:          var(--space-xsmall);
$popover-header-padding-x:          var(--space-medium);

$popover-body-color:                var(--fg-color);
$popover-body-padding-y:            var(--space-small);
$popover-body-padding-x:            var(--space-medium);

$popover-arrow-width:               1rem;
$popover-arrow-height:              .5rem;