I needed a speedtest for a comparison site. Every existing option either costs money, weighs 200KB+, requires an iframe that breaks CORS, or injects ads. So I built one in vanilla JS that runs a real measurement via Cloudflare's public speedtest endpoints, renders in a Shadow DOM bubble, and ships at 6.3KB gzipped.
Here's what I learned and the patterns that made it work.
The architecture in one diagram
┌─ Host page (any website) ────────────────────┐
│ │
│ <div id="nevik-speedtest"></div> │
│ <script src="speedtest-widget.js" async> │
│ │
│ ┌─ Shadow DOM (open) ──────────────────────┐ │
│ │ <style> /* scoped CSS */ │ │
│ │ <div class="widget"> │ │
│ │ <svg> gauge, button, sparkline │ │
│ │ </div> │ │
│ └────────────────────────────────────────────┘ │
│ │
│ fetch() → speed.cloudflare.com (CORS: *) │
└────────────────────────────────────────────────┘
The widget is a self-contained IIFE that finds its mount point, creates a Shadow DOM root, injects scoped styles and markup, then waits for a click.
Why Shadow DOM and not an iframe?
- No CORS headaches (the host page's fetch works fine)
- CSS doesn't leak in or out
-
document.querySelector()on the host page can't see inside - Smaller bundle, no second document overhead
Stripe's Payment Element and Trustpilot both do this. Iframes are the Calendly/Typeform approach — easier but heavier and harder to theme.
The speedtest engine (the part that actually measures)
Cloudflare runs a public speedtest at speed.cloudflare.com. The endpoints are:
// Download: fetch N bytes, measure throughput
const CF_DOWN = 'https://speed.cloudflare.com/__down?bytes=';
const downloadSize = 10_000_000; // 10MB
const resp = await fetch(CF_DOWN + downloadSize + '&tid=' + Math.random(), {
cache: 'no-store'
});
// Upload: POST a buffer, measure throughput
const CF_UP = 'https://speed.cloudflare.com/__up';
const payload = new Uint8Array(5_000_000);
await fetch(CF_UP, { method: 'POST', body: payload, cache: 'no-store' });
Both send Access-Control-Allow-Origin: *, so they work from any origin.
Streaming the download for live progress
The trick to a live-updating speedometer isn't to measure the whole download and divide — it's to stream it:
async function measureDownload(onProgress) {
const sizes = [3_000_000, 10_000_000, 15_000_000];
let totalBytes = 0;
const tStart = performance.now();
for (const size of sizes) {
const resp = await fetch(CF_DOWN + size + '&tid=' + Math.random(), {
cache: 'no-store'
});
const reader = resp.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.length;
const elapsed = (performance.now() - tStart) / 1000;
const mbps = (totalBytes * 8 / 1_000_000) / elapsed;
onProgress(mbps); // live callback → updates the gauge
}
}
const elapsed = (performance.now() - tStart) / 1000;
return (totalBytes * 8 / 1_000_000) / elapsed;
}
Reading resp.body.getReader() lets you count bytes as they arrive and update the UI every chunk. This is how fast.com shows a number that climbs during the test.
Ping with outlier rejection
Ping is just latency to the same endpoint:
async function measurePing() {
const times = [];
for (let i = 0; i < 6; i++) {
const t0 = performance.now();
await fetch(CF_DOWN + '1000&tid=' + Math.random(), { cache: 'no-store' });
times.push(performance.now() - t0);
}
times.sort((a, b) => a - b);
times.pop(); // discard the worst outlier
return Math.round(times.reduce((s, v) => s + v, 0) / times.length);
}
Six samples, drop the worst, average the rest. Simple and stable.
The SVG gauge (a ring that fills up)
The gauge is two SVG circles — a dim track and a bright fill:
<svg viewBox="0 0 200 200">
<circle class="track" cx="100" cy="100" r="76"
fill="none" stroke="#30363d" stroke-width="8"/>
<circle class="fill" cx="100" cy="100" r="76"
fill="none" stroke="#00e8a8" stroke-width="8"
stroke-linecap="round"/>
</svg>
The fill is controlled via stroke-dasharray and stroke-dashoffset:
const RADIUS = 76;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS; // ≈ 477.5
// Set the total length
fillCircle.style.strokeDasharray = CIRCUMFERENCE;
// Fill to X percent:
function setGauge(percent) {
const offset = CIRCUMFERENCE * (1 - percent);
fillCircle.style.strokeDashoffset = offset;
}
This is the same pattern Apple uses for Activity Rings. The SVG is rotated -90° so the fill starts at 12 o'clock.
Animated number counter (the detail that makes it feel alive)
Static numbers feel dead. A counter that eases up to the final value feels responsive and premium:
function animateSpeed(target, color) {
const start = displaySpeed;
const startTime = performance.now();
const duration = 400;
function frame(now) {
const t = Math.min((now - startTime) / duration, 1);
const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
displaySpeed = start + (target - start) * eased;
speedNum.textContent = formatSpeed(displaySpeed);
setGauge(Math.min(displaySpeed / 1000, 1));
if (t < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
The key is the easing function: 1 - Math.pow(1 - t, 3). It starts fast and decelerates, which feels snappy without being jarring. This is the standard easing Stripe, Vercel, and Linear use for their number displays.
The live sparkline (a tiny chart during measurement)
While the download runs, I push speed samples to an array and render them as an SVG polyline:
const sparkData = [];
function pushSpark(speed) {
sparkData.push(speed);
if (sparkData.length > 40) sparkData.shift();
if (sparkData.length < 2) return;
const max = Math.max(...sparkData, 1);
const w = 200, h = 40;
const points = sparkData
.map((v, i) => {
const x = (i / (sparkData.length - 1)) * w;
const y = h - (Math.min(v / max, 1) * h * 0.85) - 4;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
polyline.setAttribute('points', points);
}
Cloudflare's own speedtest shows a line graph during measurement. This is the mini version — a 200×40px sparkline that updates every ~50ms. No charting library needed.
The "Flashlight" glow (cursor-tracking radial gradient)
This is the pattern that makes the widget feel expensive. It's a radial gradient that follows the cursor:
.widget {
--x: -999px;
--y: -999px;
position: relative;
overflow: hidden;
}
.widget::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
opacity: 0;
transition: opacity 0.35s ease;
background: radial-gradient(
180px circle at var(--x) var(--y),
rgba(0, 232, 168, 0.2),
transparent 50%
);
pointer-events: none;
}
.widget.flash-active::after {
opacity: 1;
}
widget.addEventListener('pointermove', (e) => {
const rect = widget.getBoundingClientRect();
widget.style.setProperty('--x', `${e.clientX - rect.left}px`);
widget.style.setProperty('--y', `${e.clientY - rect.top}px`);
});
widget.addEventListener('pointerenter', () => {
widget.classList.add('flash-active');
});
widget.addEventListener('pointerleave', () => {
widget.classList.remove('flash-active');
});
I lifted this directly from Stripe's card border effect on stripe.com/payments/elements. The cursor sets CSS variables, the gradient reads them. No JavaScript animation loop, just a CSS transition on opacity. Costs essentially nothing in performance.
Use pointermove not mousemove so it works on touch devices too.
Shadow DOM: the containment that matters
The widget mounts into a Shadow DOM so the host page can't accidentally break it:
const host = document.getElementById('nevik-speedtest');
const shadow = host.attachShadow({ mode: 'open' });
// Styles inside shadow are scoped — they can't leak out
const style = document.createElement('style');
style.textContent = widgetCSS;
shadow.appendChild(style);
// Markup lives in shadow — host page's CSS can't touch it
const wrap = document.createElement('div');
wrap.className = 'widget';
wrap.innerHTML = widgetHTML;
shadow.appendChild(wrap);
This means even if the host page has * { margin: 0 !important; } or weird global resets, the widget is untouched. Stripe and Trustpilot do the same thing.
What I'd do differently
- Cloudflare rate limits the endpoints. For a production widget on a high-traffic site, you'd want your own download/upload endpoints as a fallback.
- The upload measurement is rough. A single 5MB POST isn't as accurate as the multi-file approach Cloudflare uses internally. Good enough for a consumer widget, not for lab-grade measurement.
-
Shadow DOM
mode: 'closed'would be more secure (host JS can't read internals), but I went with'open'so the widget is debuggable and the demo page can inspect it.
The result
- 6.3KB gzipped, zero dependencies
- Real measurement via Cloudflare (not a fake animation)
- Shadow DOM isolated — works on any page without conflicts
- Works in one line:
<div id="nevik-speedtest"></div>+ script tag - Dark/light themes, respects
prefers-reduced-motion - Live demo and embed code here
The full source is MIT-licensed. If you need a speedtest on your site without the bloat, give it a try.
Have you built embeddable widgets before? What patterns do you use for isolation — Shadow DOM, iframes, or something else?
Top comments (0)