Onboarding — Spotlight product tour (coachmarks)
A guided spotlight product tour layered over a faux Northwind workspace, where a dimmed overlay punches a cut-out highlight around the current target while a coachmark bubble carries a title, body copy, a 2 of 5 step counter, progress dots, and Back, Next, and Skip controls. The bubble auto-places toward whichever viewport edge has room, the highlight recomputes on resize and scroll, the final step clears everything, and live switches toggle spotlight cut-out versus a bordered ring and auto versus bottom placement.
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 18px 50px rgba(16, 19, 34, 0.22);
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
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;
padding: 28px 18px 110px;
min-height: 100vh;
}
button { font-family: inherit; cursor: pointer; }
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: 6px;
}
/* ===================== App shell ===================== */
.app {
max-width: 1080px;
margin: 0 auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
overflow: hidden;
}
.topbar {
display: flex;
align-items: center;
gap: 18px;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
background: var(--white);
}
.brand { display: flex; align-items: center; gap: 9px; }
.brand-mark { color: var(--brand); display: grid; place-items: center; }
.brand-name { font-weight: 800; letter-spacing: -0.02em; font-size: 16px; }
.search {
flex: 1;
max-width: 460px;
display: flex;
align-items: center;
gap: 9px;
padding: 8px 12px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--r-md);
color: var(--muted);
}
.search input {
border: 0;
background: transparent;
flex: 1;
font-size: 13.5px;
color: var(--ink);
outline: none;
}
.search input::placeholder { color: var(--muted); }
.search kbd {
font: inherit;
font-size: 11px;
font-weight: 600;
color: var(--muted);
background: var(--white);
border: 1px solid var(--line);
border-radius: 6px;
padding: 1px 6px;
}
.top-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.icon-btn {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13.5px;
font-weight: 600;
color: var(--white);
background: var(--brand);
border: 1px solid transparent;
border-radius: var(--r-sm);
padding: 8px 13px;
box-shadow: var(--sh-1);
transition: background .15s, transform .12s;
}
.icon-btn:hover { background: var(--brand-d); }
.icon-btn:active { transform: translateY(1px); }
.icon-btn.ghost {
position: relative;
background: var(--white);
color: var(--ink-2);
border-color: var(--line);
padding: 8px;
}
.icon-btn.ghost:hover { background: var(--bg); }
.icon-btn .dot {
position: absolute;
top: 6px; right: 6px;
width: 8px; height: 8px;
background: var(--danger);
border: 2px solid var(--white);
border-radius: 50%;
}
.avatar {
width: 34px; height: 34px;
border-radius: 50%;
border: 1px solid var(--line);
background: linear-gradient(135deg, var(--accent), var(--brand));
color: var(--white);
font-size: 12px;
font-weight: 700;
}
.body { display: grid; grid-template-columns: 224px 1fr; }
.sidebar {
border-right: 1px solid var(--line);
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 16px;
background: var(--white);
}
.sidebar nav { display: flex; flex-direction: column; gap: 2px; }
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 11px;
border-radius: var(--r-sm);
text-decoration: none;
color: var(--ink-2);
font-size: 13.5px;
font-weight: 500;
transition: background .14s, color .14s;
}
.nav-item:hover { background: var(--bg); color: var(--ink); }
.nav-item.active { background: var(--brand-50); color: var(--brand-700); font-weight: 600; }
.ni-ic { width: 18px; text-align: center; opacity: .8; }
.nav-item .pill {
margin-left: auto;
font-style: normal;
font-size: 11px;
font-weight: 700;
background: var(--ink);
color: var(--white);
border-radius: 999px;
padding: 1px 7px;
}
.nav-foot { margin-top: auto; }
.plan-card {
background: linear-gradient(160deg, var(--brand-50), var(--white));
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.plan-card strong { font-size: 13px; }
.plan-card span { font-size: 12px; color: var(--muted); }
.upgrade {
margin-top: 6px;
border: 0;
background: var(--ink);
color: var(--white);
font-weight: 600;
font-size: 12.5px;
border-radius: var(--r-sm);
padding: 7px 0;
}
.upgrade:hover { background: var(--brand-700); }
.content { padding: 22px 24px 26px; min-width: 0; }
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.page-head h1 { margin: 0; font-size: 22px; letter-spacing: -0.02em; }
.page-head .sub { margin: 3px 0 0; color: var(--muted); font-size: 14px; }
.ghost-link {
border: 1px solid var(--line);
background: var(--white);
color: var(--ink-2);
font-weight: 600;
font-size: 13px;
border-radius: var(--r-sm);
padding: 8px 12px;
white-space: nowrap;
transition: background .14s, border-color .14s;
}
.ghost-link:hover { background: var(--bg); border-color: var(--line-2); }
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 18px;
}
.stat {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px;
display: flex;
flex-direction: column;
gap: 4px;
box-shadow: var(--sh-1);
}
.stat-label { font-size: 12px; color: var(--muted); font-weight: 500; }
.stat-value { font-size: 26px; font-weight: 800; letter-spacing: -0.02em; }
.trend { font-size: 11.5px; font-weight: 600; }
.trend.up { color: var(--ok); }
.trend.down { color: var(--ok); }
.trend.flat { color: var(--muted); }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.card {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 16px;
box-shadow: var(--sh-1);
}
.card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 12px;
}
.card-head h2 { margin: 0; font-size: 15px; letter-spacing: -0.01em; }
.muted { color: var(--muted); font-size: 12px; }
.proj-list, .feed { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.proj-list li { display: flex; align-items: center; gap: 11px; }
.proj-ic {
width: 34px; height: 34px;
border-radius: 10px;
display: grid; place-items: center;
background: color-mix(in srgb, var(--c) 16%, white);
color: var(--c);
font-size: 12px;
font-weight: 800;
flex: none;
}
.proj-meta { display: flex; flex-direction: column; min-width: 0; }
.proj-meta strong { font-size: 13.5px; }
.proj-meta span { font-size: 12px; color: var(--muted); }
.badge {
margin-left: auto;
font-size: 11px;
font-weight: 700;
padding: 3px 9px;
border-radius: 999px;
}
.badge.ok { background: #e4f4ec; color: var(--ok); }
.badge.warn { background: #fcefdc; color: var(--warn); }
.feed li { display: flex; gap: 10px; align-items: flex-start; }
.feed p { margin: 0; font-size: 13px; color: var(--ink-2); }
.feed strong { color: var(--ink); }
.feed em { font-style: normal; color: var(--brand-700); font-weight: 600; }
.ago { color: var(--muted); font-size: 11.5px; margin-left: 4px; }
.dot-av {
width: 26px; height: 26px;
flex: none;
border-radius: 50%;
display: grid; place-items: center;
background: var(--c);
color: var(--white);
font-size: 10.5px;
font-weight: 700;
}
/* ===================== Variant switcher bar ===================== */
.tour-bar {
max-width: 1080px;
margin: 16px auto 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--sh-1);
}
.start-btn {
border: 0;
background: var(--brand);
color: var(--white);
font-weight: 700;
font-size: 13.5px;
border-radius: var(--r-sm);
padding: 10px 16px;
box-shadow: var(--sh-1);
transition: background .15s, transform .12s;
}
.start-btn:hover { background: var(--brand-d); }
.start-btn:active { transform: translateY(1px); }
.seg { display: flex; align-items: center; gap: 6px; }
.seg-label { font-size: 12px; font-weight: 600; color: var(--muted); margin-right: 2px; }
.seg-btn {
border: 1px solid var(--line);
background: var(--white);
color: var(--ink-2);
font-size: 12.5px;
font-weight: 600;
padding: 7px 11px;
border-radius: var(--r-sm);
transition: background .14s, color .14s, border-color .14s;
}
.seg-btn:hover { background: var(--bg); }
.seg-btn.is-on {
background: var(--brand-50);
color: var(--brand-700);
border-color: color-mix(in srgb, var(--brand) 35%, var(--line));
}
/* ===================== Tour overlay ===================== */
.tour {
position: fixed;
inset: 0;
z-index: 60;
}
.tour[hidden] { display: none; }
.tour-dim {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
pointer-events: auto;
}
.dim-rect { fill: rgba(16, 19, 34, 0.62); }
/* Bordered variant: ring around target instead of cut-out */
.tour-ring {
position: fixed;
border: 3px solid var(--brand);
border-radius: var(--r-md);
box-shadow: 0 0 0 9999px rgba(16, 19, 34, 0.5), 0 0 0 4px var(--brand-50);
pointer-events: none;
display: none;
transition: top .28s cubic-bezier(.2,.7,.2,1), left .28s cubic-bezier(.2,.7,.2,1),
width .28s cubic-bezier(.2,.7,.2,1), height .28s cubic-bezier(.2,.7,.2,1);
}
.tour.is-border .tour-dim { display: none; }
.tour.is-border .tour-ring { display: block; }
/* subtle pulse ring on the spotlight hole edge */
.tour-pulse {
position: fixed;
border-radius: var(--r-md);
border: 2px solid rgba(91, 91, 240, 0.85);
pointer-events: none;
display: none;
animation: pulse 1.8s ease-out infinite;
}
.tour.is-spotlight .tour-pulse { display: block; }
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(91,91,240,.45); }
70% { box-shadow: 0 0 0 12px rgba(91,91,240,0); }
100% { box-shadow: 0 0 0 0 rgba(91,91,240,0); }
}
/* Coachmark */
.coach {
position: fixed;
width: 308px;
max-width: calc(100vw - 24px);
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--sh-3);
padding: 16px 16px 14px;
pointer-events: auto;
transition: top .28s cubic-bezier(.2,.7,.2,1), left .28s cubic-bezier(.2,.7,.2,1), opacity .2s;
}
.coach-arrow {
position: absolute;
width: 14px; height: 14px;
background: var(--white);
border: 1px solid var(--line);
transform: rotate(45deg);
}
.coach[data-side="top"] .coach-arrow { bottom: -8px; border-top: 0; border-left: 0; }
.coach[data-side="bottom"] .coach-arrow { top: -8px; border-bottom: 0; border-right: 0; }
.coach[data-side="left"] .coach-arrow { right: -8px; border-bottom: 0; border-left: 0; }
.coach[data-side="right"] .coach-arrow { left: -8px; border-top: 0; border-right: 0; }
.coach-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.coach-step {
font-size: 11px;
font-weight: 700;
letter-spacing: .03em;
text-transform: uppercase;
color: var(--brand-700);
background: var(--brand-50);
border-radius: 999px;
padding: 3px 9px;
}
.coach-skip {
border: 0;
background: transparent;
color: var(--muted);
font-size: 12.5px;
font-weight: 600;
padding: 2px 4px;
border-radius: 6px;
}
.coach-skip:hover { color: var(--ink); text-decoration: underline; }
.coach-title { margin: 0 0 5px; font-size: 16px; letter-spacing: -0.01em; }
.coach-body { margin: 0 0 12px; font-size: 13.5px; color: var(--ink-2); }
.coach-dots { display: flex; gap: 6px; margin-bottom: 12px; }
.coach-dots i {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--line-2);
transition: background .2s, width .2s;
}
.coach-dots i.on { background: var(--brand); width: 18px; border-radius: 999px; }
.coach-actions { display: flex; align-items: center; gap: 8px; }
.btn {
border-radius: var(--r-sm);
font-weight: 600;
font-size: 13px;
padding: 9px 14px;
border: 1px solid transparent;
transition: background .14s, border-color .14s, transform .12s;
}
.btn:active { transform: translateY(1px); }
.btn.ghost {
background: var(--white);
border-color: var(--line);
color: var(--ink-2);
}
.btn.ghost:hover { background: var(--bg); }
.btn.ghost:disabled { opacity: .45; cursor: not-allowed; }
.btn.primary {
margin-left: auto;
background: var(--brand);
color: var(--white);
}
.btn.primary:hover { background: var(--brand-d); }
/* ===================== Toast ===================== */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 16px);
background: var(--ink);
color: var(--white);
font-size: 13px;
font-weight: 600;
padding: 11px 18px;
border-radius: 999px;
box-shadow: var(--sh-2);
opacity: 0;
pointer-events: none;
z-index: 80;
transition: opacity .22s, transform .22s;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* ===================== Responsive ===================== */
@media (max-width: 820px) {
.stats { grid-template-columns: repeat(2, 1fr); }
.grid { grid-template-columns: 1fr; }
}
@media (max-width: 520px) {
body { padding: 16px 10px 96px; }
.topbar { flex-wrap: wrap; gap: 10px; }
.search { order: 3; max-width: none; flex-basis: 100%; }
.body { grid-template-columns: 1fr; }
.sidebar {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.sidebar nav { flex-direction: row; flex-wrap: wrap; gap: 4px; }
.nav-item .pill { margin-left: 4px; }
.nav-foot { display: none; }
.page-head { flex-direction: column; }
.stats { grid-template-columns: 1fr 1fr; }
.tour-bar { flex-direction: column; align-items: stretch; }
.seg { flex-wrap: wrap; }
.coach { width: auto; left: 12px !important; right: 12px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important;
transition-duration: .001ms !important;
}
}(function () {
"use strict";
/* ---------- Tour step definitions (each points at a target element) ---------- */
var STEPS = [
{
target: "#t-search",
title: "Find anything, fast",
body: "Jump to any project, person, or document. Press ⌘K from anywhere to open search instantly.",
prefer: "bottom"
},
{
target: "#t-nav",
title: "Your workspace, organized",
body: "Switch between Overview, Tasks, and Projects here. The badge shows how many tasks need you.",
prefer: "right"
},
{
target: "#t-stats",
title: "Track what matters",
body: "These live metrics update as your team ships. Cycle time trending down means you're moving faster.",
prefer: "bottom"
},
{
target: "#t-activity",
title: "Stay in the loop",
body: "Recent activity streams in real time so you never miss a comment, close, or new issue.",
prefer: "left"
},
{
target: "#t-create",
title: "Create in one click",
body: "When you're ready, hit New to spin up a task, project, or doc. That's the whole tour — enjoy!",
prefer: "bottom"
}
];
var PAD = 8; // breathing room around the highlight
var GAP = 14; // space between target and coachmark
var EDGE = 12; // viewport edge margin for the coachmark
/* ---------- Element refs ---------- */
var tour = document.getElementById("tour");
var maskHole = document.getElementById("maskHole");
var ring = document.getElementById("tourRing");
var coach = document.getElementById("coach");
var arrow = document.getElementById("coachArrow");
var elStep = document.getElementById("coachStep");
var elTitle = document.getElementById("coachTitle");
var elBody = document.getElementById("coachBody");
var elDots = document.getElementById("coachDots");
var btnBack = document.getElementById("coachBack");
var btnNext = document.getElementById("coachNext");
var btnSkip = document.getElementById("coachSkip");
var toastEl = document.getElementById("toast");
/* a pulse ring element for the spotlight variant */
var pulse = document.createElement("div");
pulse.className = "tour-pulse";
tour.appendChild(pulse);
/* ---------- State ---------- */
var idx = 0;
var active = false;
var styleVariant = "spotlight"; // 'spotlight' | 'border'
var placeVariant = "auto"; // 'auto' | 'bottom'
var lastFocus = null;
/* ---------- Toast helper ---------- */
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2200);
}
/* ---------- Build progress dots once ---------- */
(function buildDots() {
var html = "";
for (var i = 0; i < STEPS.length; i++) html += "<i></i>";
elDots.innerHTML = html;
})();
/* ---------- Measure a target's rect (viewport coords) ---------- */
function targetRect(sel) {
var el = document.querySelector(sel);
if (!el) return null;
var r = el.getBoundingClientRect();
return {
x: r.left - PAD,
y: r.top - PAD,
w: r.width + PAD * 2,
h: r.height + PAD * 2
};
}
/* ---------- Position the highlight (mask hole or ring) ---------- */
function paintHighlight(rect) {
// spotlight cut-out via SVG mask
maskHole.setAttribute("x", rect.x);
maskHole.setAttribute("y", rect.y);
maskHole.setAttribute("width", rect.w);
maskHole.setAttribute("height", rect.h);
// bordered-variant ring
ring.style.top = rect.y + "px";
ring.style.left = rect.x + "px";
ring.style.width = rect.w + "px";
ring.style.height = rect.h + "px";
// pulse follows the hole edge
pulse.style.top = rect.y + "px";
pulse.style.left = rect.x + "px";
pulse.style.width = rect.w + "px";
pulse.style.height = rect.h + "px";
}
/* ---------- Choose a placement side that fits in the viewport ---------- */
function resolveSide(rect, cw, ch) {
if (placeVariant === "bottom") return "bottom";
var vw = window.innerWidth, vh = window.innerHeight;
var below = vh - (rect.y + rect.h);
var above = rect.y;
var rightSpace = vw - (rect.x + rect.w);
var leftSpace = rect.x;
var prefer = STEPS[idx].prefer;
var fits = {
bottom: below >= ch + GAP + EDGE,
top: above >= ch + GAP + EDGE,
right: rightSpace >= cw + GAP + EDGE,
left: leftSpace >= cw + GAP + EDGE
};
if (fits[prefer]) return prefer;
var order = ["bottom", "top", "right", "left"];
for (var i = 0; i < order.length; i++) if (fits[order[i]]) return order[i];
// nothing fits cleanly — fall back to wherever there is the most room
return below >= above ? "bottom" : "top";
}
/* ---------- Position the coachmark relative to the rect ---------- */
function placeCoach(rect) {
var cw = coach.offsetWidth || 308;
var ch = coach.offsetHeight || 180;
var side = resolveSide(rect, cw, ch);
coach.setAttribute("data-side", side);
var top, left;
var cx = rect.x + rect.w / 2;
var cy = rect.y + rect.h / 2;
if (side === "bottom") { top = rect.y + rect.h + GAP; left = cx - cw / 2; }
else if (side === "top") { top = rect.y - GAP - ch; left = cx - cw / 2; }
else if (side === "right") { left = rect.x + rect.w + GAP; top = cy - ch / 2; }
else { left = rect.x - GAP - cw; top = cy - ch / 2; }
// clamp to viewport
var vw = window.innerWidth, vh = window.innerHeight;
left = Math.max(EDGE, Math.min(left, vw - cw - EDGE));
top = Math.max(EDGE, Math.min(top, vh - ch - EDGE));
coach.style.top = top + "px";
coach.style.left = left + "px";
// point the arrow at the target center along the relevant axis
if (side === "bottom" || side === "top") {
var ax = Math.max(14, Math.min(cx - left, cw - 14));
arrow.style.left = ax + "px";
arrow.style.top = "";
} else {
var ay = Math.max(14, Math.min(cy - top, ch - 14));
arrow.style.top = ay + "px";
arrow.style.left = "";
}
}
/* ---------- Render the current step ---------- */
function render() {
var step = STEPS[idx];
var rect = targetRect(step.target);
if (!rect) { endTour(true); return; }
// scroll target into view if it's off-screen, then re-measure on next frame
var el = document.querySelector(step.target);
var r = el.getBoundingClientRect();
if (r.top < 0 || r.bottom > window.innerHeight) {
el.scrollIntoView({ block: "center", behavior: "smooth" });
requestAnimationFrame(function () {
requestAnimationFrame(render);
});
return;
}
paintHighlight(rect);
elStep.textContent = (idx + 1) + " of " + STEPS.length;
elTitle.textContent = step.title;
elBody.textContent = step.body;
var dots = elDots.children;
for (var i = 0; i < dots.length; i++) {
dots[i].className = i === idx ? "on" : "";
}
btnBack.disabled = idx === 0;
btnNext.textContent = idx === STEPS.length - 1 ? "Finish" : "Next";
placeCoach(rect);
}
/* ---------- Open / advance / close ---------- */
function startTour() {
idx = 0;
active = true;
lastFocus = document.activeElement;
tour.hidden = false;
applyStyleClasses();
render();
// focus the primary action for keyboard users
btnNext.focus();
}
function next() {
if (idx < STEPS.length - 1) {
idx++;
render();
} else {
endTour(false);
toast("Tour complete — you're all set! 🎉");
}
}
function back() {
if (idx > 0) { idx--; render(); }
}
function endTour(silent) {
active = false;
tour.hidden = true;
if (lastFocus && lastFocus.focus) lastFocus.focus();
if (!silent && idx < STEPS.length - 1) toast("Tour skipped — replay it anytime.");
}
function applyStyleClasses() {
tour.classList.toggle("is-spotlight", styleVariant === "spotlight");
tour.classList.toggle("is-border", styleVariant === "border");
}
/* ---------- Wire controls ---------- */
document.getElementById("startTour").addEventListener("click", startTour);
document.getElementById("replay").addEventListener("click", startTour);
btnNext.addEventListener("click", next);
btnBack.addEventListener("click", back);
btnSkip.addEventListener("click", function () { endTour(false); });
/* dim/ring backdrop click advances (common tour UX); ignore clicks on the coach */
tour.addEventListener("click", function (e) {
if (e.target.closest(".coach")) return;
next();
});
/* ---------- Variant switcher ---------- */
document.querySelectorAll(".seg-btn[data-style]").forEach(function (b) {
b.addEventListener("click", function () {
styleVariant = b.getAttribute("data-style");
document.querySelectorAll(".seg-btn[data-style]").forEach(function (o) {
var on = o === b;
o.classList.toggle("is-on", on);
o.setAttribute("aria-pressed", on ? "true" : "false");
});
if (active) { applyStyleClasses(); render(); }
});
});
document.querySelectorAll(".seg-btn[data-place]").forEach(function (b) {
b.addEventListener("click", function () {
placeVariant = b.getAttribute("data-place");
document.querySelectorAll(".seg-btn[data-place]").forEach(function (o) {
var on = o === b;
o.classList.toggle("is-on", on);
o.setAttribute("aria-pressed", on ? "true" : "false");
});
if (active) render();
});
});
/* ---------- Keyboard ---------- */
document.addEventListener("keydown", function (e) {
if (!active) return;
if (e.key === "Escape") { e.preventDefault(); endTour(false); }
else if (e.key === "ArrowRight" || e.key === "Enter") { e.preventDefault(); next(); }
else if (e.key === "ArrowLeft") { e.preventDefault(); back(); }
else if (e.key === "Tab") {
// trap focus within the coachmark controls
var f = [btnSkip, btnBack, btnNext].filter(function (x) { return !x.disabled; });
if (!f.length) return;
var first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
else if (f.indexOf(document.activeElement) === -1) { e.preventDefault(); first.focus(); }
}
});
/* ---------- Recompute on resize / scroll ---------- */
var rafId;
function recompute() {
if (!active) return;
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(render);
}
window.addEventListener("resize", recompute);
window.addEventListener("scroll", recompute, true);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Onboarding — Spotlight product tour</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>
<!-- ============ Faux app shell (the tour target surface) ============ -->
<div class="app" id="app">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none">
<rect x="3" y="3" width="8" height="8" rx="2" fill="currentColor"/>
<rect x="13" y="3" width="8" height="8" rx="2" fill="currentColor" opacity=".55"/>
<rect x="3" y="13" width="8" height="8" rx="2" fill="currentColor" opacity=".55"/>
<rect x="13" y="13" width="8" height="8" rx="2" fill="currentColor"/>
</svg>
</span>
<span class="brand-name">Northwind</span>
</div>
<div class="search" data-tour="search" id="t-search">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
<path d="m20 20-3.2-3.2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<input type="text" placeholder="Search projects, people, docs…" aria-label="Search" />
<kbd>⌘K</kbd>
</div>
<div class="top-actions">
<button class="icon-btn" data-tour="create" id="t-create" type="button" aria-label="Create new">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span>New</span>
</button>
<button class="icon-btn ghost" data-tour="notifs" id="t-notifs" type="button" aria-label="Notifications">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<path d="M13.7 21a2 2 0 0 1-3.4 0" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span class="dot" aria-hidden="true"></span>
</button>
<button class="avatar" type="button" aria-label="Account: Mara Velez">MV</button>
</div>
</header>
<div class="body">
<aside class="sidebar" data-tour="nav" id="t-nav" aria-label="Primary">
<nav>
<a class="nav-item active" href="#"><span class="ni-ic">▦</span> Overview</a>
<a class="nav-item" href="#"><span class="ni-ic">◔</span> Tasks <em class="pill">12</em></a>
<a class="nav-item" href="#"><span class="ni-ic">◇</span> Projects</a>
<a class="nav-item" href="#"><span class="ni-ic">☰</span> Reports</a>
<a class="nav-item" href="#"><span class="ni-ic">⚙</span> Settings</a>
</nav>
<div class="nav-foot">
<div class="plan-card">
<strong>Free plan</strong>
<span>3 of 5 seats used</span>
<button type="button" class="upgrade">Upgrade</button>
</div>
</div>
</aside>
<main class="content">
<div class="page-head">
<div>
<h1>Good afternoon, Mara</h1>
<p class="sub">Here's what's moving across your workspace today.</p>
</div>
<button class="ghost-link" type="button" id="replay">↻ Replay tour</button>
</div>
<section class="stats" data-tour="stats" id="t-stats" aria-label="Key metrics">
<article class="stat">
<span class="stat-label">Open tasks</span>
<span class="stat-value">42</span>
<span class="trend up">▲ 8 this week</span>
</article>
<article class="stat">
<span class="stat-label">In review</span>
<span class="stat-value">9</span>
<span class="trend flat">• steady</span>
</article>
<article class="stat">
<span class="stat-label">Shipped</span>
<span class="stat-value">128</span>
<span class="trend up">▲ 14%</span>
</article>
<article class="stat">
<span class="stat-label">Cycle time</span>
<span class="stat-value">2.4d</span>
<span class="trend down">▼ 0.3d</span>
</article>
</section>
<div class="grid">
<section class="card">
<header class="card-head">
<h2>Active projects</h2>
<span class="muted">Updated 4m ago</span>
</header>
<ul class="proj-list">
<li>
<span class="proj-ic" style="--c:#5b5bf0">AP</span>
<div class="proj-meta"><strong>Atlas Platform</strong><span>14 open · 3 in review</span></div>
<span class="badge ok">On track</span>
</li>
<li>
<span class="proj-ic" style="--c:#00b4a6">BL</span>
<div class="proj-meta"><strong>Beacon Launch</strong><span>22 open · 5 in review</span></div>
<span class="badge warn">At risk</span>
</li>
<li>
<span class="proj-ic" style="--c:#d4503e">CR</span>
<div class="proj-meta"><strong>Citrus Redesign</strong><span>6 open · 1 in review</span></div>
<span class="badge ok">On track</span>
</li>
</ul>
</section>
<section class="card" data-tour="activity" id="t-activity">
<header class="card-head">
<h2>Recent activity</h2>
<span class="muted">Live</span>
</header>
<ul class="feed">
<li><span class="dot-av" style="--c:#5b5bf0">JT</span> <p><strong>Jonas Thiel</strong> closed <em>Auth rate limits</em> <span class="ago">12m</span></p></li>
<li><span class="dot-av" style="--c:#00b4a6">PA</span> <p><strong>Priya Anand</strong> commented on <em>Billing v2</em> <span class="ago">38m</span></p></li>
<li><span class="dot-av" style="--c:#d98a2b">LO</span> <p><strong>Leo Okafor</strong> opened <em>Mobile drift bug</em> <span class="ago">1h</span></p></li>
</ul>
</section>
</div>
</main>
</div>
</div>
<!-- ============ Tour controls (variant switcher) ============ -->
<div class="tour-bar" role="region" aria-label="Tour options">
<button class="start-btn" id="startTour" type="button">Start product tour</button>
<div class="seg" role="group" aria-label="Highlight style">
<span class="seg-label">Highlight</span>
<button class="seg-btn is-on" data-style="spotlight" type="button" aria-pressed="true">Spotlight cut-out</button>
<button class="seg-btn" data-style="border" type="button" aria-pressed="false">Bordered</button>
</div>
<div class="seg" role="group" aria-label="Tooltip placement">
<span class="seg-label">Tooltip</span>
<button class="seg-btn is-on" data-place="auto" type="button" aria-pressed="true">Auto</button>
<button class="seg-btn" data-place="bottom" type="button" aria-pressed="false">Bottom</button>
</div>
</div>
<!-- ============ Tour overlay (built once, reused) ============ -->
<div class="tour" id="tour" hidden>
<!-- SVG dim layer with a punched-out spotlight hole -->
<svg class="tour-dim" id="tourDim" aria-hidden="true" preserveAspectRatio="none">
<defs>
<mask id="spotMask">
<rect id="maskFull" x="0" y="0" width="100%" height="100%" fill="white"/>
<rect id="maskHole" x="0" y="0" width="0" height="0" rx="14" fill="black"/>
</mask>
</defs>
<rect class="dim-rect" x="0" y="0" width="100%" height="100%" mask="url(#spotMask)"/>
</svg>
<!-- Bordered-variant ring (no cut-out) -->
<div class="tour-ring" id="tourRing" aria-hidden="true"></div>
<!-- Coachmark bubble -->
<div class="coach" id="coach" role="dialog" aria-modal="true" aria-labelledby="coachTitle" aria-describedby="coachBody">
<span class="coach-arrow" id="coachArrow" aria-hidden="true"></span>
<div class="coach-top">
<span class="coach-step" id="coachStep" aria-live="polite">1 of 5</span>
<button class="coach-skip" id="coachSkip" type="button">Skip</button>
</div>
<h3 id="coachTitle" class="coach-title">Title</h3>
<p id="coachBody" class="coach-body">Body</p>
<div class="coach-dots" id="coachDots" aria-hidden="true"></div>
<div class="coach-actions">
<button class="btn ghost" id="coachBack" type="button">Back</button>
<button class="btn primary" id="coachNext" type="button">Next</button>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Spotlight product tour (coachmarks)
A multi-step coachmark tour staged over a realistic product shell — the Northwind workspace with a search bar, sidebar nav, live stat cards, and an activity feed. Starting the tour drops a dimmed overlay over the whole UI and uses an SVG mask to punch a rounded cut-out around the current target, so the highlighted element reads through crisply while everything else recedes. A floating coachmark bubble follows along, carrying a step pill (2 of 5), title, body copy, animated progress dots, and Back / Next / Skip controls, with the final step swapping Next for Finish and clearing the overlay.
The bubble is auto-placed: it measures the target rect and the surrounding viewport space, honors a per-step preferred side, and falls back through bottom, top, right, and left until it finds room, clamping to the screen edges and pointing its arrow at the target. The highlight and bubble recompute on every resize and scroll (rAF-throttled), and off-screen targets are scrolled into view before measuring. Backdrop clicks advance, ArrowLeft/ArrowRight and Enter drive it from the keyboard, Escape skips out, and focus is trapped across the bubble’s controls and restored to the opener when the tour ends. A small toast() helper announces completion or skip through an aria-live region.
A segmented switcher demonstrates both variant axes live. Highlight toggles between the spotlight cut-out (with a soft pulse ring on the hole edge) and a simpler bordered highlight that draws a brand ring with a flat scrim. Tooltip toggles between fully auto placement and a forced bottom placement. Down to 360px the shell collapses to a single column, the sidebar nav wraps inline, and the coachmark spans the available width.
Illustrative UI only — fictional names and data.