MarkupRefineLib

Menu


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

NeedUseWhy
Blocking decision or workflowModal LayerNative modal <dialog> provides inertness and modal semantics.
Blocking transient edge panelDrawer LayerSame dialog lifecycle, different presentation.
Interactive non-modal floating UI with lifecycle/ownershipPopover LayerNative top layer and light dismiss plus Layer hierarchy.
Short supplementary hover/focus explanationTooltip previewTransient, non-focusable, and intentionally outside layers.stack.
Simple disclosure or persistent contentNo LayerPrefer inline HTML, <details>, or another native structure.
Simple popover with no orchestration needsPlain native popoverpopover/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>
Hover or focus long enough to pin the explanation.

Tooltip presentation primitives

ClassPurpose
.mr-tooltipRuntime-generated Tooltip surface presentation.
.mr-tooltip--previewRuntime-generated native popover="hint" preview, including entry/exit motion.
.mr-tooltip--pinnedRuntime-generated interactive manual Popover Layer. The default presentation adds a stronger surface shadow plus a size-neutral pinned outline.
.mr-tooltip__pin-progressRuntime-generated determinate circular progress ring. It fills over the actual configured pinDelay and reaches 100% when promotion occurs.
.mr-tooltip__dismissRuntime-generated top-right dismiss button available only after pinning.
.mr-tooltip__titleOptional compact title inside authored Tooltip source content.
.mr-tooltip__contentOptional body wrapper shared by preview and pinned clones.

The state machine is:

hidden → waiting → preview → pinned
                       ↘ hidden
                                  pinned child → pinned grandchild → …

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>
Wait for the pinned outline, then move into the tooltip and continue inward.

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

TypeMeaning
TooltipState"hidden" | "waiting" | "preview" | "pinned".
TooltipOptionsTiming configuration: showDelay, pinDelay, and closeDelay.
TooltipPer-trigger controller with trigger, state, show(), hide(), pin(), and unpin().
TooltipRegistryThe 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

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>

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.