Cookie Consent
An honest cookie card: Reject weighs as much as Accept, Choose unfolds the categories, the choice is kept and a link brings the card back.
View codeA guided tour: the page dims, a spotlight cuts out each element and glides to the next, reshaping on the way, with a card that follows. Keyboard too.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Walk new users through my dashboard with a Kodza Product Tour.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-tour" data-scope=".demo-app">
<button class="kodza-tour__start" type="button">Take the tour</button>
<ol class="kodza-tour__steps" hidden>
<li data-target=".demo-search" data-title="Search everything">Press / from anywhere. It finds components faster than you find your keys.</li>
<li data-target=".demo-new" data-title="Start a project">One click, no wizard, no twelve-step onboarding. Ironic, we know.</li>
<li data-target=".demo-project" data-title="Your projects live here">Each card shows its last deploy. Green would be nice; we went with ink.</li>
<li data-target=".demo-avatar" data-title="That's you">Settings, dark mode and the logout button nobody uses. Enjoy the tour? It's over.</li>
</ol>
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Product Tour
A guided tour: the page dims, a spotlight cuts out the element to look at, and a card next to
it explains. Between steps, the spotlight glides and reshapes itself to the next element and
the card follows, its words sliding the way you go. Arrows, Enter and Escape drive it, and it
scrolls each element into view first. Steps are an ordered list (ol) of data-target / data-title, looked
for inside data-scope (the whole page by default).
Everything is tuned with the --kz-tour-* variables. */
.kodza-tour {
--kz-tour-bg: #ffffff;
--kz-tour-fg: #0a0a0a;
--kz-tour-muted: rgba(10, 10, 10, 0.6);
--kz-tour-line: rgba(10, 10, 10, 0.12);
--kz-tour-ink: #0a0a0a;
--kz-tour-on-ink: #ffffff;
--kz-tour-dim: rgba(10, 10, 10, 0.55);
--kz-tour-ring: rgba(255, 255, 255, 0.9);
--kz-tour-pad: 6px;
--kz-tour-radius: 12px;
--kz-tour-ease: cubic-bezier(0.65, 0, 0.35, 1);
color: var(--kz-tour-fg);
}
.kodza-tour__start,
.kodza-tour__next,
.kodza-tour__back {
height: 40px;
padding: 0 18px;
border: 0;
border-radius: 999px;
background: var(--kz-tour-ink);
color: var(--kz-tour-on-ink);
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.kodza-tour__start:hover,
.kodza-tour__next:hover {
opacity: 0.86;
}
.kodza-tour__start:active,
.kodza-tour__next:active,
.kodza-tour__back:active {
transform: scale(0.97);
}
.kodza-tour__back {
background: none;
box-shadow: inset 0 0 0 1px var(--kz-tour-line);
color: var(--kz-tour-fg);
}
.kodza-tour__back[hidden] {
display: none;
}
/* The spotlight: a hole in the dimmed page, with a thin ring */
.kodza-tour__spot {
position: fixed;
top: 0;
left: 0;
z-index: 80;
border-radius: var(--kz-tour-radius);
box-shadow: 0 0 0 1.5px var(--kz-tour-ring), 0 0 0 100vmax var(--kz-tour-dim);
pointer-events: none;
opacity: 0;
transition: opacity 0.35s ease;
}
.kodza-tour.is-on .kodza-tour__spot {
opacity: 1;
}
/* A clear layer catches clicks on the page while the tour runs */
.kodza-tour__blocker {
position: fixed;
inset: 0;
z-index: 79;
display: none;
}
.kodza-tour.is-on .kodza-tour__blocker {
display: block;
}
/* The card */
.kodza-tour__card {
position: fixed;
top: 0;
left: 0;
z-index: 81;
display: grid;
gap: 8px;
width: min(320px, calc(100vw - 24px));
padding: 18px;
border-radius: 16px;
background: var(--kz-tour-bg);
box-shadow: 0 0 0 1px var(--kz-tour-line), 0 24px 50px -20px rgba(10, 10, 10, 0.5);
box-sizing: border-box;
opacity: 0;
visibility: hidden;
transform: translate(var(--kz-card-x, 0), var(--kz-card-y, 0)) scale(0.96);
transition: opacity 0.3s ease, transform 0.55s var(--kz-tour-ease), visibility 0s linear 0.3s;
}
.kodza-tour.is-on .kodza-tour__card {
opacity: 1;
visibility: visible;
transform: translate(var(--kz-card-x, 0), var(--kz-card-y, 0));
transition: opacity 0.3s ease, transform 0.55s var(--kz-tour-ease), visibility 0s;
}
/* Following a scroll: no glide, the card stays with its target */
.kodza-tour.is-on .kodza-tour__card.is-following {
transition: opacity 0.3s ease, visibility 0s;
}
.kodza-tour__card:focus {
outline: none;
}
.kodza-tour__count {
margin: 0;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 11px;
color: var(--kz-tour-muted);
}
/* The words: old ones leave the way you go, new ones come in from the other side */
.kodza-tour__words {
display: grid;
overflow: hidden;
}
.kodza-tour__page {
display: grid;
grid-area: 1 / 1;
gap: 6px;
}
.kodza-tour__title {
margin: 0;
font-size: 17px;
font-weight: 600;
letter-spacing: -0.02em;
}
.kodza-tour__text {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: var(--kz-tour-muted);
text-wrap: pretty;
}
.kodza-tour__foot {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.kodza-tour__dots {
display: flex;
flex: 1;
gap: 4px;
}
.kodza-tour__dots span {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--kz-tour-line);
transition: transform 0.4s var(--kz-tour-ease), background-color 0.3s ease;
}
.kodza-tour__dots span.is-on {
background: var(--kz-tour-ink);
transform: scale(1.35);
}
.kodza-tour__skip {
position: absolute;
top: 14px;
right: 14px;
padding: 2px 0;
border: 0;
border-radius: 4px;
background: none;
color: var(--kz-tour-muted);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.kodza-tour__skip:hover {
color: var(--kz-tour-fg);
}
.kodza-tour__start:focus-visible,
.kodza-tour__next:focus-visible,
.kodza-tour__back:focus-visible,
.kodza-tour__skip:focus-visible {
outline: 2px solid var(--kz-tour-fg);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.kodza-tour__spot,
.kodza-tour__card,
.kodza-tour.is-on .kodza-tour__card,
.kodza-tour__dots span {
transition-duration: 0.01ms !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-tour {
--kz-tour-bg: #1b1b1b;
--kz-tour-fg: #f2f2f0;
--kz-tour-muted: rgba(242, 242, 240, 0.62);
--kz-tour-line: rgba(255, 255, 255, 0.14);
--kz-tour-ink: #f2f2f0;
--kz-tour-on-ink: #0a0a0a;
--kz-tour-dim: rgba(0, 0, 0, 0.65);
}
*/
/* kodza-guard 72f093c01d53 — generated by Kodza, do not edit.
Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
component. :not(#_) is always true and weighs as much as an id, so these lines win over
theme rules made of classes and tags. Customise the component through its --kz-* variables. */
/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */
:where(.kodza-tour__start):not(#_),
:where(.kodza-tour__back):not(#_),
:where(.kodza-tour__next):not(#_),
:where(.kodza-tour__skip):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-tour__card):not(#_) {
outline: revert;
}
:where(.kodza-tour__back):not(#_):where(:not([hidden])) {
display: revert;
}
:where(.kodza-tour__back):not(#_):where([hidden]) {
display: none;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-tour:not(#_),
.kodza-tour__blocker:not(#_),
.kodza-tour__spot:not(#_),
.kodza-tour__words:not(#_),
.kodza-tour__foot:not(#_),
.kodza-tour__dots:not(#_),
.kodza-tour__dots span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-tour__start:not(#_),
.kodza-tour__next:not(#_) {
margin: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-tour__steps:not(#_),
.kodza-tour__steps li:where(:not([class*="kodza-"]):not(.kodza-tour__steps [class*="kodza-"] *)):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-tour__card:not(#_) {
outline-offset: revert;
}
.kodza-tour__count:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-tour__back:not(#_) {
margin: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-tour__skip:not(#_) {
margin: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-tour__start:not(#_):where(:not([hidden])),
.kodza-tour__steps:not(#_):where(:not([hidden])),
.kodza-tour__count:not(#_):where(:not([hidden])),
.kodza-tour__next:not(#_):where(:not([hidden])),
.kodza-tour__skip:not(#_):where(:not([hidden])),
.kodza-tour__steps li:where(:not([class*="kodza-"]):not(.kodza-tour__steps [class*="kodza-"] *)):not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-tour__start:not(#_):where([hidden]),
.kodza-tour__steps:not(#_):where([hidden]),
.kodza-tour__count:not(#_):where([hidden]),
.kodza-tour__next:not(#_):where([hidden]),
.kodza-tour__skip:not(#_):where([hidden]),
.kodza-tour__steps li:where(:not([class*="kodza-"]):not(.kodza-tour__steps [class*="kodza-"] *)):not(#_):where([hidden]) {
display: none;
}
/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */
.kodza-tour__start:not(#_),
.kodza-tour__next:not(#_),
.kodza-tour__back:not(#_) {
height: 40px;
padding: 0 18px;
border: 0;
border-radius: 999px;
background: var(--kz-tour-ink);
color: var(--kz-tour-on-ink);
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.kodza-tour__start:hover:not(#_),
.kodza-tour__next:hover:not(#_) {
opacity: 0.86;
}
.kodza-tour__start:active:not(#_),
.kodza-tour__next:active:not(#_),
.kodza-tour__back:active:not(#_) {
transform: scale(0.97);
}
.kodza-tour__back:not(#_) {
background: none;
box-shadow: inset 0 0 0 1px var(--kz-tour-line);
color: var(--kz-tour-fg);
}
.kodza-tour__back[hidden]:not(#_) {
display: none;
}
.kodza-tour__card:not(#_) {
box-shadow: 0 0 0 1px var(--kz-tour-line), 0 24px 50px -20px rgba(10, 10, 10, 0.5);
}
.kodza-tour__card:focus:not(#_) {
outline: none;
}
.kodza-tour__count:not(#_) {
margin: 0;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 11px;
color: var(--kz-tour-muted);
}
.kodza-tour__skip:not(#_) {
position: absolute;
top: 14px;
right: 14px;
padding: 2px 0;
border: 0;
border-radius: 4px;
background: none;
color: var(--kz-tour-muted);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.kodza-tour__skip:hover:not(#_) {
color: var(--kz-tour-fg);
}
.kodza-tour__start:focus-visible:not(#_),
.kodza-tour__next:focus-visible:not(#_),
.kodza-tour__back:focus-visible:not(#_),
.kodza-tour__skip:focus-visible:not(#_) {
outline: 2px solid var(--kz-tour-fg);
outline-offset: 2px;
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Product Tour
// Builds the spotlight and the card, then walks the steps: scrolls the target into view, moves
// the spotlight onto it (it glides and reshapes between steps), places the card beside it
// (below, above, or to the side, kept on screen) and slides the words the way you go.
// Next / Back / Skip, the arrow keys and Escape. Fires "kodza:tour" with { step } and
// "kodza:tour-end" with { completed }. root.kodzaTour(index) starts it from code.
(() => {
const SELECTOR = '.kodza-tour';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const ease = 'cubic-bezier(0.65, 0, 0.35, 1)';
const pad2 = (n) => String(n).padStart(2, '0');
let uid = 0;
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const start = root.querySelector('.kodza-tour__start');
const steps = [...root.querySelectorAll('.kodza-tour__steps > li')].map((li) => ({ target: li.dataset.target, title: li.dataset.title || '', text: li.textContent.trim() }));
if (!steps.length) return;
const scope = (root.dataset.scope && root.closest(root.dataset.scope)) || document;
const id = `kodza-tour-${++uid}`;
const spot = Object.assign(document.createElement('div'), { className: 'kodza-tour__spot' });
spot.setAttribute('aria-hidden', 'true');
const blocker = Object.assign(document.createElement('div'), { className: 'kodza-tour__blocker' });
const card = document.createElement('div');
card.className = 'kodza-tour__card';
card.tabIndex = -1;
card.setAttribute('role', 'dialog');
card.setAttribute('aria-labelledby', `${id}-title`);
card.innerHTML = `
<p class="kodza-tour__count" aria-hidden="true"></p>
<div class="kodza-tour__words" aria-live="polite"></div>
<div class="kodza-tour__foot">
<span class="kodza-tour__dots" aria-hidden="true">${steps.map(() => '<span></span>').join('')}</span>
<button class="kodza-tour__back" type="button">Back</button>
<button class="kodza-tour__next" type="button">Next</button>
</div>
<button class="kodza-tour__skip" type="button">Skip</button>`;
root.append(blocker, spot, card);
const count = card.querySelector('.kodza-tour__count');
const words = card.querySelector('.kodza-tour__words');
const dots = [...card.querySelectorAll('.kodza-tour__dots span')];
const back = card.querySelector('.kodza-tour__back');
const next = card.querySelector('.kodza-tour__next');
let current = -1;
let from = null;
const page = (step) => {
const box = Object.assign(document.createElement('div'), { className: 'kodza-tour__page' });
const title = Object.assign(document.createElement('p'), { className: 'kodza-tour__title', textContent: step.title, id: `${id}-title` });
box.append(title, Object.assign(document.createElement('p'), { className: 'kodza-tour__text', textContent: step.text }));
return box;
};
// The spotlight and the card, onto the target
const place = (target, animate) => {
const pad = parseFloat(getComputedStyle(root).getPropertyValue('--kz-tour-pad')) || 6;
const r = target.getBoundingClientRect();
const box = { left: r.left - pad, top: r.top - pad, width: r.width + pad * 2, height: r.height + pad * 2 };
const to = { left: `${box.left}px`, top: `${box.top}px`, width: `${box.width}px`, height: `${box.height}px` };
// The first time, the hole closes in from the whole screen onto the target, like an iris
const start = from || { left: '0px', top: '0px', width: `${innerWidth}px`, height: `${innerHeight}px` };
if (animate && !reduceMotion) spot.animate([start, to], { duration: from ? 550 : 700, easing: ease });
Object.assign(spot.style, to);
from = to;
// The card: below, else above, else to the right; always on screen
const w = card.offsetWidth;
const h = card.offsetHeight;
let x = Math.min(innerWidth - w - 12, Math.max(12, box.left + box.width / 2 - w / 2));
let y = box.top + box.height + 14;
if (y + h > innerHeight - 12) y = box.top - h - 14;
if (y < 12) {
y = Math.min(innerHeight - h - 12, Math.max(12, box.top));
x = box.left + box.width + 14 + w < innerWidth ? box.left + box.width + 14 : Math.max(12, box.left - w - 14);
}
// The first time, the card appears in place instead of flying in from the corner
if (!card.kzPlaced) card.style.transition = 'opacity 0.3s ease 0.35s';
card.style.setProperty('--kz-card-x', `${x}px`);
card.style.setProperty('--kz-card-y', `${y}px`);
if (!card.kzPlaced) {
card.kzPlaced = true;
requestAnimationFrame(() => requestAnimationFrame(() => card.style.removeProperty('transition')));
}
};
const show = async (index) => {
const step = steps[index];
const target = step && scope.querySelector(step.target);
if (!target) return end(false);
const dir = index > current ? 1 : -1;
current = index;
// Bring it into view first
const r = target.getBoundingClientRect();
const roomDown = scrollY + innerHeight < document.documentElement.scrollHeight - 1;
if ((r.top < 80 && scrollY > 0) || (r.bottom > innerHeight - 220 && roomDown)) {
target.scrollIntoView({ block: 'center', behavior: reduceMotion ? 'auto' : 'smooth' });
await new Promise((resolve) => setTimeout(resolve, reduceMotion ? 0 : 450));
if (signal.aborted) return;
}
// The words slide the way you go
const old = words.firstElementChild;
const fresh = page(step);
words.append(fresh);
if (old) {
old.querySelector('.kodza-tour__title')?.removeAttribute('id');
if (reduceMotion) old.remove();
else {
const timing = { duration: 380, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' };
old.animate([{ transform: 'none', opacity: 1 }, { transform: `translateX(${dir * -24}px)`, opacity: 0 }], { ...timing, fill: 'forwards' }).finished.then(() => old.remove());
fresh.animate([{ transform: `translateX(${dir * 24}px)`, opacity: 0 }, { transform: 'none', opacity: 1 }], { ...timing, delay: 80, fill: 'backwards' });
}
}
count.textContent = `${pad2(index + 1)} / ${pad2(steps.length)}`;
dots.forEach((dot, i) => dot.classList.toggle('is-on', i === index));
back.hidden = index === 0;
next.textContent = index === steps.length - 1 ? 'Done' : 'Next';
// The tour shows once the first step is written and in place, never empty or elsewhere
place(target, true);
root.classList.add('is-on');
root.dispatchEvent(new CustomEvent('kodza:tour', { detail: { step: index }, bubbles: true }));
};
const begin = (index = 0) => {
from = null;
current = -1;
words.replaceChildren();
card.kzPlaced = false;
show(index).then(() => card.focus({ preventScroll: true }));
};
const end = (completed) => {
if (!root.classList.contains('is-on')) return;
root.classList.remove('is-on');
card.kzPlaced = false;
root.dispatchEvent(new CustomEvent('kodza:tour-end', { detail: { completed }, bubbles: true }));
start?.focus({ preventScroll: true });
};
root.kodzaTour = begin;
signal.addEventListener('abort', () => delete root.kodzaTour);
start?.addEventListener('click', () => begin(0), { signal });
next.addEventListener('click', () => (current >= steps.length - 1 ? end(true) : show(current + 1)), { signal });
back.addEventListener('click', () => current > 0 && show(current - 1), { signal });
card.querySelector('.kodza-tour__skip').addEventListener('click', () => end(false), { signal });
document.addEventListener('keydown', (event) => {
if (!root.classList.contains('is-on')) return;
if (event.key === 'Escape') end(false);
else if (event.key === 'ArrowRight' && current < steps.length - 1) show(current + 1);
else if (event.key === 'ArrowLeft' && current > 0) show(current - 1);
else if (event.key === 'Tab') {
// Keep the focus in the card
const stops = [...card.querySelectorAll('button:not([hidden])')];
const at = stops.indexOf(document.activeElement);
event.preventDefault();
stops[(at + (event.shiftKey ? -1 : 1) + stops.length) % stops.length]?.focus();
}
}, { signal });
// While the page scrolls, the spotlight and the card stick to the target instead of gliding after it
let still = 0;
const follow = () => {
if (!root.classList.contains('is-on') || current < 0) return;
spot.getAnimations().forEach((animation) => animation.finish());
card.classList.add('is-following');
place(scope.querySelector(steps[current].target), false);
clearTimeout(still);
still = setTimeout(() => card.classList.remove('is-following'), 140);
};
addEventListener('resize', follow, { signal });
addEventListener('scroll', follow, { passive: true, signal });
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load.
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const init = (scope) => find(scope).forEach((root) => {
if (root.classList.contains('is-ready')) return; // already running, maybe from another copy of this script
const controller = new AbortController();
instances.set(root, { controller, html: root.innerHTML, attrs: [...root.attributes].map((a) => [a.name, a.value]) });
mount(root, controller.signal);
root.classList.add('is-ready');
});
const destroy = (scope) => find(scope).forEach((root) => {
const instance = instances.get(root);
if (!instance) return;
instances.delete(root);
instance.controller.abort();
[...root.attributes].forEach((a) => root.removeAttribute(a.name));
instance.attrs.forEach(([name, value]) => root.setAttribute(name, value));
root.innerHTML = instance.html;
});
document.addEventListener('kodza:init', (event) => init(event.detail?.root));
document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
init();
// Loaded in <head>, the script runs before the markup exists: once more when it is parsed
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
'use client';
// Kodza — Product Tour, for React. From https://kodza.dev/components/product-tour/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: product-tour.css and product-tour.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './product-tour.css';
export type ProductTourProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "vars" | "scope" | "onTour" | "onTourEnd"> & {
/** data-scope on the root (default: .demo-app) */
scope?: ".demo-app";
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-tour-bg"?: string | number;
/** default: #0a0a0a */
"--kz-tour-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.6) */
"--kz-tour-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.12) */
"--kz-tour-line"?: string | number;
/** default: #0a0a0a */
"--kz-tour-ink"?: string | number;
/** default: #ffffff */
"--kz-tour-on-ink"?: string | number;
/** default: rgba(10, 10, 10, 0.55) */
"--kz-tour-dim"?: string | number;
/** default: rgba(255, 255, 255, 0.9) */
"--kz-tour-ring"?: string | number;
/** default: 6px */
"--kz-tour-pad"?: string | number;
/** default: 12px */
"--kz-tour-radius"?: string | number;
/** default: cubic-bezier(0.65, 0, 0.35, 1) */
"--kz-tour-ease"?: string | number;
};
/** "kodza:tour", fired by the script (bubbles from the root) */
onTour?: (event: CustomEvent) => void;
/** "kodza:tour-end", fired by the script (bubbles from the root) */
onTourEnd?: (event: CustomEvent) => void;
className?: string;
};
export function ProductTour({ scope = ".demo-app", vars, onTour, onTourEnd, className, style, ...rest }: ProductTourProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-tour", className].filter(Boolean).join(' ');
// Everything the markup is made of: when it changes, React gets a fresh element (see the top)
const key = JSON.stringify([classes, scope, vars, style, rest]);
// Starts the script on this element once React has rendered it, stops it when it goes
useEffect(() => {
const root = ref.current;
let live = true;
// @ts-ignore: a plain script, no types to find
import('./product-tour.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
useEffect(() => {
const root = ref.current;
if (!root || !onTour) return;
const listener = (event: Event) => onTour(event as CustomEvent);
root.addEventListener("kodza:tour", listener);
return () => root.removeEventListener("kodza:tour", listener);
}, [onTour, key]);
useEffect(() => {
const root = ref.current;
if (!root || !onTourEnd) return;
const listener = (event: Event) => onTourEnd(event as CustomEvent);
root.addEventListener("kodza:tour-end", listener);
return () => root.removeEventListener("kodza:tour-end", listener);
}, [onTourEnd, key]);
return (
<div key={key} {...rest} className={classes} data-scope={scope} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<button className="kodza-tour__start" type="button">Take the tour</button>
<ol className="kodza-tour__steps" hidden>
<li data-target=".demo-search" data-title="Search everything">Press / from anywhere. It finds components faster than you find your keys.</li>
<li data-target=".demo-new" data-title="Start a project">One click, no wizard, no twelve-step onboarding. Ironic, we know.</li>
<li data-target=".demo-project" data-title="Your projects live here">Each card shows its last deploy. Green would be nice; we went with ink.</li>
<li data-target=".demo-avatar" data-title="That's you">Settings, dark mode and the logout button nobody uses. Enjoy the tour? It's over.</li>
</ol>
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.