Tooltip
A lightweight floating label that appears on hover, focus, or click. Automatically flips placement when near viewport edges and dismisses on Escape.
Features
- 📍 4 Placements: top (default), bottom, left, right — with viewport-aware auto-flip
- ⚡ 3 Trigger Modes: hover, focus, click — comma-separated for combinations
- ⏱️ Show Delay: configurable delay before appearing
- 🎨 2 Variants: dark (default), light
- 📏 3 Sizes: sm, md, lg
- ♿ Accessible:
role="tooltip",aria-describedbywiring, keyboardEscapedismiss - 🔧 Powered by floatit: uses
@vielzeug/floatitfor viewport-aware auto-positioning (flip,shift,autoUpdate)
Source Code
View Source Code
ts
import type { Placement } from '@vielzeug/floatit';
import { computed, createId, defineComponent, html, onMount, onSlotChange, signal, watch } from '@vielzeug/craftit';
import { autoUpdate, computePosition, flip, offset, shift } from '@vielzeug/floatit';
import type { ComponentSize } from '../../types';
import { forcedColorsMixin } from '../../styles';
type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';
type TooltipTrigger = 'hover' | 'focus' | 'click';
const TOOLTIP_OFFSET = 8; // gap from trigger to tooltip edge
const LEFT_GAP_COMPENSATION = 4; // left placement looks visually tighter in practice
import styles from './tooltip.css?inline';
/** Tooltip component properties */
export type BitTooltipProps = {
/** Hide delay in ms (useful to keep tooltip open when moving focus between trigger and tooltip) */
'close-delay'?: number;
/** Tooltip text content */
content?: string;
/** Show delay in ms */
delay?: number;
/** Disable the tooltip */
disabled?: boolean;
/** Controlled open state. When provided, the tooltip acts as a controlled component and ignores trigger events for open/close. */
open?: boolean;
/** Preferred placement relative to trigger */
placement?: TooltipPlacement;
/** Tooltip size */
size?: ComponentSize;
/** Which trigger(s) show/hide the tooltip — comma-separated if multiple, e.g. "hover,focus" */
trigger?: string;
/** Visual variant: 'dark' (default) or 'light' */
variant?: 'dark' | 'light';
};
/**
* A lightweight tooltip shown on hover/focus/click relative to the slotted trigger.
*
* @element bit-tooltip
*
* @attr {string} content - Tooltip text content
* @attr {string} placement - 'top' | 'bottom' | 'left' | 'right' (default: 'top')
* @attr {string} trigger - 'hover' | 'focus' | 'click' or comma-separated combination
* @attr {number} delay - Show delay in milliseconds (default: 0)
* @attr {string} size - Size: 'sm' | 'md' | 'lg'
* @attr {string} variant - 'dark' (default) | 'light'
* @attr {boolean} disabled - Disable the tooltip
*
* @slot - Trigger element that the tooltip is anchored to
* @slot content - Complex tooltip content (overrides the `content` attribute)
*
* @cssprop --tooltip-max-width - Max width of the tooltip bubble
*
* @example
* ```html
* <bit-tooltip content="Copy to clipboard">
* <button>Copy</button>
* </bit-tooltip>
*
* <bit-tooltip placement="right" trigger="focus,hover" content="Required field">
* <input type="text" />
* </bit-tooltip>
* ```
*/
export const TOOLTIP_TAG = defineComponent<BitTooltipProps>({
props: {
'close-delay': { default: 0 },
content: { default: '' },
delay: { default: 0 },
disabled: { default: false },
open: { default: undefined },
placement: { default: 'top' },
size: { default: undefined },
trigger: { default: 'hover,focus' },
variant: { default: undefined },
},
setup({ host, props }) {
const visible = signal(false);
const activePlacement = signal<TooltipPlacement>('top');
let autoUpdateCleanup: (() => void) | null = null;
let showTimer: ReturnType<typeof setTimeout> | null = null;
let hideTimer: ReturnType<typeof setTimeout> | null = null;
let tooltipEl: HTMLElement | null = null;
const tooltipId = createId('tooltip');
const triggers = computed<TooltipTrigger[]>(() =>
String(props.trigger.value)
.split(',')
.map((t: string) => t.trim() as TooltipTrigger)
.filter(Boolean),
);
function getTriggerEl(): Element | null {
// First slotted element is the trigger
const slot = host.shadowRoot?.querySelector<HTMLSlotElement>('slot:not([name])');
const assigned = slot?.assignedElements({ flatten: true });
return assigned?.[0] ?? null;
}
function updatePosition() {
if (!tooltipEl) return;
const triggerEl = getTriggerEl();
if (!triggerEl) return;
computePosition(triggerEl, tooltipEl, {
middleware: [offset(TOOLTIP_OFFSET), flip(), shift({ padding: 6 })],
placement: props.placement.value as Placement,
}).then(({ placement, x, y }) => {
if (!tooltipEl) return;
const side = placement.split('-')[0] as TooltipPlacement;
const adjustedX = side === 'left' ? x - LEFT_GAP_COMPENSATION : x;
tooltipEl.style.left = `${adjustedX}px`;
tooltipEl.style.top = `${y}px`;
activePlacement.value = side;
});
}
function show() {
if (props.open.value !== undefined) return; // controlled mode
const hasSlottedContent = () => {
const contentSlot = host.shadowRoot?.querySelector<HTMLSlotElement>('slot[name="content"]');
return (contentSlot?.assignedNodes({ flatten: true }).length ?? 0) > 0;
};
if (props.disabled.value || (!props.content.value && !hasSlottedContent())) return;
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
if (showTimer) clearTimeout(showTimer);
showTimer = setTimeout(
() => {
visible.value = true;
if (tooltipEl && !tooltipEl.matches(':popover-open')) {
tooltipEl.showPopover();
}
// Start autoUpdate: repositions on scroll, resize, and reference size change
const triggerEl = getTriggerEl();
if (triggerEl && tooltipEl) {
autoUpdateCleanup?.();
autoUpdateCleanup = autoUpdate(triggerEl, tooltipEl, updatePosition);
} else {
requestAnimationFrame(() => updatePosition());
}
},
Number(props.delay.value) || 0,
);
}
function hide() {
if (props.open.value !== undefined) return; // controlled mode
if (showTimer) {
clearTimeout(showTimer);
showTimer = null;
}
const closeDelay = Number(props['close-delay'].value) || 0;
if (closeDelay > 0) {
if (hideTimer) clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
hideTimer = null;
_doHide();
}, closeDelay);
} else {
_doHide();
}
}
function _doHide() {
autoUpdateCleanup?.();
autoUpdateCleanup = null;
visible.value = false;
if (tooltipEl?.matches(':popover-open')) {
tooltipEl.hidePopover();
}
}
function toggleClick() {
if (visible.value) hide();
else show();
}
onMount(() => {
const slot = host.shadowRoot?.querySelector<HTMLSlotElement>('slot:not([name])');
if (!slot) return;
const bindTriggerEvents = () => {
unbindTriggerEvents(); // clean up previous bindings
const triggerEl = slot.assignedElements({ flatten: true })[0] as HTMLElement | undefined;
if (!triggerEl) return;
triggerEl.setAttribute('aria-describedby', tooltipId);
const t = triggers.value;
if (t.includes('hover')) {
triggerEl.addEventListener('pointerenter', show);
triggerEl.addEventListener('pointerleave', hide);
}
if (t.includes('focus')) {
triggerEl.addEventListener('focusin', show);
triggerEl.addEventListener('focusout', hide);
}
if (t.includes('click')) {
triggerEl.addEventListener('click', toggleClick);
}
// Keyboard escape to dismiss
document.addEventListener('keydown', handleKeydown);
};
const unbindTriggerEvents = () => {
const triggerEl = slot.assignedElements({ flatten: true })[0] as HTMLElement | undefined;
if (!triggerEl) return;
triggerEl.removeAttribute('aria-describedby');
triggerEl.removeEventListener('pointerenter', show);
triggerEl.removeEventListener('pointerleave', hide);
triggerEl.removeEventListener('focusin', show);
triggerEl.removeEventListener('focusout', hide);
triggerEl.removeEventListener('click', toggleClick);
document.removeEventListener('keydown', handleKeydown);
};
onSlotChange('default', bindTriggerEvents);
// Controlled mode: watch the `open` prop and show/hide accordingly
watch(props.open, (openVal) => {
if (openVal === undefined || openVal === null) return;
if (openVal) {
visible.value = true;
if (tooltipEl && !tooltipEl.matches(':popover-open')) tooltipEl.showPopover();
const triggerEl = getTriggerEl();
if (triggerEl && tooltipEl) {
autoUpdateCleanup?.();
autoUpdateCleanup = autoUpdate(triggerEl, tooltipEl, updatePosition);
} else {
requestAnimationFrame(() => updatePosition());
}
} else {
_doHide();
}
});
return () => {
unbindTriggerEvents();
if (showTimer) clearTimeout(showTimer);
if (hideTimer) clearTimeout(hideTimer);
autoUpdateCleanup?.();
autoUpdateCleanup = null;
if (tooltipEl?.matches(':popover-open')) {
tooltipEl.hidePopover();
}
};
});
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') hide();
}
return html`
<slot></slot>
<div
class="tooltip"
part="tooltip"
id="${tooltipId}"
role="tooltip"
popover="manual"
ref=${(el: HTMLElement) => {
tooltipEl = el;
}}
:data-placement="${activePlacement}"
:aria-hidden="${() => String(!visible.value)}">
<slot name="content">${() => props.content.value}</slot>
</div>
`;
},
styles: [forcedColorsMixin, styles],
tag: 'bit-tooltip',
});Basic Usage
Wrap any element with bit-tooltip and set the content attribute.
html
<bit-tooltip content="Copy to clipboard">
<button>Copy</button>
</bit-tooltip>
<script type="module">
import '@vielzeug/buildit/tooltip';
</script>Placement
Trigger Modes
Variants
Sizes
Show Delay
Use delay (milliseconds) to add a pause before the tooltip shows — useful for dense UIs.
Rich Content via Slot
For complex tooltip content, use the content named slot.
Disabled Tooltips
Set disabled to suppress the tooltip entirely.
html
<bit-tooltip content="This won't show" disabled>
<bit-button>No tooltip</bit-button>
</bit-tooltip>API Reference
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
content | string | '' | Tooltip text |
placement | 'top' | 'bottom' | 'left' | 'right' | 'top' | Preferred placement (auto-flips near viewport edges) |
trigger | string | 'hover,focus' | Trigger mode(s), comma-separated |
delay | number | 0 | Show delay in milliseconds |
close-delay | number | 0 | Hide delay in milliseconds — useful to keep the tooltip open when moving between trigger and bubble |
open | boolean | — | Controlled open state; when set, trigger events are ignored |
variant | 'dark' | 'light' | — | Visual style (dark appearance is the unset default) |
size | 'sm' | 'md' | 'lg' | — | Tooltip bubble size (medium appearance is the unset default) |
disabled | boolean | false | Disable the tooltip entirely |
Slots
| Slot | Description |
|---|---|
| (default) | The trigger element the tooltip is anchored to |
content | Rich tooltip content (overrides the content attribute) |
CSS Custom Properties
| Property | Description | Default |
|---|---|---|
--tooltip-max-width | Max width of the bubble | 18rem |
Accessibility
The tooltip component follows WAI-ARIA best practices.
bit-tooltip
✅ Keyboard Navigation
- Pressing
Escapewhile a tooltip is visible dismisses it.
✅ Screen Readers
- The tooltip bubble has
role="tooltip". - The trigger element is augmented with
aria-describedbypointing to the tooltip — this happens automatically when using thefocustrigger.
TIP
When trigger includes focus, the tooltip is automatically wired as a programmatic description for the focused element, which benefits screen reader users.
Best Practices
Do:
- Keep tooltip text short — one sentence or a keyboard shortcut label.
- Use
trigger="focus"(or"hover,focus") for form field hints so keyboard-only users see them. - Use
delay(e.g.400–600ms) in action-dense toolbars to avoid visual noise on quick cursor sweeps. - Prefer
variant="light"on dark backgrounds.
Don't:
- Use tooltips to hold essential information — if the user must see it to act, put it in helper text or an alert instead.
- Add interactive elements (buttons, links) inside the tooltip bubble; tooltips are not focusable.