Coach-mark tour that walks around each target
A product tour whose one coach mark hops between targets and sides (below, after, above, before) with data-⚓️-to + data-⚓️-side + data-⚓️-tween, and a data-⚓️-fill spotlight that follows along.
<header class="intro">
<h1>Coach-mark tour</h1>
<p>
One card walks around the app, hopping between targets <em>and</em> sides. Each step only sets
<code>data-⚓️-to</code>, <code>data-⚓️-side</code> and a cross-axis <code>data-⚓️-align-*</code>;
<code>data-⚓️-tween</code> + <code>data-⚓️-transition="morph"</code> make every hop swing
instead of snap. The spotlight is <code>data-⚓️-fill</code> on the same target. No position maths in JS.
</p>
<label class="xray-toggle"><input type="checkbox" id="xray"> X-ray</label>
</header>
<div class="app" data-⚓️-root>
<header class="bar">
<span class="logo" aria-hidden="true">◐</span>
<span class="brand">Orbit</span>
<label class="search" data-⚓️-name="--search"
data-tour-title="Search everything" data-tour-body="Projects, people and files. Press / from anywhere."
data-tour-side="below" data-tour-align="leading">
<span aria-hidden="true">⌕</span>
<input type="search" placeholder="Search…" aria-label="Search">
</label>
<button class="avatar" data-⚓️-name="--avatar" aria-label="Account"
data-tour-title="Your account" data-tour-body="Theme, shortcuts and billing live up here."
data-tour-side="below" data-tour-align="trailing">AA</button>
</header>
<nav class="nav" data-⚓️-name="--nav" aria-label="Sections"
data-tour-title="Jump between spaces" data-tour-body="Your pinned spaces live here. Drag to reorder them."
data-tour-side="after" data-tour-align="top"
data-tour-side-sm="below" data-tour-align-sm="leading">
<a href="#" aria-current="page"><span aria-hidden="true">▦</span> Overview</a>
<a href="#"><span aria-hidden="true">◎</span> Goals</a>
<a href="#"><span aria-hidden="true">✦</span> Launches</a>
<a href="#"><span aria-hidden="true">☰</span> Notes</a>
</nav>
<main class="main">
<div class="hello">
<h2>Morning, Adam</h2>
<p>Here's how the week is shaping up.</p>
<div class="stats"><span><b>12</b> open goals</span><span><b>3</b> launches</span><span><b>98%</b> on track</span></div>
</div>
<section class="card chart" data-⚓️-name="--chart"
data-tour-title="Weekly pulse" data-tour-body="Hover a bar for the day's numbers. It updates live."
data-tour-side="above" data-tour-align="leading">
<h2>Weekly pulse</h2>
<div class="bars" aria-hidden="true">
<i style="--h:40%"></i><i style="--h:65%"></i><i style="--h:52%"></i><i style="--h:80%"></i><i style="--h:72%"></i><i style="--h:35%"></i><i style="--h:58%"></i>
</div>
</section>
<section class="card foot">
<p>3 teammates viewed this today.</p>
<button class="share" data-⚓️-name="--share"
data-tour-title="Share it" data-tour-body="Invite anyone by link, no account needed."
data-tour-side="before" data-tour-align="bottom"
data-tour-side-sm="above" data-tour-align-sm="trailing">Share</button>
</section>
</main>
<!-- The spotlight: fills whatever the tour points at -->
<div class="spot" data-⚓️-to="--search" data-⚓️-fill data-⚓️-transition="morph" aria-hidden="true"></div>
<!-- The coach mark: one element, every step is attribute changes -->
<div class="coach" id="coach" role="dialog" aria-labelledby="coach-title" aria-describedby="coach-body"
data-⚓️-to="--search" data-⚓️-side="below" data-⚓️-align-leading
data-⚓️-tween data-⚓️-transition="morph">
<div class="panel">
<p class="count"><span id="coach-step">1</span> / <span id="coach-total">5</span></p>
<h3 id="coach-title">Search everything</h3>
<p id="coach-body">Projects, people and files.</p>
<div class="actions">
<button class="ghost" id="skip">Skip</button>
<button class="ghost" id="back">Back</button>
<button class="primary" id="next">Next</button>
</div>
</div>
</div>
</div>
<button class="replay" id="replay" hidden>Replay tour</button>/* Demo styling only. Every position comes from data-⚓️-* attributes. */
:root {
--ink: oklch(24% 0.02 270);
--muted: oklch(52% 0.02 270);
--line: oklch(90% 0.01 270);
--surface: oklch(99% 0.003 270);
--canvas: oklch(95.5% 0.008 270);
--accent: oklch(58% 0.2 275);
--accent-ink: oklch(99% 0.01 275);
color-scheme: light;
font: 400 1rem/1.5 system-ui, sans-serif;
color: var(--ink);
background: var(--canvas);
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: clamp(1rem, 4vw, 2.5rem);
display: grid;
gap: 1.5rem;
justify-items: center;
}
.intro {
max-width: 62ch;
width: 100%;
}
.intro h1 { margin: 0 0 0.4rem; font-size: 1.4rem; letter-spacing: -0.01em; }
.intro p { margin: 0 0 0.75rem; color: var(--muted); }
code { font: 0.85em ui-monospace, monospace; color: var(--accent); white-space: nowrap; }
.xray-toggle {
display: inline-flex;
gap: 0.4rem;
align-items: center;
font: 500 0.85rem ui-monospace, monospace;
padding: 0.3rem 0.7rem;
border: 1px solid var(--line);
border-radius: 99px;
background: var(--surface);
cursor: pointer;
}
/* ---------- the mock app ---------- */
.app {
width: min(100%, 62rem);
display: grid;
grid-template-columns: 12rem 1fr;
grid-template-rows: auto 1fr;
grid-template-areas: "bar bar" "nav main";
background: var(--surface);
border: 1px solid var(--line);
border-radius: 1rem;
box-shadow: 0 20px 50px -30px oklch(30% 0.05 270 / 0.5);
overflow: clip; /* keeps the spotlight's dimming inside the app frame */
min-height: 30rem;
}
.bar {
grid-area: bar;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-block-end: 1px solid var(--line);
}
.logo { font-size: 1.4rem; color: var(--accent); }
.brand { font-weight: 700; letter-spacing: -0.02em; }
.search {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 0 1 20rem;
margin-inline-start: 1.5rem;
padding: 0.4rem 0.75rem;
border: 1px solid var(--line);
border-radius: 0.6rem;
background: var(--canvas);
color: var(--muted);
}
.search input {
border: 0;
background: none;
font: inherit;
color: var(--ink);
width: 100%;
outline: none;
}
.avatar {
margin-inline-start: auto;
inline-size: 2.2rem;
block-size: 2.2rem;
border-radius: 50%;
border: 0;
font: 700 0.75rem system-ui;
color: var(--accent-ink);
background: linear-gradient(135deg, oklch(65% 0.2 330), var(--accent));
}
.nav {
grid-area: nav;
display: grid;
align-content: start;
gap: 0.15rem;
padding: 1rem 0.75rem;
border-inline-end: 1px solid var(--line);
}
.nav a {
display: flex;
gap: 0.6rem;
padding: 0.45rem 0.6rem;
border-radius: 0.5rem;
color: var(--muted);
text-decoration: none;
font-weight: 500;
}
.nav a[aria-current] { background: oklch(94% 0.03 275); color: var(--accent); }
.main {
grid-area: main;
display: grid;
align-content: start;
gap: 1rem;
padding: 1.25rem;
}
.hello h2 { margin: 0; font-size: 1.35rem; letter-spacing: -0.015em; }
.hello p { margin: 0.2rem 0 0; color: var(--muted); }
.stats { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-block-start: 0.9rem; }
.stats span {
padding: 0.3rem 0.7rem;
border-radius: 99px;
background: var(--canvas);
font: 500 0.82rem system-ui;
color: var(--muted);
}
.stats b { color: var(--ink); }
.card {
border: 1px solid var(--line);
border-radius: 0.8rem;
padding: 1rem 1.25rem;
}
.card h2 { margin: 0 0 1rem; font-size: 1rem; }
.bars {
display: flex;
align-items: end;
gap: 0.6rem;
block-size: 8rem;
}
.bars i {
flex: 1;
block-size: var(--h);
border-radius: 0.35rem 0.35rem 0.1rem 0.1rem;
background: linear-gradient(oklch(70% 0.15 275), var(--accent));
}
.foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.foot p { margin: 0; color: var(--muted); }
.share, .replay, .primary {
font: 600 0.9rem system-ui;
padding: 0.5rem 1rem;
border-radius: 0.55rem;
border: 0;
background: var(--accent);
color: var(--accent-ink);
cursor: pointer;
}
/* ---------- the spotlight (data-⚓️-fill on the current target) ---------- */
.spot {
z-index: 1;
pointer-events: none;
border-radius: 0.7rem;
box-shadow:
0 0 0 3px var(--surface),
0 0 0 6px var(--accent),
0 0 0 100vmax oklch(20% 0.03 270 / 0.35);
}
/* ---------- the coach mark ---------- */
/* The coach element is a transparent frame; its padding is the gap
between the card and its target on every side, so no side-specific
margins are needed and every hop is a pure inset/translate tween. */
.coach {
z-index: 2;
inline-size: min(19rem, calc(100vw - 2rem));
padding: 0.85rem;
}
.panel {
position: relative;
background: var(--ink);
color: oklch(96% 0.01 270);
border-radius: 0.9rem;
padding: 1rem 1.1rem 0.9rem;
box-shadow: 0 18px 40px -18px oklch(20% 0.05 270 / 0.7);
}
.panel h3 { margin: 0 0 0.25rem; font-size: 1.05rem; }
.panel p { margin: 0; }
.panel #coach-body { color: oklch(82% 0.02 270); font-size: 0.92rem; }
.count { font: 600 0.72rem ui-monospace, monospace; color: oklch(75% 0.12 275); margin-block-end: 0.35rem !important; }
.actions { display: flex; gap: 0.4rem; justify-content: end; margin-block-start: 0.9rem; }
.ghost {
font: 500 0.85rem system-ui;
padding: 0.45rem 0.7rem;
border-radius: 0.5rem;
border: 0;
background: none;
color: oklch(82% 0.02 270);
cursor: pointer;
}
.ghost:hover { background: oklch(35% 0.02 270); }
#skip { margin-inline-end: auto; }
button:focus-visible { outline: 2px solid oklch(75% 0.15 275); outline-offset: 2px; }
/* The nub: pure styling keyed off the live side/align attributes. */
.panel::before {
content: "";
position: absolute;
inline-size: 0.9rem;
block-size: 0.9rem;
background: inherit;
rotate: 45deg;
border-radius: 0.15rem;
}
.coach[data-⚓️-side="below"] .panel::before { inset-block-start: -0.35rem; }
.coach[data-⚓️-side="above"] .panel::before { inset-block-end: -0.35rem; }
.coach[data-⚓️-side="after"] .panel::before { inset-inline-start: -0.35rem; }
.coach[data-⚓️-side="before"] .panel::before { inset-inline-end: -0.35rem; }
.coach[data-⚓️-align-leading] .panel::before { inset-inline-start: 1.1rem; }
.coach[data-⚓️-align-trailing] .panel::before { inset-inline-end: 1.1rem; }
.coach[data-⚓️-align-top] .panel::before { inset-block-start: 1.1rem; }
.coach[data-⚓️-align-bottom] .panel::before { inset-block-end: 1.1rem; }
/* Tour off: fade both pieces out (they stay anchored, just invisible). */
.app.tour-off .coach,
.app.tour-off .spot { opacity: 0; pointer-events: none; }
.replay { justify-self: center; }
/* ---------- X-ray ---------- */
.xray [data-⚓️-name] { outline: 2px dashed oklch(62% 0.22 25); outline-offset: 3px; }
.xray .coach { outline: 1px dashed oklch(62% 0.22 25 / 0.7); }
.xray .coach::after {
content: "to=" attr(data-⚓️-to) " side=" attr(data-⚓️-side);
display: block;
margin-block-start: 0.4rem;
inline-size: fit-content;
padding: 0.15rem 0.45rem;
border-radius: 0.3rem;
font: 600 0.72rem ui-monospace, monospace;
background: oklch(62% 0.22 25);
color: white;
}
/* ---------- small screens: nav becomes a strip under the bar ---------- */
@media (width < 40rem) {
.app {
grid-template-columns: 1fr;
grid-template-areas: "bar" "nav" "main";
}
.brand { display: none; }
.search { margin-inline-start: 0.25rem; }
.nav {
display: flex;
overflow-x: auto;
border-inline-end: 0;
border-block-end: 1px solid var(--line);
padding: 0.5rem;
}
.nav a { white-space: nowrap; }
}// State only: which step we're on. Each step copies the target's anchor name
// and its preferred side/alignment onto the coach + spotlight attributes;
// the library + browser do all the positioning and tweening.
const app = document.querySelector(".app");
const coach = document.getElementById("coach");
const spot = document.querySelector(".spot");
const steps = [...document.querySelectorAll("[data-tour-title]")];
const small = matchMedia("(width < 40rem)");
const $ = (id) => document.getElementById(id);
const ALIGNS = ["⚓️AlignLeading", "⚓️AlignTrailing", "⚓️AlignTop", "⚓️AlignBottom"];
let i = 0;
function show(n) {
i = Math.max(0, Math.min(n, steps.length - 1));
const t = steps[i].dataset;
const side = (small.matches && t.tourSideSm) || t.tourSide;
const align = (small.matches && t.tourAlignSm) || t.tourAlign;
coach.dataset["⚓️To"] = spot.dataset["⚓️To"] = t["⚓️Name"];
coach.dataset["⚓️Side"] = side;
for (const a of ALIGNS) delete coach.dataset[a];
coach.dataset["⚓️Align" + align[0].toUpperCase() + align.slice(1)] = "";
$("coach-step").textContent = i + 1;
$("coach-total").textContent = steps.length;
$("coach-title").textContent = t.tourTitle;
$("coach-body").textContent = t.tourBody;
$("back").disabled = i === 0;
$("next").textContent = i === steps.length - 1 ? "Done" : "Next";
steps[i].scrollIntoView({ block: "nearest", behavior: "smooth" });
}
function start() {
app.classList.remove("tour-off");
coach.inert = false;
$("replay").hidden = true;
show(0);
$("next").focus({ preventScroll: true });
}
function end() {
app.classList.add("tour-off");
coach.inert = true;
$("replay").hidden = false;
$("replay").focus({ preventScroll: true });
}
$("next").onclick = () => (i === steps.length - 1 ? end() : show(i + 1));
$("back").onclick = () => show(i - 1);
$("skip").onclick = end;
$("replay").onclick = start;
coach.addEventListener("keydown", (e) => {
if (e.key === "Escape") end();
if (e.key === "ArrowRight") show(i + 1);
if (e.key === "ArrowLeft") show(i - 1);
});
small.addEventListener("change", () => show(i));
$("xray").onchange = (e) => document.body.classList.toggle("xray", e.target.checked);
start();data-⚓️-align-leadingdata-⚓️-filldata-⚓️-namedata-⚓️-rootdata-⚓️-sidedata-⚓️-todata-⚓️-transitiondata-⚓️-tween