Rondleiding met hotspots New
Short tour through a customer portal: four softly pulsing hotspots, an explanation per step with back, next, skip and Esc, a spotlight on the element and focus that stays inside the explanation and returns afterwards. Click a single hotspot to see just that tip. Positions are calculated from the real elements, so it also works on mobile. Pulsing stops with reduced motion. In production you store which tips someone has seen in localStorage or the profile.
Openstaand€ 4.280,006 facturen, 1 te laat
Btw-aangifte Q3€ 1.932,40indienen voor 31 okt
How to use it
- Paste the HTML where the components should appear.
- Add the CSS to your stylesheet. All classes are prefixed, so nothing clashes with your own styles.
- Add the JavaScript and call init() with the element that contains the HTML.
<!-- Rondleiding met hotspots · NedDev · eigen werk. -->
<div class="jouw-container">
<div class="ptr">
<div class="ptr__app">
<div class="ptr__side" role="navigation" aria-label="Menu (demo)">
<span class="ptr__brand">Kasboek</span>
<a href="#" aria-current="page">Overzicht</a><a href="#">Facturen</a><a href="#" data-tip="3">Uitgaven</a><a href="#">Btw</a>
</div>
<div class="ptr__main">
<div class="ptr__top">
<label class="ptr__search" data-tip="1"><span class="ptr__sr">Zoeken</span><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4.5 4.5"/></svg><input type="search" placeholder="Zoek klant of factuur"></label>
<button type="button" class="ptr__new" data-tip="0">Nieuwe factuur</button>
</div>
<div class="ptr__cards">
<div class="ptr__card"><span>Openstaand</span><b>€ 4.280,00</b><small>6 facturen, 1 te laat</small></div>
<div class="ptr__card" data-tip="2"><span>Btw-aangifte Q3</span><b>€ 1.932,40</b><small>indienen voor 31 okt</small></div>
</div>
</div>
</div>
<div class="ptr__dots"></div>
<button type="button" class="ptr__start">Rondleiding starten</button>
<div class="ptr__ring" hidden></div>
<div class="ptr__pop" role="dialog" aria-labelledby="ptr-t" aria-describedby="ptr-d" hidden>
<p class="ptr__count"></p>
<h4 class="ptr__t" id="ptr-t" tabindex="-1"></h4>
<p class="ptr__d" id="ptr-d"></p>
<div class="ptr__btns">
<button type="button" class="ptr__skip">Overslaan</button>
<button type="button" class="ptr__b" data-d="-1">Terug</button>
<button type="button" class="ptr__b ptr__b--main" data-d="1">Volgende</button>
</div>
</div>
<p class="ptr__sr" aria-live="polite"></p>
</div>
</div>/* Rondleiding met hotspots · NedDev · eigen werk. */
.ptr{--ink:#0a0a0a;--pop:#F5480F;--pd:#b83a0b;--mut:#5b5b5b;--line:rgba(10,10,10,.12);position:absolute;inset:0;overflow:hidden;font-family:'Switzer',system-ui,sans-serif;font-size:12.5px;line-height:1.4;color:var(--ink);background:#f4f3f1;container:ptr/inline-size;text-align:left}
.ptr *,.ptr *::before,.ptr *::after{box-sizing:border-box}
.ptr svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round}
.ptr__sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);margin:0}
.ptr__app{height:100%;display:grid;grid-template-columns:96px 1fr}
.ptr__side{display:flex;flex-direction:column;gap:2px;padding:12px 8px;background:var(--ink);color:#fff}
.ptr__brand{font-weight:700;font-size:13px;padding:0 6px 10px}
.ptr__side a{padding:5px 6px;border-radius:4px;color:#d8d6d2;text-decoration:none;font-size:12px;transition:background-color .15s,color .15s}
.ptr__side a:hover{background:#262626;color:#fff}
.ptr__side a[aria-current="page"]{background:#fff;color:var(--ink);font-weight:600}
.ptr__main{min-width:0;padding:12px;display:flex;flex-direction:column;gap:10px}
.ptr__top{display:flex;gap:6px;align-items:center}
.ptr__search{flex:1;min-width:0;display:flex;align-items:center;gap:6px;height:30px;padding:0 8px;border:1px solid var(--line);border-radius:4px;background:#fff;color:var(--mut)}
.ptr__search input{flex:1;min-width:0;height:100%;border:0;background:none;font:500 12px/1 'Switzer',system-ui,sans-serif;color:var(--ink)}
.ptr__search input:focus{outline:none}
.ptr__search:focus-within{outline:2px solid var(--pop);outline-offset:1px}
.ptr__new{height:30px;padding:0 10px;border:0;border-radius:4px;background:var(--ink);color:#fff;font:600 12px/1 'Switzer',system-ui,sans-serif;cursor:pointer;white-space:nowrap}
.ptr__new:hover{background:#2a2a2a}
.ptr__cards{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.ptr__card{display:flex;flex-direction:column;padding:10px;background:#fff;border:1px solid var(--line);border-radius:4px}
.ptr__card span{font-size:11.5px;color:var(--mut)}
.ptr__card b{font-size:17px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
.ptr__card small{font-size:11px;color:var(--mut)}
.ptr__dots{position:absolute;inset:0;pointer-events:none}
.ptr__dot{position:absolute;width:24px;height:24px;margin:-12px 0 0 -12px;padding:0;border:0;background:none;pointer-events:auto;cursor:pointer;display:grid;place-items:center}
.ptr__dot::before{content:'';width:10px;height:10px;border-radius:4px;background:var(--pop);outline:2px solid #fff}
.ptr__dot::after{content:'';position:absolute;width:10px;height:10px;border-radius:4px;border:2px solid var(--pop);animation:ptr-pulse 1.8s ease-out infinite}
.ptr__dot.is-seen::before{background:#b5b2ad}
.ptr__dot.is-seen::after{display:none}
.ptr.is-touring .ptr__dot{visibility:hidden}
@keyframes ptr-pulse{from{transform:scale(1);opacity:.9}to{transform:scale(2.6);opacity:0}}
.ptr__start{position:absolute;right:10px;bottom:10px;height:28px;padding:0 10px;border:1px solid var(--ink);border-radius:4px;background:#fff;color:var(--ink);font:500 12px/1 'Switzer',system-ui,sans-serif;cursor:pointer;transition:background-color .15s,color .15s}
.ptr__start:hover{background:var(--ink);color:#fff}
.ptr.is-touring .ptr__start{visibility:hidden}
.ptr__ring{position:absolute;z-index:3;border-radius:4px;outline:2px solid var(--pop);outline-offset:2px;box-shadow:0 0 0 9999px rgba(10,10,10,.45);pointer-events:none;transition:top .2s,left .2s,width .2s,height .2s}
.ptr__ring[hidden]{display:none}
.ptr__pop{position:absolute;z-index:4;width:min(236px,calc(100% - 16px));padding:10px 12px;background:#fff;border:1px solid var(--ink);border-radius:4px;transition:top .2s,left .2s}
.ptr__pop[hidden]{display:none}
.ptr__count{margin:0;font-size:11px;color:var(--mut)}
.ptr__t{margin:1px 0 3px;font-size:13.5px;font-weight:600}
.ptr__t:focus{outline:none}
.ptr__d{margin:0 0 8px;font-size:12px;color:#333}
.ptr__btns{display:flex;align-items:center;gap:4px}
.ptr__skip{margin-right:auto;padding:4px 0;border:0;background:none;font:500 11.5px/1.2 'Switzer',system-ui,sans-serif;color:var(--mut);text-decoration:underline;text-underline-offset:2px;cursor:pointer}
.ptr__skip:hover{color:var(--ink)}
.ptr__b{height:26px;padding:0 9px;border:1px solid var(--ink);border-radius:4px;background:#fff;color:var(--ink);font:500 11.5px/1 'Switzer',system-ui,sans-serif;cursor:pointer;transition:background-color .15s}
.ptr__b:hover{background:#f4f3f1}
.ptr__b:disabled{opacity:.35;cursor:not-allowed}
.ptr__b--main{background:var(--ink);color:#fff}
.ptr__b--main:hover{background:#2a2a2a}
.ptr button:focus-visible,.ptr a:focus-visible{outline:2px solid var(--pop);outline-offset:2px}
@container ptr (max-width:380px){
.ptr__app{grid-template-columns:1fr;grid-template-rows:1fr auto}
.ptr__side{order:2;flex-direction:row;justify-content:space-around;padding:6px}
.ptr__brand{display:none}
.ptr__main{padding:10px}
.ptr__start{bottom:46px}
}
@media (prefers-reduced-motion:reduce){.ptr__dot::after{animation:none;display:none}.ptr__ring,.ptr__pop,.ptr *{transition:none!important}}// Rondleiding met hotspots · NedDev · eigen werk.
// root = het element waarin de HTML staat
function init(root){
const w = root.querySelector('.ptr'); if (!w) return;
const dots = w.querySelector('.ptr__dots'), ring = w.querySelector('.ptr__ring'), pop = w.querySelector('.ptr__pop');
const start = w.querySelector('.ptr__start'), sr = w.querySelector('.ptr__sr');
const S = [
{ t: 'Nieuwe factuur', d: 'Maak een factuur in een minuut. Klantgegevens, btw en betaallink met iDEAL vullen we voor je in.' },
{ t: 'Alles terugvinden', d: 'Zoek op klantnaam, factuurnummer of bedrag. Ook een deel van een bedrag werkt, zoals 4.280.' },
{ t: 'Btw-aangifte', d: 'Hier zie je wat je dit kwartaal afdraagt. Twee weken voor de deadline krijg je een herinnering.' },
{ t: 'Bonnetjes', d: 'Fotografeer een bon met je telefoon of mail hem door. Hij staat dan vanzelf bij Uitgaven.' }
];
const seen = new Set();
let cur = -1, single = false, back = null;
const tgt = i => w.querySelector('[data-tip="' + i + '"]');
w.querySelectorAll('.ptr__side a').forEach(a => a.addEventListener('click', e => e.preventDefault()));
dots.innerHTML = S.map((s, i) => '<button type="button" class="ptr__dot" data-i="' + i + '" aria-label="Uitleg: ' + s.t + '"></button>').join('');
function rel(el) { const a = el.getBoundingClientRect(), b = w.getBoundingClientRect(); return { x: a.left - b.left, y: a.top - b.top, w: a.width, h: a.height }; }
function placeDots() {
S.forEach((s, i) => { const r = rel(tgt(i)), d = dots.children[i]; d.style.left = (r.x + r.w - 4) + 'px'; d.style.top = (r.y + 4) + 'px'; d.classList.toggle('is-seen', seen.has(i)); });
}
function place() {
if (cur < 0) return;
const r = rel(tgt(cur)), W = w.clientWidth, H = w.clientHeight;
Object.assign(ring.style, { left: r.x + 'px', top: r.y + 'px', width: r.w + 'px', height: r.h + 'px' });
const pw = pop.offsetWidth, ph = pop.offsetHeight;
let top = r.y + r.h + 10;
if (top + ph > H - 6) top = r.y - ph - 10;
if (top < 6) top = Math.max(6, Math.min(H - ph - 6, r.y + r.h / 2 - ph / 2));
let left = Math.min(Math.max(8, r.x + r.w / 2 - pw / 2), W - pw - 8);
if (top < r.y + r.h && top + ph > r.y) left = r.x > W / 2 ? Math.max(8, r.x - pw - 10) : Math.min(W - pw - 8, r.x + r.w + 10);
pop.style.top = top + 'px'; pop.style.left = left + 'px';
}
function show(i) {
cur = i; seen.add(i);
pop.querySelector('.ptr__count').textContent = single ? 'Tip' : (i + 1) + ' van ' + S.length;
pop.querySelector('.ptr__t').textContent = S[i].t;
pop.querySelector('.ptr__d').textContent = S[i].d;
const b = pop.querySelector('[data-d="-1"]'), n = pop.querySelector('[data-d="1"]');
b.hidden = single; b.disabled = i === 0;
n.textContent = single || i === S.length - 1 ? 'Klaar' : 'Volgende';
pop.querySelector('.ptr__skip').hidden = single;
ring.hidden = false; pop.hidden = false; w.classList.add('is-touring');
place(); pop.querySelector('.ptr__t').focus();
}
function begin(i, one, from) { single = one; back = from; show(i); }
function end(msg) {
cur = -1; ring.hidden = true; pop.hidden = true; w.classList.remove('is-touring'); placeDots();
if (msg) sr.textContent = msg;
(back && w.contains(back) ? back : start).focus();
if (seen.size === S.length) start.textContent = 'Rondleiding opnieuw';
}
start.addEventListener('click', () => begin(0, false, start));
dots.addEventListener('click', e => { const d = e.target.closest('.ptr__dot'); if (d) begin(+d.dataset.i, true, d); });
pop.addEventListener('click', e => {
if (e.target.closest('.ptr__skip')) { end('Rondleiding overgeslagen. Je vindt hem terug onder de knop rechtsonder.'); return; }
const b = e.target.closest('[data-d]'); if (!b) return;
const n = cur + +b.dataset.d;
if (single || n >= S.length) end(single ? '' : 'Rondleiding afgerond.'); else if (n >= 0) show(n);
});
w.addEventListener('keydown', e => {
if (cur < 0) return;
if (e.key === 'Escape') { e.preventDefault(); end('Rondleiding gesloten.'); return; }
if (!single && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) { e.preventDefault(); const n = cur + (e.key === 'ArrowRight' ? 1 : -1); if (n >= 0 && n < S.length) show(n); return; }
if (e.key === 'Tab') {
const f = [...pop.querySelectorAll('button')].filter(x => !x.hidden && !x.disabled);
const i = f.indexOf(document.activeElement);
if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus(); }
else if (!e.shiftKey && (i === f.length - 1 || i < 0)) { e.preventDefault(); f[i < 0 ? 0 : 0].focus(); }
}
});
if (window.ResizeObserver) new ResizeObserver(() => { placeDots(); place(); }).observe(w);
placeDots();
}
init(document.querySelector('.jouw-container'));
More components
Seen something you like?
Add it to your selection and send it over. We build your site around it, in your brand.
Get in touch →