Tooltip คำอธิบาย
Short hints on hover and keyboard focus, positioned above, below, left or right and flipped when there is no room.
Live demo
Everything here talks to the real Spring Boot + SQL Server back end.Four positions
Hover with the mouse or press Tab to focus. The text is linked with aria-describedby.
target
Icon toolbar
Icon-only buttons need a tooltip (and an aria-label) or nobody knows what they do.
Help next to a form field
Flip at the edge
These ask for right, but there is no room near the right edge of the window, so they flip to the left.
What to notice
- One tooltip element is reused; the target is linked with aria-describedby.
- The position is measured with getBoundingClientRect() and flipped at the edges.
- Tooltips also open on focus, so keyboard users get them.
Try it
Source code
4 parts, read from the files in this project
src/main/resources/templates/demos/l319.html
A tooltip is only two data attributes on any element
<div class="tip-grid">
<span></span><button class="btn btn-ghost" type="button" data-tip="I am above the button" data-tip-pos="top">Top</button><span></span>
<button class="btn btn-ghost" type="button" data-tip="Left side" data-tip-pos="left">Left</button>
<div class="center">target</div>
<button class="btn btn-ghost" type="button" data-tip="Right side" data-tip-pos="right">Right</button>
<span></span><button class="btn btn-ghost" type="button" data-tip="Below the button" data-tip-pos="bottom">Bottom</button><span></span>
</div>
src/main/resources/static/css/app.css
One shared tooltip element with an arrow
.tooltip {
position: fixed; z-index: 400; max-width: 16rem; padding: .4rem .65rem; border-radius: .45rem; pointer-events: none;
background: #0f172a; color: #f8fafc; font-size: .78rem; font-weight: 500; line-height: 1.4; box-shadow: var(--shadow);
animation: tip-in .12s ease;
}
html[data-theme="dark"] .tooltip { background: #e8ecf6; color: #0f172a; }
.tooltip::after { content: ""; position: absolute; width: .55rem; height: .55rem; background: inherit; transform: rotate(45deg); }
.tooltip[data-pos="top"]::after { bottom: -.25rem; left: calc(var(--arrow, 50%) - .275rem); }
.tooltip[data-pos="bottom"]::after { top: -.25rem; left: calc(var(--arrow, 50%) - .275rem); }
.tooltip[data-pos="left"]::after { right: -.25rem; top: calc(50% - .275rem); }
.tooltip[data-pos="right"]::after { left: -.25rem; top: calc(50% - .275rem); }
@keyframes tip-in { from { opacity: 0; } }
src/main/resources/static/css/app.css
Demo targets for the four tooltip positions
.tip-grid { display: grid; grid-template-columns: repeat(3, auto); gap: .75rem; justify-content: center; align-items: center; padding: 2rem 0; }
.tip-grid .center { width: 7rem; height: 4rem; display: grid; place-items: center; border-radius: var(--radius); background: var(--surface-2); color: var(--text-3); font-size: .8rem; border: 1px dashed var(--border-strong); }
src/main/resources/static/js/core.js
Show, position and flip the tooltip
const tooltip = (() => {
const tip = $('#tooltip');
let target = null, timer = null;
function show(el) {
if (el.dataset.tipWhen === 'collapsed' && !(root.classList.contains('sidebar-collapsed') && innerWidth > 900)) return;
target = el;
tip.textContent = el.dataset.tip;
tip.hidden = false;
el.setAttribute('aria-describedby', 'tooltip');
place(el, el.dataset.tipPos || 'top');
}
function place(el, wanted) {
const r = el.getBoundingClientRect(), t = tip.getBoundingClientRect(), gap = 9;
const fits = {
top: r.top - t.height - gap > 0,
bottom: r.bottom + t.height + gap < innerHeight,
left: r.left - t.width - gap > 0,
right: r.right + t.width + gap < innerWidth
};
const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
const pos = fits[wanted] ? wanted : (fits[opposite[wanted]] ? opposite[wanted] : 'top'); // flip when there is no room
let x, y;
if (pos === 'top' || pos === 'bottom') {
x = r.left + r.width / 2 - t.width / 2;
y = pos === 'top' ? r.top - t.height - gap : r.bottom + gap;
} else {
x = pos === 'left' ? r.left - t.width - gap : r.right + gap;
y = r.top + r.height / 2 - t.height / 2;
}
const clampedX = Math.max(8, Math.min(x, innerWidth - t.width - 8)); // keep it inside the window
tip.style.setProperty('--arrow', (r.left + r.width / 2 - clampedX) + 'px');
tip.style.left = clampedX + 'px';
tip.style.top = Math.max(8, y) + 'px';
tip.dataset.pos = pos;
}
function hide() {
clearTimeout(timer);
if (target) target.removeAttribute('aria-describedby');
target = null; tip.hidden = true;
}
document.addEventListener('mouseover', (e) => {
const el = e.target.closest('[data-tip]');
if (el === target) return;
hide();
if (el) timer = setTimeout(() => show(el), 280);
});
document.addEventListener('focusin', (e) => { const el = e.target.closest('[data-tip]'); hide(); if (el && el.matches(':focus-visible')) show(el); });
document.addEventListener('focusout', hide);
document.addEventListener('pointerdown', hide);
addEventListener('scroll', hide, { passive: true, capture: true });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
return { hide };
})();