Onboarding — Contextual hint tooltips / beacons
A self-contained onboarding layer that scatters pulsing beacon dots over a realistic product workspace — search, new-task, notifications, invite, and home. Tapping a beacon opens a positioned popover with a step counter, a short tip, and a Got it action that dismisses just that beacon, while a live aria-live counter tracks how many hints have been seen. A segmented switcher toggles beacon style between pulsing dots and static question-mark badges and flips open behaviour between click and hover, and a Reset hints button restores every beacon.
MCP
コード
:root {
--brand: #5b5bf0;
--brand-d: #4646d6;
--brand-700: #3a3ab8;
--brand-50: #eef0ff;
--accent: #00b4a6;
--accent-soft: #d8f5f2;
--ink: #101322;
--ink-2: #3a4060;
--muted: #6c7393;
--bg: #f6f7fb;
--white: #ffffff;
--surface: #ffffff;
--line: rgba(16, 19, 34, 0.1);
--line-2: rgba(16, 19, 34, 0.16);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(16, 19, 34, 0.08);
--sh-2: 0 8px 24px rgba(16, 19, 34, 0.08);
--sh-3: 0 16px 44px rgba(16, 19, 34, 0.16);
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.page {
max-width: 920px;
margin: 0 auto;
padding: 40px 24px 56px;
}
/* ---------- header ---------- */
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 22px;
}
.page-head__title {
display: flex;
gap: 14px;
align-items: flex-start;
}
.logo {
flex: none;
width: 42px;
height: 42px;
border-radius: var(--r-md);
display: grid;
place-items: center;
color: var(--white);
background: linear-gradient(135deg, var(--brand), var(--brand-700));
box-shadow: var(--sh-2);
}
.page-head h1 {
margin: 0;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.02em;
}
.page-head__sub {
margin: 4px 0 0;
max-width: 46ch;
font-size: 14px;
color: var(--muted);
}
/* ---------- buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
gap: 7px;
border: 1px solid transparent;
border-radius: var(--r-sm);
padding: 9px 14px;
font: inherit;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: background 0.16s, border-color 0.16s, transform 0.06s, box-shadow 0.16s;
}
.btn:active {
transform: translateY(1px);
}
.btn--sm {
padding: 7px 12px;
font-size: 13px;
}
.btn--primary {
background: var(--brand);
color: var(--white);
box-shadow: var(--sh-1);
}
.btn--primary:hover {
background: var(--brand-d);
}
.btn--ghost {
background: var(--white);
color: var(--ink-2);
border-color: var(--line-2);
box-shadow: var(--sh-1);
}
.btn--ghost:hover {
background: var(--brand-50);
color: var(--brand-700);
border-color: var(--brand);
}
:focus-visible {
outline: 3px solid rgba(91, 91, 240, 0.4);
outline-offset: 2px;
}
/* ---------- controls bar ---------- */
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px;
padding: 14px 16px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--sh-1);
margin-bottom: 22px;
}
.segmented {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--r-sm);
}
.segmented__label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
padding: 0 8px 0 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.seg {
border: 0;
background: transparent;
font: inherit;
font-weight: 600;
font-size: 13px;
color: var(--ink-2);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.16s, color 0.16s, box-shadow 0.16s;
}
.seg:hover {
color: var(--brand-700);
}
.seg[aria-checked="true"] {
background: var(--white);
color: var(--brand-700);
box-shadow: var(--sh-1);
}
.counter {
margin-left: auto;
display: inline-flex;
align-items: baseline;
gap: 6px;
font-size: 13px;
color: var(--muted);
}
.counter__num {
font-size: 18px;
font-weight: 800;
color: var(--brand);
font-variant-numeric: tabular-nums;
}
/* ---------- workspace mock ---------- */
.workspace {
display: grid;
grid-template-columns: 184px 1fr;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
overflow: visible;
}
.ws-sidebar {
border-right: 1px solid var(--line);
padding: 18px 14px;
display: flex;
flex-direction: column;
gap: 6px;
background: linear-gradient(180deg, #fbfbff, var(--surface));
border-radius: var(--r-lg) 0 0 var(--r-lg);
}
.ws-brand {
width: 34px;
height: 34px;
border-radius: 9px;
background: var(--ink);
color: var(--white);
display: grid;
place-items: center;
font-weight: 800;
font-size: 14px;
margin-bottom: 10px;
}
.ws-nav {
display: flex;
flex-direction: column;
gap: 3px;
}
.ws-nav__item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 11px;
border-radius: var(--r-sm);
font-size: 14px;
font-weight: 600;
color: var(--ink-2);
text-decoration: none;
border: 0;
background: transparent;
font-family: inherit;
cursor: pointer;
transition: background 0.16s, color 0.16s;
}
.ws-nav__item:hover {
background: var(--brand-50);
color: var(--brand-700);
}
.ws-nav__item.is-active {
background: var(--brand-50);
color: var(--brand-700);
}
.ws-nav__cta {
margin-top: auto;
justify-content: flex-start;
background: var(--accent-soft);
color: #0a7c72;
}
.ws-nav__cta:hover {
background: #c4efeb;
color: #0a7c72;
}
.ws-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.ws-topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--line);
}
.ws-search {
flex: 1;
display: flex;
align-items: center;
gap: 9px;
padding: 0 12px;
height: 40px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--r-sm);
color: var(--muted);
min-width: 0;
}
.ws-search input {
flex: 1;
border: 0;
background: transparent;
font: inherit;
font-size: 14px;
color: var(--ink);
outline: none;
min-width: 0;
}
.ws-search input::placeholder {
color: var(--muted);
}
.ws-search kbd {
font: inherit;
font-size: 11px;
font-weight: 600;
color: var(--muted);
background: var(--white);
border: 1px solid var(--line-2);
border-radius: 5px;
padding: 2px 6px;
}
.ws-topbar__right {
display: flex;
align-items: center;
gap: 10px;
}
.ws-icon-btn {
position: relative;
width: 40px;
height: 40px;
display: grid;
place-items: center;
border: 1px solid var(--line);
background: var(--white);
border-radius: var(--r-sm);
color: var(--ink-2);
cursor: pointer;
transition: background 0.16s, color 0.16s;
}
.ws-icon-btn:hover {
background: var(--brand-50);
color: var(--brand-700);
}
.ws-badge {
position: absolute;
top: -5px;
right: -5px;
min-width: 17px;
height: 17px;
padding: 0 4px;
border-radius: 9px;
background: var(--danger);
color: var(--white);
font-size: 10px;
font-weight: 700;
display: grid;
place-items: center;
border: 2px solid var(--white);
}
.ws-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #8a8aff, var(--brand-700));
color: var(--white);
display: grid;
place-items: center;
font-weight: 700;
font-size: 13px;
}
.ws-board {
padding: 20px 18px 24px;
}
.ws-board__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.ws-board h2 {
margin: 0;
font-size: 17px;
font-weight: 700;
letter-spacing: -0.01em;
}
.ws-board__meta {
margin: 2px 0 0;
font-size: 12.5px;
color: var(--muted);
}
.ws-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.ws-row {
display: flex;
align-items: center;
gap: 11px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
font-size: 14px;
font-weight: 500;
background: var(--white);
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--line-2);
flex: none;
}
.dot--ok {
background: var(--ok);
}
.dot--warn {
background: var(--warn);
}
.pill {
margin-left: auto;
font-size: 11.5px;
font-weight: 600;
padding: 3px 9px;
border-radius: 999px;
background: rgba(47, 158, 111, 0.12);
color: var(--ok);
}
.pill--warn {
background: rgba(217, 138, 43, 0.14);
color: var(--warn);
}
.pill--muted {
background: var(--bg);
color: var(--muted);
}
.footnote {
margin: 18px 2px 0;
font-size: 12px;
color: var(--muted);
}
/* ---------- beacons ---------- */
[data-hint-anchor] {
position: relative;
}
.beacon {
position: absolute;
z-index: 5;
width: 22px;
height: 22px;
border: 0;
padding: 0;
cursor: pointer;
background: transparent;
display: grid;
place-items: center;
/* default anchor: top-right corner */
top: -9px;
right: -9px;
}
.beacon[data-pos="inline-end"] {
top: 50%;
right: 10px;
transform: translateY(-50%);
}
.beacon[data-pos="cta"] {
top: -8px;
right: 8px;
}
.beacon__core {
position: relative;
z-index: 2;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--brand);
box-shadow: 0 0 0 3px var(--white), var(--sh-1);
display: grid;
place-items: center;
color: var(--white);
font-size: 10px;
font-weight: 800;
transition: transform 0.16s, background 0.16s;
}
.beacon:hover .beacon__core {
transform: scale(1.12);
background: var(--brand-d);
}
.beacon[aria-expanded="true"] .beacon__core {
background: var(--brand-700);
transform: scale(1.12);
}
/* pulsing ring (beacon style only) */
.beacon__pulse {
position: absolute;
inset: 4px;
border-radius: 50%;
background: var(--brand);
z-index: 1;
animation: beaconPulse 1.9s ease-out infinite;
}
@keyframes beaconPulse {
0% { transform: scale(0.9); opacity: 0.55; }
70% { transform: scale(2.6); opacity: 0; }
100% { transform: scale(2.6); opacity: 0; }
}
/* badge style: hide pulse, show "?" mark, neutral look */
.workspace[data-style="badge"] .beacon__pulse {
display: none;
}
.workspace[data-style="badge"] .beacon__core {
width: 18px;
height: 18px;
background: var(--white);
color: var(--brand);
border: 1.5px solid var(--brand);
box-shadow: 0 0 0 2px var(--white), var(--sh-1);
}
.workspace[data-style="badge"] .beacon:hover .beacon__core,
.workspace[data-style="badge"] .beacon[aria-expanded="true"] .beacon__core {
background: var(--brand);
color: var(--white);
}
.beacon__qmark {
display: none;
}
.workspace[data-style="badge"] .beacon__qmark {
display: block;
}
/* dismissed beacons disappear */
.beacon.is-dismissed {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.beacon__pulse {
animation: none;
opacity: 0.3;
}
}
/* ---------- popover ---------- */
.popover {
position: absolute;
z-index: 50;
width: 268px;
max-width: calc(100vw - 24px);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--sh-3);
opacity: 0;
transform: translateY(4px) scale(0.98);
transition: opacity 0.14s ease, transform 0.14s ease;
}
.popover.is-open {
opacity: 1;
transform: translateY(0) scale(1);
}
.popover__arrow {
position: absolute;
width: 12px;
height: 12px;
background: var(--surface);
border: 1px solid var(--line);
transform: rotate(45deg);
}
.popover[data-side="bottom"] .popover__arrow {
top: -7px;
border-right: 0;
border-bottom: 0;
}
.popover[data-side="top"] .popover__arrow {
bottom: -7px;
border-left: 0;
border-top: 0;
}
.popover__body {
position: relative;
z-index: 1;
padding: 16px 16px 14px;
}
.popover__step {
display: inline-block;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--accent);
background: var(--accent-soft);
padding: 3px 8px;
border-radius: 999px;
margin-bottom: 9px;
}
.popover h3 {
margin: 0 0 5px;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
}
.popover p {
margin: 0;
font-size: 13.5px;
color: var(--ink-2);
}
.popover__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 14px;
}
.popover__skip {
border: 0;
background: transparent;
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--muted);
cursor: pointer;
padding: 4px 2px;
border-radius: 6px;
}
.popover__skip:hover {
color: var(--danger);
}
/* ---------- toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%) translateY(12px);
background: var(--ink);
color: var(--white);
font-size: 13.5px;
font-weight: 600;
padding: 11px 18px;
border-radius: 999px;
box-shadow: var(--sh-3);
opacity: 0;
transition: opacity 0.2s, transform 0.2s;
z-index: 60;
pointer-events: none;
}
.toast.is-show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ---------- responsive ---------- */
@media (max-width: 520px) {
.page {
padding: 26px 14px 44px;
}
.page-head {
flex-direction: column;
align-items: stretch;
}
.controls {
align-items: stretch;
}
.segmented {
flex-wrap: wrap;
}
.counter {
margin-left: 0;
}
.workspace {
grid-template-columns: 1fr;
}
.ws-sidebar {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
border-right: 0;
border-bottom: 1px solid var(--line);
border-radius: var(--r-lg) var(--r-lg) 0 0;
}
.ws-brand {
margin-bottom: 0;
}
.ws-nav {
flex-direction: row;
flex: 1;
flex-wrap: wrap;
}
.ws-nav__cta {
margin-top: 0;
width: 100%;
}
.ws-search kbd {
display: none;
}
.ws-board__head {
flex-direction: column;
align-items: flex-start;
}
.popover {
width: 240px;
}
}(function () {
"use strict";
// --- hint definitions, each anchored to a control in the workspace mock ---
var HINTS = [
{
id: "search",
pos: "inline-end",
title: "Universal search",
tip: "Jump to any project, person, or file. Press ⌘K from anywhere to open it instantly.",
},
{
id: "newtask",
pos: "default",
title: "Create a task",
tip: "Add work to this board. Tasks inherit the board owner and current sprint automatically.",
},
{
id: "notify",
pos: "default",
title: "Notifications",
tip: "Mentions, assignments, and review requests collect here. The badge shows unread items.",
},
{
id: "invite",
pos: "cta",
title: "Invite your team",
tip: "Bring teammates in to co-own boards and get assigned tasks. Up to 10 seats on the free plan.",
},
{
id: "nav",
pos: "inline-end",
title: "Workspace home",
tip: "Your overview — recent boards, activity, and what needs your attention today.",
},
];
var workspace = document.querySelector(".workspace");
var popover = document.getElementById("popover");
var popTitle = document.getElementById("popTitle");
var popDesc = document.getElementById("popDesc");
var popStep = document.getElementById("popStep");
var popGot = document.getElementById("popGot");
var popSkip = document.getElementById("popSkip");
var seenCountEl = document.getElementById("seenCount");
var totalCountEl = document.getElementById("totalCount");
var resetBtn = document.getElementById("resetBtn");
var toastEl = document.getElementById("toast");
var openMode = "click"; // or "hover"
var dismissed = Object.create(null);
var activeBeacon = null;
var hoverTimer = null;
totalCountEl.textContent = String(HINTS.length);
// --- toast helper ---
var toastTimer = null;
function toast(msg) {
toastEl.textContent = msg;
toastEl.hidden = false;
// force reflow so transition runs
void toastEl.offsetWidth;
toastEl.classList.add("is-show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("is-show");
setTimeout(function () {
toastEl.hidden = true;
}, 220);
}, 2200);
}
// --- build a beacon for each hint ---
function buildBeacons() {
HINTS.forEach(function (hint, i) {
var anchor = workspace.querySelector('[data-hint-anchor="' + hint.id + '"]');
if (!anchor) return;
var btn = document.createElement("button");
btn.type = "button";
btn.className = "beacon";
btn.setAttribute("data-pos", hint.pos);
btn.setAttribute("data-beacon", hint.id);
btn.setAttribute("aria-expanded", "false");
btn.setAttribute("aria-haspopup", "dialog");
btn.setAttribute("aria-label", "Hint: " + hint.title);
btn.innerHTML =
'<span class="beacon__pulse"></span>' +
'<span class="beacon__core"><span class="beacon__qmark">?</span></span>';
btn._hint = hint;
btn._index = i;
anchor.appendChild(btn);
btn.addEventListener("click", function (e) {
e.preventDefault();
e.stopPropagation();
if (activeBeacon === btn) {
closePopover();
} else {
openPopover(btn);
}
});
btn.addEventListener("mouseenter", function () {
if (openMode !== "hover") return;
clearTimeout(hoverTimer);
openPopover(btn);
});
btn.addEventListener("mouseleave", function () {
if (openMode !== "hover") return;
scheduleHoverClose();
});
});
}
function scheduleHoverClose() {
clearTimeout(hoverTimer);
hoverTimer = setTimeout(closePopover, 220);
}
// keep popover open while hovering it (hover mode)
popover.addEventListener("mouseenter", function () {
if (openMode === "hover") clearTimeout(hoverTimer);
});
popover.addEventListener("mouseleave", function () {
if (openMode === "hover") scheduleHoverClose();
});
// --- positioning relative to anchor ---
function positionPopover(beacon) {
// measure beacon center in document coords
var r = beacon.getBoundingClientRect();
var scrollX = window.pageXOffset;
var scrollY = window.pageYOffset;
var centerX = r.left + r.width / 2 + scrollX;
var anchorBottom = r.bottom + scrollY;
var anchorTop = r.top + scrollY;
// make it measurable
popover.hidden = false;
var pw = popover.offsetWidth;
var ph = popover.offsetHeight;
var gap = 12;
var margin = 10;
// decide side: prefer below, flip above if not enough room
var spaceBelow = window.innerHeight - r.bottom;
var side = spaceBelow > ph + gap + 16 || r.top < ph + gap ? "bottom" : "top";
var top = side === "bottom" ? anchorBottom + gap : anchorTop - ph - gap;
// horizontally center, then clamp into viewport
var left = centerX - pw / 2;
var minLeft = scrollX + margin;
var maxLeft = scrollX + window.innerWidth - pw - margin;
if (left < minLeft) left = minLeft;
if (left > maxLeft) left = maxLeft;
popover.style.top = top + "px";
popover.style.left = left + "px";
popover.setAttribute("data-side", side);
// place arrow under/over the beacon center
var arrow = popover.querySelector(".popover__arrow");
var arrowLeft = centerX - left - 6;
arrowLeft = Math.max(14, Math.min(pw - 26, arrowLeft));
arrow.style.left = arrowLeft + "px";
}
function openPopover(beacon) {
if (activeBeacon && activeBeacon !== beacon) {
activeBeacon.setAttribute("aria-expanded", "false");
}
activeBeacon = beacon;
var hint = beacon._hint;
popStep.textContent = "Hint " + (beacon._index + 1) + " / " + HINTS.length;
popTitle.textContent = hint.title;
popDesc.textContent = hint.tip;
beacon.setAttribute("aria-expanded", "true");
positionPopover(beacon);
// animate in
requestAnimationFrame(function () {
popover.classList.add("is-open");
});
popGot.focus({ preventScroll: true });
}
function closePopover() {
if (!activeBeacon) return;
activeBeacon.setAttribute("aria-expanded", "false");
popover.classList.remove("is-open");
var beacon = activeBeacon;
activeBeacon = null;
setTimeout(function () {
if (!activeBeacon) popover.hidden = true;
}, 150);
return beacon;
}
function updateCount() {
var n = Object.keys(dismissed).length;
seenCountEl.textContent = String(n);
}
function dismissActive() {
if (!activeBeacon) return;
var hint = activeBeacon._hint;
activeBeacon.classList.add("is-dismissed");
if (!dismissed[hint.id]) {
dismissed[hint.id] = true;
updateCount();
}
closePopover();
if (Object.keys(dismissed).length === HINTS.length) {
toast("All hints seen — nice! ✨");
} else {
toast("“" + hint.title + "” dismissed");
}
}
// --- got it / skip / reset ---
popGot.addEventListener("click", dismissActive);
popSkip.addEventListener("click", function () {
closePopover();
var added = 0;
HINTS.forEach(function (hint) {
var b = workspace.querySelector('[data-beacon="' + hint.id + '"]');
if (b && !b.classList.contains("is-dismissed")) {
b.classList.add("is-dismissed");
dismissed[hint.id] = true;
added++;
}
});
updateCount();
if (added) toast("Skipped all remaining hints");
});
resetBtn.addEventListener("click", function () {
closePopover();
dismissed = Object.create(null);
var all = workspace.querySelectorAll(".beacon");
all.forEach(function (b) {
b.classList.remove("is-dismissed");
b.setAttribute("aria-expanded", "false");
});
updateCount();
toast("Hints reset — all beacons restored");
});
// --- close on Esc / outside click ---
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && activeBeacon) {
var b = closePopover();
if (b) b.focus({ preventScroll: true });
}
});
document.addEventListener("click", function (e) {
if (!activeBeacon) return;
if (popover.contains(e.target) || e.target.closest(".beacon")) return;
closePopover();
});
// reposition while open if layout shifts
window.addEventListener("resize", function () {
if (activeBeacon) positionPopover(activeBeacon);
});
window.addEventListener(
"scroll",
function () {
if (activeBeacon) positionPopover(activeBeacon);
},
true
);
// --- variant switchers ---
function wireSegment(group, attr, onChange) {
var btns = Array.prototype.slice.call(group.querySelectorAll(".seg"));
btns.forEach(function (btn) {
btn.addEventListener("click", function () {
btns.forEach(function (b) {
b.setAttribute("aria-checked", "false");
});
btn.setAttribute("aria-checked", "true");
onChange(btn.getAttribute(attr));
});
});
}
var styleGroup = document.querySelector('[aria-label="Beacon style"]');
var openGroup = document.querySelector('[aria-label="Open behaviour"]');
wireSegment(styleGroup, "data-style", function (val) {
workspace.setAttribute("data-style", val);
});
wireSegment(openGroup, "data-open", function (val) {
openMode = val;
closePopover();
});
// --- init ---
workspace.setAttribute("data-style", "beacon");
buildBeacons();
updateCount();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Onboarding — Contextual hint beacons</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<header class="page-head">
<div class="page-head__title">
<span class="logo" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none"><path d="M12 2.5 14.9 8.5 21.5 9.4 16.7 14 17.9 20.6 12 17.5 6.1 20.6 7.3 14 2.5 9.4 9.1 8.5Z" fill="currentColor"/></svg>
</span>
<div>
<h1>Welcome to Northwind</h1>
<p class="page-head__sub">We sprinkled a few hints around the workspace. Tap a glowing dot to learn what each control does.</p>
</div>
</div>
<button class="btn btn--ghost" id="resetBtn" type="button">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true"><path d="M4 4v6h6M20 20v-6h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.5 14a7 7 0 0 0 12.4 2.5M18.5 10A7 7 0 0 0 6.1 7.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
Reset hints
</button>
</header>
<section class="controls" aria-label="Hint demo settings">
<div class="segmented" role="radiogroup" aria-label="Beacon style">
<span class="segmented__label">Style</span>
<button class="seg" role="radio" aria-checked="true" data-style="beacon" type="button">Pulsing beacon</button>
<button class="seg" role="radio" aria-checked="false" data-style="badge" type="button">Static “?” badge</button>
</div>
<div class="segmented" role="radiogroup" aria-label="Open behaviour">
<span class="segmented__label">Open on</span>
<button class="seg" role="radio" aria-checked="true" data-open="click" type="button">Click</button>
<button class="seg" role="radio" aria-checked="false" data-open="hover" type="button">Hover</button>
</div>
<div class="counter" aria-live="polite">
<span class="counter__num" id="seenCount">0</span>
<span class="counter__label">of <span id="totalCount">5</span> hints seen</span>
</div>
</section>
<!-- Mock product surface that the beacons annotate -->
<section class="workspace" aria-label="Product workspace preview">
<aside class="ws-sidebar">
<div class="ws-brand">NW</div>
<nav class="ws-nav" aria-label="Primary">
<a class="ws-nav__item is-active" href="#" data-hint-anchor="nav">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true"><path d="M4 10 12 4l8 6v9a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>
Home
</a>
<a class="ws-nav__item" href="#"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true"><rect x="4" y="4" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.8"/><rect x="13" y="4" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.8"/><rect x="4" y="13" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.8"/><rect x="13" y="13" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.8"/></svg> Boards</a>
<a class="ws-nav__item" href="#"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true"><path d="M4 18V8m5 10V5m5 13v-7m5 7V9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg> Insights</a>
</nav>
<button class="ws-nav__item ws-nav__cta" type="button" data-hint-anchor="invite">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true"><path d="M16 14a4 4 0 1 0-4-4M5 20a6 6 0 0 1 12 0M19 11h3m-1.5-1.5v3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
Invite team
</button>
</aside>
<div class="ws-main">
<div class="ws-topbar">
<div class="ws-search" data-hint-anchor="search">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" aria-hidden="true"><circle cx="11" cy="11" r="6" stroke="currentColor" stroke-width="1.8"/><path d="m20 20-3.2-3.2" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
<input type="text" placeholder="Search projects, people, files…" aria-label="Search" />
<kbd>⌘K</kbd>
</div>
<div class="ws-topbar__right">
<button class="ws-icon-btn" type="button" data-hint-anchor="notify" aria-label="Notifications">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" aria-hidden="true"><path d="M6 16V10a6 6 0 0 1 12 0v6l1.5 2H4.5L6 16Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M10 21h4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
<span class="ws-badge">3</span>
</button>
<div class="ws-avatar" aria-hidden="true">RA</div>
</div>
</div>
<div class="ws-board">
<div class="ws-board__head">
<div>
<h2>Q3 Launch Plan</h2>
<p class="ws-board__meta">Owned by Rena Adeyemi · Updated 4m ago</p>
</div>
<button class="btn btn--primary" type="button" data-hint-anchor="newtask">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
New task
</button>
</div>
<ul class="ws-list">
<li class="ws-row"><span class="dot dot--ok"></span> Finalize pricing tiers <span class="pill">Done</span></li>
<li class="ws-row"><span class="dot dot--warn"></span> Draft launch email <span class="pill pill--warn">In review</span></li>
<li class="ws-row"><span class="dot"></span> Schedule press briefing <span class="pill pill--muted">To do</span></li>
</ul>
</div>
</div>
</section>
<p class="footnote">Illustrative onboarding UI — fictional workspace, names, and data.</p>
</main>
<!-- single reusable popover -->
<div class="popover" id="popover" role="dialog" aria-modal="false" aria-labelledby="popTitle" hidden>
<span class="popover__arrow" aria-hidden="true"></span>
<div class="popover__body">
<span class="popover__step" id="popStep"></span>
<h3 id="popTitle"></h3>
<p id="popDesc"></p>
<div class="popover__foot">
<button class="popover__skip" id="popSkip" type="button">Skip all</button>
<button class="btn btn--primary btn--sm" id="popGot" type="button">Got it</button>
</div>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
<script src="script.js"></script>
</body>
</html>Contextual hint tooltips / beacons
A first-run onboarding layer painted over a faithful mock of the Northwind workspace — sidebar nav, universal search bar, notifications, an invite-team CTA, and a Q3 launch board. Five glowing beacon dots are anchored to the controls most worth explaining. Each beacon positions itself against its anchor, so the popover it opens flips above or below depending on available room and clamps its arrow to stay centred on the dot.
Click (or hover) a beacon to open a contextual popover with a Hint N / 5 step pill, a title, a one-line tip, a Skip all link, and a Got it button. Dismissing a hint hides only that beacon and bumps the aria-live counter in the controls bar; Skip all clears every remaining beacon at once, and Reset hints brings them all back. The popover closes on Escape — returning focus to its beacon — or on an outside click, and a small toast confirms each action.
Two variant switchers drive the demo live. The style toggle swaps the animated pulsing dot for a quieter static question-mark badge, and the open-on toggle flips the interaction between click and hover (hover keeps the popover alive while the pointer is over it). The whole layout collapses to a single column with a horizontal sidebar down to 360px, and respects prefers-reduced-motion by stilling the pulse.
Illustrative onboarding UI only — fictional workspace, names, and data.