Popover
stimeo--popover
A floating panel toggled by its trigger. The background stays usable; focus and dismissal are handled.
A floating panel toggled by its trigger that leaves the rest of the page alone. It is not modal, so the background stays interactive while it is open. Opening moves focus to the first control inside the panel. Esc and a click outside close it and return focus to the trigger. Tabbing out of the panel closes it without yanking focus back. Static placement here is this demo's CSS. Flipping and shifting away from a screen edge is handled by the positioning module you can opt into.
Keyboard
| Key | Action |
|---|---|
| Enter / Space | Toggle the popover while the trigger is focused (native button). |
| Esc | Close the popover and return focus to the trigger. |
| Tab / Shift+Tab | Move within the panel; moving focus out of it closes the popover. |
<%# Markup for the popover demo.
A non-modal floating panel toggled by a trigger click. The panel is role="dialog"
with no aria-modal (the background stays interactive). Static placement lives in
this Playground's CSS; the library only handles open/close, focus, and dismiss. %>
<div class="popover" data-controller="stimeo--popover">
<button
class="demo-trigger"
type="button"
data-stimeo--popover-target="trigger"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="popover-panel"
data-action="click->stimeo--popover#toggle">
<%= t("components.popover.demo.trigger") %>
</button>
<div
class="popover__panel"
id="popover-panel"
role="dialog"
aria-label="<%= t("components.popover.demo.title") %>"
data-stimeo--popover-target="panel"
hidden>
<label class="popover__field">
<%= t("components.popover.demo.name_label") %>
<input type="text" name="display_name" />
</label>
<button
class="demo-trigger"
type="button"
data-action="click->stimeo--popover#close">
<%= t("components.popover.demo.done") %>
</button>
</div>
</div>
/*
* Presentation-only styles for the popover demo.
* The library shows/hides the panel by toggling its hidden attribute. Placement
* (directly below the trigger) is static and the consumer's CSS responsibility;
* connect the opt-in stimeo-ui/positioning module if you need dynamic flip/shift.
*/
.popover {
position: relative;
display: inline-block;
}
.popover__panel {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
z-index: 10;
min-width: 16rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
background: var(--surface, var(--surface-card));
border: 1px solid var(--border-strong);
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgb(15 23 42 / 0.12);
}
/* Setting display: flex overrides the hidden attribute's default display:none, so
re-declare display:none while closed to honor the library's hidden toggle. */
.popover__panel[hidden] {
display: none;
}
.popover__field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--fg);
}
.popover__field input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
font: inherit;
}
// popover opt-in positioning demo (consumer-side JS).
//
// The core controller (stimeo--popover) stays zero-dep and only handles open/close,
// focus, and dismiss. Viewport-edge flip/shift (collision avoidance) is optionally
// connected to the opt-in stimeo-ui/positioning module (@floating-ui/dom based) only
// when the consumer needs it. Here we watch the panel's hidden attribute: on open,
// start coordinate tracking with attachPositioning; on close, detach and clear the
// inline coordinates (falling back to demo.css's static placement).
import { attachPositioning } from 'stimeo-ui/positioning';
document.querySelectorAll('[data-controller~="stimeo--popover"]').forEach((root) => {
const trigger = root.querySelector('[data-stimeo--popover-target="trigger"]');
const panel = root.querySelector('[data-stimeo--popover-target="panel"]');
if (!trigger || !panel) return;
let detach = null;
const sync = () => {
if (!panel.hidden && !detach) {
// Opened: start tracking relative to the trigger (offset for the gap, flip/shift to
// avoid edges).
detach = attachPositioning(trigger, panel, {
placement: 'bottom-start',
offset: 8,
padding: 8,
});
} else if (panel.hidden && detach) {
// Closed: stop tracking, clear the inline coordinates, and return to the static CSS
// placement.
detach();
detach = null;
panel.style.position = panel.style.left = panel.style.top = '';
}
};
new MutationObserver(sync).observe(panel, { attributes: true, attributeFilter: ['hidden'] });
sync();
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--popover"
Targets
| Name | Description | Attribute |
|---|---|---|
trigger
required
|
The button that toggles the popover; carries aria-haspopup/aria-expanded and focus is restored here on close. |
data-stimeo--popover-target="trigger" |
panel
required
|
The non-modal role="dialog" panel shown/hidden via hidden; focus moves inside on open but is not trapped. |
data-stimeo--popover-target="panel" |
Values
| Name | Attribute |
|---|---|
closeOnScroll
|
data-stimeo--popover-close-on-scroll-value |
Actions
| Name | Description | Action |
|---|---|---|
close
|
Closes the panel and reflects the collapsed state on the trigger. | stimeo--popover#close |
open
|
Opens the panel, reflects state, and moves focus to the first focusable element inside (or the panel itself). | stimeo--popover#open |
toggle
|
Toggles the popover open/closed. | stimeo--popover#toggle |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-expanded |
Trigger | "true" while the popover is open, "false" when closed. |
hidden |
Panel | Removed when open, present when closed. |