Popovers and pinned tooltips
Non-modal floating UI uses the native Popover API. Markup Refine adds Layer ownership/lifecycle only when orchestration is useful, and adds a small Tooltip subsystem for delayed preview-to-pinned information surfaces.
Choose the smallest primitive
| Need | Use | Why |
|---|---|---|
| Blocking decision or workflow | Modal Layer | Native modal <dialog> provides inertness and modal semantics. |
| Blocking transient edge panel | Drawer Layer | Same dialog lifecycle, different presentation. |
| Interactive non-modal floating UI with lifecycle/ownership | Popover Layer | Native top layer and light dismiss plus Layer hierarchy. |
| Short supplementary hover/focus explanation | Tooltip preview | Transient, non-focusable, and intentionally outside layers.stack. |
| Simple disclosure or persistent content | No Layer | Prefer inline HTML, <details>, or another native structure. |
| Simple popover with no orchestration needs | Plain native popover | popover/popovertarget already supply the behavior. |
Native Popover Layer demonstration
The same mr-layer__* content primitives used by modal dialogs can be used inside a Popover Layer. The modifier changes the surface presentation; the content scaffold does not know which Layer mode contains it.
Formatting help
Interactive, non-modal, and light-dismissed natively.
Popover content may contain links, buttons, forms, and nested Layer invokers.
<button popovertarget="format-help">Formatting help</button>
<div id="format-help"
popover="auto"
data-mr-layer="popover"
class="mr-layer mr-layer--popover"
aria-labelledby="format-help-title">
<header class="mr-layer__header">
<div class="mr-layer__heading">
<h2 id="format-help-title" class="mr-layer__title">Formatting help</h2>
<p class="mr-layer__description">A native non-modal top-layer surface.</p>
</div>
<button popovertarget="format-help"
popovertargetaction="hide"
aria-label="Close formatting help">Close</button>
</header>
<div class="mr-layer__body">
<p>Formatting changes presentation without changing the underlying value.</p>
</div>
<footer class="mr-layer__actions">
<a href="/formatting">Read the full guide</a>
</footer>
</div>popover="auto" owns outside-click/Escape light dismiss. The browser also places the surface in the top layer. Markup Refine does not add a z-index stack or a second light-dismiss implementation.
Declarative popovertarget is preferred when a button directly owns the popover. Programmatic layers.open(..., { trigger }) uses the same native popover operation and supplies the trigger as the native source.
Unstyled native baseline
<button popovertarget="plain-help">Help</button>
<div id="plain-help" popover="auto">Native popover, no Markup Refine classes.</div>Markup Refine CSS is optional. If no ownership/lifecycle integration is needed,data-mr-layer is optional too.
Tooltip preview → pinned Layer
Tooltip source markup may use the small mr-tooltip__title andmr-tooltip__content presentation primitives. The runtime clones the same source into both the short-lived preview and the interactive pinned popover. Both render the same authored HTML and share Tooltip box metrics. Preview controls keep their visual styling, but are temporarily inert and hidden from the accessibility tree. Promotion therefore changes behavior and state feedback without deliberately changing the tooltip's content or box dimensions.
<button type="button" data-mr-tooltip="formatting-term">
Formatting
</button>
<template id="formatting-term">
<strong class="mr-tooltip__title">Formatting</strong>
<span class="mr-tooltip__content">
Formatting changes presentation without changing the underlying value.
<button type="button" data-mr-tooltip="semantic-term">Semantic HTML</button>
is a related concept.
</span>
</template>
<template id="semantic-term">
<strong class="mr-tooltip__title">Semantic HTML</strong>
<span class="mr-tooltip__content">
Choose elements according to their meaning and native behavior.
</span>
</template>Tooltip presentation primitives
| Class | Purpose |
|---|---|
.mr-tooltip | Runtime-generated Tooltip surface presentation. |
.mr-tooltip--preview | Runtime-generated native popover="hint" preview, including entry/exit motion. |
.mr-tooltip--pinned | Runtime-generated interactive manual Popover Layer. The default presentation adds a stronger surface shadow plus a size-neutral pinned outline. |
.mr-tooltip__pin-progress | Runtime-generated determinate circular progress ring. It fills over the actual configured pinDelay and reaches 100% when promotion occurs. |
.mr-tooltip__dismiss | Runtime-generated top-right dismiss button available only after pinning. |
.mr-tooltip__title | Optional compact title inside authored Tooltip source content. |
.mr-tooltip__content | Optional body wrapper shared by preview and pinned clones. |
The state machine is:
hidden → waiting → preview → pinned
↘ hidden
pinned child → pinned grandchild → …- The preview uses
popover="hint"androle="tooltip". Authored HTML is rendered visually unchanged; interactive descendants are temporarilyinertandaria-hidden, while the preview exposes a plain-text accessible description through its tooltip label. - While the preview's pin timer runs,
data-mr-tooltip-pinningexposes a size-neutral circular progress ring in the reserved control slot. The SVG stroke fills linearly over the samepinDelayused by the promotion timer, so 100% means the Tooltip is pinning now. - The preview is never registered with Layer core and therefore never enters
layers.stack. - After
pinDelay, an interactivepopover="manual"surface is opened as a normal Popover Layer. - Pinned content may contain additional
data-mr-tooltiptriggers; their Layers inherit normal parent/child ownership. - Closing a parent Layer closes descendant Layers child-first.
- Markup Refine does not attach click handlers to tooltip triggers, so ordinary link/button activation remains the author's behavior.
Promotion is visible by default, but it is deliberately size-neutral: the preview reserves a top-right control slot and shows a determinate circular progress ring there while the pin timer is active. The ring fills from empty to complete over the actual configuredpinDelay; there is no separate CSS duration that can drift from the pin timer. After promotion that same slot contains the dismiss button, while the pinned outline becomes visible. The tooltip should read as the same surface becoming interactive, not as a second popover replacing it. Programmatic state remains available from tooltip.state.
Internally the preview remains a native popover="hint". The pinned surface is a native popover="manual" controlled by the Tooltip registry. Manual mode is intentional: clicks/selections inside remain stable, while one pointer-down outside the current Tooltip interaction region dismisses the whole active Tooltip stack.Escape remains topmost-only for keyboard users. showPopover({ source })is still used, so the browser retains top-layer placement, focus-order integration, and the implicit CSS anchor relationship.
Layered tooltip walkthrough
This example intentionally goes three levels deep. Hover or focus the first trigger until its preview promotes. Once the pinned outline appears, move into the pinned surface and hover/focus the nested trigger. The child tooltip becomes a child Popover Layer of the pinned parent; pinning it again creates the next level without any special nesting API.
<button type="button" data-mr-tooltip="layered-tooltip-level-1">
Layered tooltip demo
</button>
<template id="layered-tooltip-level-1">
<strong class="mr-tooltip__title">Level 1 · HTTP cache</strong>
<span class="mr-tooltip__content">
This preview becomes interactive after it pins.
<button type="button" data-mr-tooltip="layered-tooltip-level-2">
Cache-Control
</button>
</span>
</template>
<template id="layered-tooltip-level-2">
<strong class="mr-tooltip__title">Level 2 · Cache-Control</strong>
<span class="mr-tooltip__content">
Directives can themselves need explanation.
<button type="button" data-mr-tooltip="layered-tooltip-level-3">
stale-while-revalidate
</button>
</span>
</template>
<template id="layered-tooltip-level-3">
<strong class="mr-tooltip__title">Level 3 · stale-while-revalidate</strong>
<span class="mr-tooltip__content">
A cache may serve a stale response while it revalidates in the background.
</span>
</template>Transient previews render the same nested button HTML you will see after pinning, so promotion does not unexpectedly reflow or replace labels with different markup. The preview controls are inert, so they cannot be focused or activated until the pinned clone opens. Closing a pinned parent closes its pinned descendants child-first through normal Layer ownership.
JavaScript timing and API
import { initTooltips, tooltips } from "markup-refine-lib/tooltips";
tooltips.configure({
showDelay: 400,
pinDelay: 1600,
closeDelay: 250,
});
initTooltips(document);Timing is intentionally JavaScript configuration, not an attribute DSL. Existing Tooltip objects read the current registry configuration when a transition begins. Tooltip visual motion remains CSS-controlled through--mr-tooltip-motion-* tokens.
const trigger = document.querySelector("[data-mr-tooltip='formatting-term']");
if (trigger instanceof HTMLElement) {
tooltips.pin(trigger); // immediate programmatic promotion
console.log(tooltips.get(trigger)?.state);
}Complete public Tooltip API
import {
DEFAULT_TOOLTIP_OPTIONS,
initTooltips,
tooltips,
} from "markup-refine-lib/tooltips";
initTooltips(root?: ParentNode);
tooltips.configure(options);
tooltips.get(trigger);
tooltips.closest(node);
tooltips.pin(trigger);
tooltips.hideAll();
const tooltip = tooltips.get(trigger);
tooltip?.show();
tooltip?.hide();
tooltip?.pin();
tooltip?.unpin();
// state: "hidden" | "waiting" | "preview" | "pinned"
console.log(DEFAULT_TOOLTIP_OPTIONS);closest() finds the nearest registered Tooltip for a node, whilehideAll() closes previews/pinned tooltip surfaces managed by the registry. The exported defaults are useful when an application wants to override only selected timing values without duplicating the library defaults.
Public Tooltip types
| Type | Meaning |
|---|---|
TooltipState | "hidden" | "waiting" | "preview" | "pinned". |
TooltipOptions | Timing configuration: showDelay, pinDelay, and closeDelay. |
Tooltip | Per-trigger controller with trigger, state, show(), hide(), pin(), and unpin(). |
TooltipRegistry | The shape implemented by the exported tooltips registry: configure, lookup, pin, and global hide operations. |
import type {
Tooltip,
TooltipOptions,
TooltipRegistry,
TooltipState,
} from "markup-refine-lib/tooltips";Selection, dismissal, pointer, and keyboard behavior
Tooltip text explicitly uses user-select: text. Before pinning, pointer leave from the invoking element immediately cancels the current waiting/previewTooltip; the preview does not remain open while the pointer travels into it. Once pinned, pointer leave and focus leave no longer dismiss it. This gives a clear rule: transient previews belong to their trigger, while pinned surfaces are stable enough for text selection and interaction.
Pinned Tooltips dismiss in three explicit ways: activate the small top-right dismiss button, press Escape, or pointer-down outside the current Tooltip interaction region. Outside pointer-down is intentionally whole-tree dismissal: every active preview and pinned Tooltip in that document closes together. Escape remains topmost-only, so keyboard users can still unwind nested pinned Tooltips one level at a time. Clicking or selecting inside any active Tooltip surface does not dismiss it.
Before pinning, moving the pointer away from the invoking element cancels the current preview immediately, including its pin timer. A pinned ancestor is unaffected when a nested trigger's transient child preview is canceled. Preview surfaces never receive focus. When promoted,role="tooltip" is removed; the pinned surface is a non-modal Popover Layer and the trigger receives an aria-details relationship while it is open.
Authoring guidance
- Use tooltips for supplementary explanation, never for the only copy of essential meaning or instructions.
- Prefer a
<template>source for interactive pinned content, especially when it contains IDs, labels, or nested controls. - Use naturally focusable triggers when keyboard users need the explanation.
- Keep preview content concise. Pinned content may be richer, but it remains non-modal.
- Toasts are intentionally outside this subsystem; notification lifetime and announcement semantics are a separate concern.
Tooltip width
Tooltip width is independent from the invoking element. Markup Refine does not useanchor-size(width) or otherwise copy the trigger width. Configure minimum, preferred, and maximum inline size independently with CSS tokens.
:root {
--mr-tooltip-min-inline-size: 12rem;
--mr-tooltip-inline-size: 20rem;
--mr-tooltip-max-inline-size: 28rem;
}Defaults are 0, max-content, and 24rem. That keeps short tooltips compact, wraps longer text at the maximum, and still lets the viewport gap cap the used size on narrow screens. Set the tokens per scope or per Tooltip surface.
Positioning, viewport edges, and motion
Tooltip and Popover Layer CSS uses the native invoker/anchor relationship. The preferred position is position-area: block-end center. On current anchor- positioning browsers, position-try-fallbacks then asks the browser to try positions above and to either inline side when the preferred placement would overflow the viewport. This handles the common top, bottom, left, right, and corner-edge cases without a JavaScript coordinate engine.
position-area: block-end center;
position-try-fallbacks:
block-start center,
block-end inline-start,
block-end inline-end,
block-start inline-start,
block-start inline-end;Resize the page to a narrow viewport and try the trigger below. As it approaches the inline edge, the browser can select one of the alternate anchor areas instead of letting the tooltip run off-screen.
<div class="tooltip-edge-demo">
<button type="button" data-mr-tooltip="edge-tooltip">
Trigger near the edge
</button>
</div>
<template id="edge-tooltip">
<strong class="mr-tooltip__title">Viewport-aware placement</strong>
<span class="mr-tooltip__content">
The browser tries alternate anchor areas when the preferred position would overflow.
</span>
</template>Viewport-aware placementThe browser tries alternate anchor areas when the preferred position would overflow.Markup Refine still has no application-level collision, coordinate, or safe-polygon engine. On older browsers that lack position-try-fallbacks, the tooltip keeps its native popover behavior and viewport-constrained maximum size, but automatic collision flipping is not guaranteed. Before pinning, pointer leave from the trigger remains the deliberate first-line solution for pointer travel; after pinning, the surface persists until explicit or whole-tree outside dismissal.
Native hint previews transition with opacity/transform plus discrete display/overlay transitions. Pinned tooltips use the normal Popover Layer transition. The pin-progress ring also uses stepped progress updates underprefers-reduced-motion: reduce. Reduced-motion preferences set surface transitions to zero duration.
:root {
--mr-tooltip-motion-duration: 120ms;
--mr-tooltip-motion-distance: 0.2rem;
--mr-tooltip-content-gap: var(--mr-space-xs);
--mr-tooltip-pinned-outline-width: 2px;
--mr-tooltip-control-size: 1.5rem;
--mr-tooltip-min-inline-size: 12rem;
--mr-tooltip-inline-size: 20rem;
--mr-tooltip-max-inline-size: 28rem;
}Lower-capability fallback
Popover enhancement must not be the only place for essential information. Prefer ordinary inline content or a native disclosure when the content must remain usable without the Popover API.
<details>
<summary>Formatting help</summary>
<p>Formatting changes presentation without changing the underlying value.</p>
</details>Tooltip previews use role="tooltip" and are associated with their trigger through aria-describedby. Unsupported Popover capability does not cause an automatic modal promotion.