/* Shortcut tooltips: the core .tt tooltip with <kbd> keys. The button is named by the label part and described by the keys. */
.tt{--tt-bg:#1f1933;--tt-ink:#f7f5ff;--tt-gap:10px;--tt-arrow:6px;position:relative;display:inline-flex;vertical-align:middle}
.tt-tip{position:absolute;z-index:30;display:flex;align-items:center;gap:10px;width:max-content;max-width:260px;padding:6px 7px 6px 11px;border-radius:9px;background:var(--tt-bg);color:var(--tt-ink);
font:500 .8125rem/1.4 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;white-space:nowrap;box-shadow:0 10px 24px -10px rgba(31,25,51,.6);
visibility:hidden;opacity:0;scale:0;transition:opacity .15s,visibility .15s,scale 0s .15s}
.tt:is(:hover,:focus-within,.is-open):not(.is-dismissed)>.tt-tip{visibility:visible;opacity:1;scale:1;transition:opacity .15s,visibility 0s,scale 0s}
.tt-tip::before{content:"";position:absolute;border:var(--tt-arrow) solid transparent}
.tt-tip::after{content:"";position:absolute}
.tt-bottom{top:calc(100% + var(--tt-gap));left:50%;translate:calc(-50% + var(--tt-nudge,0px)) 0}
.tt-bottom::before{bottom:100%;left:calc(50% - var(--tt-arrow) - var(--tt-nudge,0px));border-bottom-color:var(--tt-bg);border-top-width:0}
.tt-bottom::after{bottom:100%;left:0;right:0;height:var(--tt-gap)}
.keys{display:inline-flex;gap:3px}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.keys kbd{display:inline-grid;place-items:center;min-width:22px;height:22px;padding:0 5px;border:1px solid #4a4266;border-bottom-width:2px;border-radius:5px;background:#2c2545;color:#ece9f8;
font:600 .75rem/1 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
.tools{display:inline-flex;gap:6px;padding:6px;border:1px solid #e2ddef;border-radius:12px;background:#faf9fd}
.ib{display:grid;place-items:center;width:38px;height:38px;padding:0;border:1px solid transparent;border-radius:9px;background:none;color:#463e60;cursor:pointer;transition:background-color .15s}
.ib:hover{background:#efebfa;color:#1f1933}
.ib svg{width:18px;height:18px}
/* Coach marks: one step of an onboarding tour at a time, pointing at the control it explains */
.cm-at{position:relative;display:inline-flex}
.cm-at::after{content:"";position:absolute;inset:-5px;border:2px solid #7c3aed;border-radius:12px;opacity:0;pointer-events:none}
.cm-at.is-current::after{opacity:1;animation:cm-pulse 1.8s ease-out infinite}
@keyframes cm-pulse{0%{box-shadow:0 0 0 0 rgba(124,58,237,.45)}70%,100%{box-shadow:0 0 0 10px rgba(124,58,237,0)}}
.cm{position:absolute;z-index:40;top:calc(100% + 16px);left:50%;translate:calc(-50% + var(--tt-nudge,0px)) 0;width:290px;max-width:calc(100vw - 32px);padding:16px 16px 14px;
border-radius:14px;background:#2e1065;color:#f5f3ff;text-align:left;font-size:.875rem;line-height:1.5;box-shadow:0 24px 48px -16px rgba(46,16,101,.55);animation:cm-in .2s ease-out}
.cm[hidden]{display:none}
.cm::before{content:"";position:absolute;bottom:100%;left:calc(50% - 8px - var(--tt-nudge,0px));border:8px solid transparent;border-top-width:0;border-bottom-color:#2e1065}
.cm:focus-visible{outline:2px solid #a78bfa;outline-offset:3px}
@keyframes cm-in{from{opacity:0;translate:calc(-50% + var(--tt-nudge,0px)) 6px}}
.cm small{display:block;color:#c4b5fd;font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase}
.cm h3{margin:4px 0 4px;font-size:1rem;color:#fff}
.cm p{margin:0;color:#ddd6fe}
.cm kbd{padding:1px 5px;border:1px solid #6d4fc4;border-radius:4px;background:#3b1a80;color:#fff;font:600 .75rem ui-sans-serif,system-ui,sans-serif}
.cm-foot{display:flex;align-items:center;gap:8px;margin-top:14px}
.cm-dots{display:flex;gap:5px;margin-right:auto}
.cm-dots i{width:7px;height:7px;border-radius:50%;background:#6d4fc4}
.cm-dots i.on{width:18px;border-radius:4px;background:#ddd6fe}
.cm button{height:32px;padding:0 12px;border:0;border-radius:8px;font:600 .8125rem/1 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;cursor:pointer}
.cm .ghost{background:none;color:#ddd6fe}
.cm .ghost:hover{background:#3b1a80;color:#fff}
.cm .go{background:#fff;color:#4c1d95}
.cm .go:hover{background:#ede9fe}
/* Demo app bar */
.app{border:1px solid #e2ddef;border-radius:14px;background:#faf9fd}
.app-bar{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid #e2ddef;background:#fff;border-radius:14px 14px 0 0}
.logo{display:flex;align-items:center;gap:8px;margin-right:auto;font-weight:800;letter-spacing:-.01em}
.logo i{width:26px;height:26px;border-radius:8px;background:linear-gradient(135deg,#7c3aed,#c026d3)}
.btn{display:inline-flex;align-items:center;gap:8px;height:36px;padding:0 13px;border:1px solid #d9d2ec;border-radius:9px;background:#fff;color:#1f1933;
font:600 .8125rem/1 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;cursor:pointer}
.btn:hover{background:#f4f1fb}
.btn.solid{background:#6d28d9;border-color:#6d28d9;color:#fff}
.btn.solid:hover{background:#5b21b6}
.btn svg{width:16px;height:16px}
.avatar{display:grid;place-items:center;width:36px;height:36px;padding:0;border:0;border-radius:50%;background:#fde68a;color:#713f12;font-weight:700;font-size:.8rem;cursor:pointer}
.empty{padding:196px 20px 28px;text-align:center;color:#5d5675;font-size:.875rem}
.empty b{display:block;color:#1f1933;font-size:.95rem}
.empty .btn{margin-top:12px}
:is(.ib,.btn,.avatar,.cm button):focus-visible{outline:2px solid #6d28d9;outline-offset:2px}
.cm button:focus-visible{outline-color:#c4b5fd}
@media (max-width:480px){.logo span{display:none}}
@media (prefers-reduced-motion:reduce){.tt .tt-tip,.tt:is(:hover,:focus-within,.is-open):not(.is-dismissed)>.tt-tip,.ib{transition:none}.cm,.cm-at.is-current::after{animation:none}}
JavaScript
(() => {
// Keeps a tooltip or coach mark inside the window by nudging it sideways (the arrow stays on its target).
const fit = tip => {
tip.style.removeProperty('--tt-nudge');
requestAnimationFrame(() => {
const r = tip.getBoundingClientRect(), w = document.documentElement.clientWidth;
const d = r.left < 8 ? 8 - r.left : r.right > w - 8 ? w - 8 - r.right : 0;
if (d) tip.style.setProperty('--tt-nudge', d + 'px');
});
};
// Tooltips (WCAG 1.4.13): Esc dismisses without moving focus, a tap opens one on touch screens, one open at a time.
const tts = [...document.querySelectorAll('.tt')];
const enter = t => { tts.forEach(o => o !== t && o.classList.remove('is-open')); t.classList.remove('is-dismissed'); fit(t.querySelector('[role="tooltip"]')); };
tts.forEach(t => { t.addEventListener('pointerenter', () => enter(t)); t.addEventListener('focusin', () => enter(t)); if (t.classList.contains('is-open')) fit(t.querySelector('[role="tooltip"]')); });
document.addEventListener('pointerdown', e => {
const t = e.target.closest('.tt');
if (!t) tts.forEach(o => o.classList.remove('is-open')); else if (e.pointerType === 'touch') t.classList.add('is-open');
});
// Coach-mark tour: one step at a time. Next and Back move between steps and focus the new one; Skip, Done or Esc end the tour.
const tour = document.querySelector('.tour');
const steps = tour ? [...tour.querySelectorAll('.cm')] : [];
const start = document.querySelector('[data-tour-start]');
let cur = -1;
const go = (i, focus = true) => {
steps.forEach((s, n) => { s.hidden = n !== i; s.parentElement.classList.toggle('is-current', n === i); });
cur = i;
if (start) start.setAttribute('aria-expanded', i >= 0);
if (i >= 0) { fit(steps[i]); if (focus) steps[i].focus(); }
};
const end = () => { const inside = cur >= 0 && steps[cur].contains(document.activeElement); go(-1); if (inside && start) start.focus(); };
if (tour) tour.addEventListener('click', e => {
const b = e.target.closest('[data-step]'); if (!b) return;
if (b.dataset.step === 'next') cur < steps.length - 1 ? go(cur + 1) : end();
else if (b.dataset.step === 'back') go(cur - 1);
else end();
});
if (start) start.addEventListener('click', () => go(0));
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (cur >= 0) { end(); return; }
tts.filter(t => t.matches(':hover, :focus-within, .is-open')).forEach(t => { t.classList.remove('is-open'); t.classList.add('is-dismissed'); });
});
})();
@theme {
--color-tt: #1f1933;
--color-tt-ink: #f7f5ff;
}
/* tt-open: a tooltip inside a .tt wrapper that is hovered, holds focus or was opened by tap, and was not dismissed with Esc. */
@custom-variant tt-open (&:is(:where(.tt):is(:hover, :focus-within, .is-open):not(.is-dismissed) > *));
@keyframes cm-pulse { 0% { box-shadow: 0 0 0 0 rgba(124,58,237,.45) } 70%, 100% { box-shadow: 0 0 0 10px rgba(124,58,237,0) } }
@keyframes cm-in { from { opacity: 0; translate: calc(-50% + var(--tt-nudge, 0px)) 6px } }
JavaScript
(() => {
// Keeps a tooltip or coach mark inside the window by nudging it sideways (the arrow stays on its target).
const fit = tip => {
tip.style.removeProperty('--tt-nudge');
requestAnimationFrame(() => {
const r = tip.getBoundingClientRect(), w = document.documentElement.clientWidth;
const d = r.left < 8 ? 8 - r.left : r.right > w - 8 ? w - 8 - r.right : 0;
if (d) tip.style.setProperty('--tt-nudge', d + 'px');
});
};
// Tooltips (WCAG 1.4.13): Esc dismisses without moving focus, a tap opens one on touch screens, one open at a time.
const tts = [...document.querySelectorAll('.tt')];
const enter = t => { tts.forEach(o => o !== t && o.classList.remove('is-open')); t.classList.remove('is-dismissed'); fit(t.querySelector('[role="tooltip"]')); };
tts.forEach(t => { t.addEventListener('pointerenter', () => enter(t)); t.addEventListener('focusin', () => enter(t)); if (t.classList.contains('is-open')) fit(t.querySelector('[role="tooltip"]')); });
document.addEventListener('pointerdown', e => {
const t = e.target.closest('.tt');
if (!t) tts.forEach(o => o.classList.remove('is-open')); else if (e.pointerType === 'touch') t.classList.add('is-open');
});
// Coach-mark tour: one step at a time. Next and Back move between steps and focus the new one; Skip, Done or Esc end the tour.
const tour = document.querySelector('.tour');
const steps = tour ? [...tour.querySelectorAll('.cm')] : [];
const start = document.querySelector('[data-tour-start]');
let cur = -1;
const go = (i, focus = true) => {
steps.forEach((s, n) => { s.hidden = n !== i; s.parentElement.classList.toggle('is-current', n === i); });
cur = i;
if (start) start.setAttribute('aria-expanded', i >= 0);
if (i >= 0) { fit(steps[i]); if (focus) steps[i].focus(); }
};
const end = () => { const inside = cur >= 0 && steps[cur].contains(document.activeElement); go(-1); if (inside && start) start.focus(); };
if (tour) tour.addEventListener('click', e => {
const b = e.target.closest('[data-step]'); if (!b) return;
if (b.dataset.step === 'next') cur < steps.length - 1 ? go(cur + 1) : end();
else if (b.dataset.step === 'back') go(cur - 1);
else end();
});
if (start) start.addEventListener('click', () => go(0));
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (cur >= 0) { end(); return; }
tts.filter(t => t.matches(':hover, :focus-within, .is-open')).forEach(t => { t.classList.remove('is-open'); t.classList.add('is-dismissed'); });
});
})();
About this set
Tooltips that teach. Tooltips with keyboard shortcuts gives an editing toolbar of Undo, Redo, Find in document and Add comment buttons, each with a dark tooltip that shows the command name and its keys as raised keycaps, such as Option Command M. The button is named by the label part of the tooltip and described by the keys part, whose symbols carry visually hidden words like Command and Shift, and aria-keyshortcuts lists the shortcut. Onboarding coach marks is a three step tour of an invoicing app called Ledgerly: Find anything points at search, Send your first invoice points at the New invoice button, and Add your bank details points at the account menu. Each step is a small non-modal dialog with a step count, progress dots and Skip, Back, Next or Done buttons, and a pulsing ring marks the control it explains. Next and Back move focus into the new step, and Skip, Done or Esc end the tour, returning focus to the Take the tour button if it was inside the step. The Bootstrap version builds the shortcut tooltips with Bootstrap Tooltip and each tour step with Bootstrap Popover.
What’s included
Shortcut keys drawn as keycaps inside the tooltip
Keys described in words for screen readers, plus aria-keyshortcuts
Three-step coach mark tour with progress dots
Pulsing ring on the control each step explains
Focus moves with the tour and returns when it ends
Bootstrap Tooltip and Popover in the Bootstrap version
Accessibility
Shortcut buttons take their name from the tooltip label and their description from the keys, so screen readers hear Undo, then Command Z. Each coach mark is a dialog labelled by its heading and described by its text; Next and Back focus the new step, and Esc, Skip or Done close the tour and, when focus was in the step, return it to the start button. Every control has a visible focus outline, and the pulse and entrance animation stop under prefers-reduced-motion.
Customise it
The tooltip uses --tt-bg and --tt-ink and the keycaps use the .keys kbd colours; the coach mark colours are on .cm. In Bootstrap edit the --bs-tooltip-* variables on .tooltip.tt and the --bs-popover-* variables on .popover.cm. In Tailwind change the theme colours and the hex utilities on the coach marks.