Tooltips

Summary

Soma ships two tooltips on purpose. The CSS-only one is an attribute and no JavaScript: perfect for short static hints. The JS one adds placement, show/hide delays, multi-line text, viewport clamping and aria-describedby. They use different attributes, so an element can never sprout both bubbles.

When to use

VariantUse it for
CSS-only
data-soma-tooltip="text"
Short single-line hints on icon buttons and controls. Zero JS. Always above, no wrapping. Not exposed to screen readers, so keep a real label/aria-label on the element.
JS
data-soma-tip / Soma.tooltip()
Longer text, a specific side, delays, or content computed at hover time. Announced to screen readers via aria-describedby.
Inline dialogAnything interactive: links, forms. Tooltips are not focusable (the bubble is pointer-events: none).

Examples

CSS-only

JS — four placements

data-soma-tip="…":

JS — auto placement

Resolved at show time from the element's viewport position.

JS — delays

delayIn / delayOut:

Default: 500 ms in, 0 out. Keyboard focus always shows instantly.

JS — function title

title: (el) => … is called on every show.

JS — html: true

Renders HTML. Trusted content only.

Imperative control

.show() / .hide() / .destroy():

degraded

After destroy() the badge behaves as never enhanced (hover shows nothing); show() re-binds it first.

Show / hide behaviour

InteractionEffect
Pointer enters / leavesShow after delayIn (default 500 ms) / hide after delayOut (default 0). A leave during the in-delay cancels the pending show.
Keyboard focus (:focus-visible)Show immediately — Tab users don't wait out a pointer-rest delay. Mouse-click focus keeps the delay.
Focus leavesHide (after delayOut).
Click on the triggerHide — activating a control suppresses its hint.
EscHide.

While visible, the bubble (role="tooltip", a generated id) is referenced from the trigger via aria-describedby, so screen readers read the hint after the element's name; the reference is removed on hide. The bubble is appended to <body> and clamped to the viewport (4px margins), so it never causes scrollbars.

The bubble is a native popover="manual" in the browser's top layer, so hints stay visible above open modal dialogs and other overlays. Manual, deliberately: show/hide timing stays with Soma's own hover/focus delay logic, and an auto popover's one-at-a-time rule would close an open menu the moment a hint appeared. Without the Popover API the fixed-position + z-index CSS applies unchanged.

Placement

top / bottom center the bubble above or below; left / right are logical (inline-start / inline-end), so they swap physical sides under RTL (the arrow follows). auto resolves at show time: below, unless the element sits in the bottom third of the viewport, then above. Where the engine supports CSS anchor positioning the bubble is placed by position-area anchored to the trigger (the JS measure-and-clamp stands down); elsewhere the JS math applies unchanged.

HTML

CSS-only. The text is the attribute value; shows on hover and :focus-visible:

<button class="soma-button" data-soma-tooltip="Saves immediately">Save</button>

JS via auto-init. The text comes from title, the attribute value is the placement (empty value = the default, bottom). Every [data-soma-tip] element is bound at load:

<button class="soma-button" title="Saves immediately" data-soma-tip="top">Save</button>

<!-- auto placement: top or bottom, resolved at show time -->
<button class="soma-button" title="Whichever fits" data-soma-tip="auto">Auto</button>

<!-- empty value: default placement (bottom) -->
<button class="soma-button" title="Below" data-soma-tip>Default</button>

On bind, the native title attribute is moved to data-soma-original-title so the browser's own tooltip doesn't double up. The text stays recoverable and .destroy() moves it back.

CSS classes

Class / attributeEffect
data-soma-tooltip="text"CSS-only tooltip above the element on hover/:focus-visible.
data-soma-tip="placement"Auto-init hook for the JS tooltip; empty value means the default (bottom).
data-soma-original-titleWhere the JS tooltip parks the native title while bound.
.soma-tooltip-bubbleThe generated JS bubble (role="tooltip", max-width 260px, pointer-events: none), with -top/-bottom/-left/-right arrow variants.

JavaScript

Constructor + options

MemberDescription
Soma.tooltip(target, opts?)Bind and return the singleton per element. target may be an element, an array of elements, or a selector: a call resolving to one element returns the instance, several return an array of instances. Options are read only on first bind per element.
opts.placement'top' | 'bottom' | 'left' | 'right' | 'auto'. Default 'bottom'. Left/right are logical (swap sides in RTL); auto resolves per show.
opts.titleThe text: a string, or fn(el) => string evaluated on every show. Default: the element's original title attribute. Empty text = the tooltip simply doesn't show.
opts.delayInms before showing on pointer enter. Default 500. Keyboard focus ignores it.
opts.delayOutms before hiding on pointer leave / focusout. Default 0.
opts.htmltrue renders the title as HTML instead of text. Trusted content only. Default false.

Instance methods

MemberDescription
.show()Show immediately: build the bubble, resolve placement, position + clamp, set aria-describedby. No-op when the resolved text is empty.
.hide()Cancel any pending timer, remove the bubble, drop aria-describedby.
.destroy()Hide, unbind all listeners, restore the original title attribute, drop the singleton. The element behaves as if never enhanced; a later Soma.tooltip(el) re-binds it.

show() and hide() return the instance, so calls chain. The tooltip dispatches no CustomEvents — it is a visual/AT supplement, not a state carrier.

Bind many elements at once with a selector (one call, shared options):

// Every toolbar icon button gets a bottom tooltip from its title attribute
Soma.tooltip('.soma-toolbar [title]', { placement: 'bottom', delayIn: 300 });

Show programmatically, for example to surface a hint when a state changes, then hide it a moment later:

const hint = Soma.tooltip('#status-badge', {
  title: 'Latency above threshold for 5 minutes',
  placement: 'right',
});

hint.show();
setTimeout(() => hint.hide(), 3000);

Tear down before removing or re-rendering the element; destroy() restores the native title:

const tip = Soma.tooltip('#save');
tip.destroy();                    // listeners off, title attribute back
Soma.tooltip('#save', { placement: 'top' });   // fresh bind, new options

Bind elements rendered after page load (auto-init runs once, at load). The call is a get-or-create singleton:

container.insertAdjacentHTML('beforeend', renderedRow);
Soma.tooltip(container.querySelectorAll('[data-soma-tip]'));

The live examples above, exactly as this page wires them:

// Placements and auto come from markup (data-soma-tip) — no wiring needed.

// Delays
Soma.tooltip('#tip-instant', { title: 'Shown instantly', delayIn: 0 });
Soma.tooltip('#tip-linger', {
  title: 'Stays for 800 ms after the pointer leaves',
  delayOut: 800,
});

// Function title — evaluated on every show
Soma.tooltip('#tip-fn', {
  title: () => `Computed at ${new Date().toLocaleTimeString()}`,
});

// html: true — trusted content only
Soma.tooltip('#tip-html', {
  title: 'Press <kbd>g</kbd> then <kbd>d</kbd> for the dashboard',
  html: true,
});

// Imperative control — show/hide/destroy (show re-binds after destroy)
const opts = { title: 'p95 latency 840 ms — threshold 500 ms', placement: 'right' };
Soma.tooltip('#tip-target', opts);
document.getElementById('tip-show')
  .addEventListener('click', () => Soma.tooltip('#tip-target', opts).show());
document.getElementById('tip-hide')
  .addEventListener('click', () => Soma.tooltip('#tip-target', opts).hide());
document.getElementById('tip-destroy')
  .addEventListener('click', () => Soma.tooltip('#tip-target', opts).destroy());