Zero-dependency tooltips that follow your page's theme on their own. Hover, click, and tab through everything below — every card is live.
Four sides with auto-flip when there's no room, plus an arrow that tracks the trigger.
<button data-vtt="I'm on top"
data-vtt-placement="top">
Hover (also shows on keyboard focus), click, or focus-only — try tabbing through.
new Tooltip('#save', {
trigger: 'click' // hover | click | focus | manual
})
The panel stays open while your pointer or focus is inside it, so it can hold links and buttons.
new Tooltip('#del', {
trigger: 'click', interactive: true,
content: function (el) { return panelEl }
})
Content is text by default; html: true is an explicit
opt-in for trusted markup.
new Tooltip('#hint', {
html: true,
content: 'Press <b>⌘S</b> to save'
})
trigger: 'manual' binds nothing — you drive it with
show(), hide(), toggle().
var tip = new Tooltip('#el', {
trigger: 'manual', content: '…' })
tip.toggle()
Tooltip.defaults.styles = false injects no CSS — the
behavior and ARIA stay, the look is yours (see Tooltip.css
for a starting point). With the VC core loaded, one call themes every
family component.
<script src="https://cdn.jsdelivr.net/gh/vanilla-ui-kit/components/tooltip/tooltip.js"></script>
VC.config({ accent: '#b45309' })
new Tooltip(target, options?) // target: element or CSS selector
One class, four trigger modes:
show() / hide() / toggle().Add interactive: true for a panel that stays open while the pointer or focus is inside it and may hold focusable content. Also works with CommonJS/AMD and is SSR-safe (no-op without a DOM); Tooltip.create(target, options) is a constructor alias.
Second constructor argument; all keys of Tooltip.defaults. Change a default for every tooltip with, e.g., Tooltip.defaults.placement = 'bottom'.
| Name | Type | Default | Description |
|---|---|---|---|
content | string | fn | Element | '' | String, fn(triggerEl) (re-resolved on every show), or a DOM element. |
placement | string | 'top' | 'top' | 'bottom' | 'left' | 'right'; flips to the opposite side when there is no room, clamps to the viewport. |
trigger | string | 'hover' | 'hover' | 'click' | 'focus' | 'manual'. Hover also shows on keyboard focus; 'manual' binds nothing. |
delay | number | object | null | Number (both ways) or { show, hide } in ms. Hover defaults to { show: 80, hide: 120 }; other triggers to 0. Focus always shows immediately. |
offset | number | 8 | Gap in px between the trigger and the panel. |
arrow | boolean | true | Render the pointer arrow (it tracks the trigger even when the panel is clamped). |
interactive | boolean | false | Popover mode: the panel stays open while the pointer or focus is inside it, and may contain focusable content. |
html | boolean | false | Content is text by default (rendered with textContent); opt in for trusted markup. |
theme | string | 'auto' | 'auto' | 'light' | 'dark' (see Theming). |
styles | boolean | true | false = headless, no CSS ever injected (see Theming). |
labels | object | { popover: 'Popover' } | Accessible names; popover labels click-trigger panels. |
onShow | function | null | fn(tooltip) — see Events & callbacks. |
onHide | function | null | fn(tooltip) — see Events & callbacks. |
All methods return the instance (chainable).
| Method | Returns | Description |
|---|---|---|
show() | this | Show the panel (re-resolves function content, repositions, joins an open <dialog>’s top layer if the trigger lives in one). |
hide() | this | Hide the panel. |
toggle() | this | Show if hidden, hide if visible. |
update(content?) | this | Replace the content; re-renders and repositions if currently visible. Call with no argument to re-render existing (function) content. |
destroy() | this | Hide, remove the panel and all listeners, restore trigger ARIA attributes, and unregister the instance. |
| Member | Returns | Description |
|---|---|---|
Tooltip.create(target, options?) | Tooltip | Constructor alias — same as new Tooltip(…). |
Tooltip.get(target) | Tooltip | null | The instance bound to an element (or selector), or null. |
Tooltip.autoInit(root?) | Tooltip[] | Activate every [data-vtt] under root (default: document) that has no instance yet. Runs automatically on load; call it for content added later. |
Tooltip.defaults | object | Global defaults — change any once, e.g. Tooltip.defaults.theme = 'dark'. |
Tooltip.salt | string | false | CSS isolation namespace (default 'vc1'). Set your own token before the first tooltip, or false to disable. |
Tooltip.css | string | The full stylesheet, always rendered with the current salt — a starting point for headless styling. |
Tooltip.version | string | Library version. |
| Callback | Signature | Fires |
|---|---|---|
onShow | fn(tooltip) | After the panel becomes visible. |
onHide | fn(tooltip) | After the panel is hidden. |
Tooltip dispatches no CustomEvents — the two callbacks above are the whole event surface.
Every [data-vtt] element is activated on load (and via Tooltip.autoInit(root) for content added later):
| Attribute | Option | Notes |
|---|---|---|
data-vtt | content | The tooltip text; also marks the element for auto-init. |
data-vtt-placement | placement | top / bottom / left / right. |
data-vtt-trigger | trigger | hover / click / focus / manual. |
data-vtt-interactive | interactive | Boolean flag; bare attribute = true, "false"/"0" = false. |
data-vtt-theme | theme | auto / light / dark. |
data-vtt-delay | delay | Number in ms (applies to both show and hide). |
<button data-vtt="Tooltip text"
data-vtt-placement="right"
data-vtt-trigger="click"
data-vtt-interactive>…</button>
Auto light/dark with the family’s resolution order: <html data-theme> / data-bs-theme / .dark class → prefers-color-scheme, re-resolved live. Pin one instance with { theme: 'dark' } or all of them with Tooltip.defaults.theme = 'dark'. All colors are CSS custom properties on .vtt (dark values on .vtt[data-theme=dark]):
| Property | Role |
|---|---|
--vtt-accent | Links and focus outlines inside the panel. |
--vtt-bg | Panel background. |
--vtt-text | Text color. |
--vtt-muted | Secondary text. |
--vtt-faint | Border. |
--vtt-shadow | Panel shadow. |
--vtt-radius | Corner radius. |
--vtt-max-width | Maximum panel width. |
--vtt-font | Font stack. |
CSS isolation — panels render as class="vtt vc1" and structural rules ship salted, so host-page design systems can’t override them; the --vtt-* variable overrides above keep working (var definitions are deliberately unsalted). Custom token: Tooltip.salt = 'acme' before the first tooltip; disable with Tooltip.salt = false.
Headless — Tooltip.defaults.styles = false (or per instance styles: false) injects no CSS, ever. You keep the full behavior (triggers, delays, flipping, ARIA) and the markup contract: .vtt.vtt-open[data-placement="top"][data-theme="dark"] containing .vtt-arrow (only when arrow; position set inline) and .vtt-content; .vtt-interactive is added in popover mode (it re-enables pointer events). Start from Tooltip.css (string) or dist/tooltip.css. With the VC core loaded, VC.config({ accent: … }) themes tooltips and every other family component, and popups position through the shared VC.position engine.
| Key | Action |
|---|---|
Tab (focus the trigger) | Shows hover/focus tooltips immediately (no delay). |
Enter / Space (click trigger) | Toggles a click popover (native button activation). |
Tab (into an interactive panel) | Keeps the panel open while focus is inside it. |
Escape | Dismisses any visible tooltip; click popovers return focus to their trigger. |
Hover/focus tooltips follow the WAI-ARIA tooltip pattern: the panel gets role="tooltip" and the trigger gets aria-describedby while it is visible (an existing aria-describedby is preserved and restored). Click popovers set aria-expanded + aria-controls on the trigger; the panel is a labelled role="group" (name via labels.popover). Focusable content inside interactive panels gets :focus-visible outlines; prefers-reduced-motion disables all animation.
Set Tooltip.defaults.styles = false and no CSS is ever injected —
you keep the full behavior (triggers, delays, positioning, flipping, ARIA) and the stable
.vtt-* class hooks to style however you like. Both demos below run in an
<iframe>: the styled examples above have already injected the stylesheet
into this page, so a headless tooltip out here would still match those rules.
The raw browser rendering — the panel is a plain positioned <div>
(positioning is behavior, so it still lands next to the trigger). It is opened on load;
click the trigger to toggle it.
Tooltip.defaults.styles = false // headless: zero CSS injected
new Tooltip('#tip', { content: '…', trigger: 'click', placement: 'bottom' })
The same headless init with the .vtt-* hooks mapped to Tailwind utilities via
@apply — a light panel with a matching arrow instead of the kit's dark one, and the
.vtt-open state class drives the transition. The button fetches
cdn.tailwindcss.com, so nothing loads until you ask.