Show off your numbers
A row of big numbers with short labels — great for landing pages (“10k users, 99.9% uptime”). A responsive grid keeps them evenly spaced.
HTML
<div class="stats"> <div class="stat"><div class="num" data-target="12000" data-suffix="+">0</div><div class="lbl">Active users</div></div> <div class="stat"><div class="num" data-target="99.9" data-suffix="%">0</div><div class="lbl">Uptime</div></div> <div class="stat"><div class="num" data-target="4.9" data-suffix="★">0</div><div class="lbl">Avg. rating</div></div> <div class="stat"><div class="num" data-target="36">0</div><div class="lbl">Countries</div></div> </div>
CSS
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 18px; text-align: center;
}
.stat .num { font-size: 2.2rem; font-weight: 800; color: #38bdf8; line-height: 1; }
.stat .lbl { font-size: .82rem; color: #6b7280; font-weight: 600; margin-top: 6px; }
JavaScript — count up when scrolled into view
// Animates every .num[data-target] from 0 to its target when it scrolls into view.
// Uses IntersectionObserver to trigger once, then requestAnimationFrame to animate.
const counters = document.querySelectorAll('.stat .num[data-target]');
function format(n, target) {
const decimals = String(target).indexOf('.') > -1 ? 1 : 0;
if (decimals) return n.toFixed(1);
return Math.round(n) >= 1000
? (Math.round(n) / 1000).toFixed(0) + 'k'
: Math.round(n).toString();
}
function animate(el) {
const target = parseFloat(el.dataset.target);
const suffix = el.dataset.suffix || '';
const duration = 1400;
let start = null;
function step(t) {
if (start === null) start = t;
const p = Math.min((t - start) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3); // easeOutCubic
el.textContent = format(target * eased, target) + suffix;
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
animate(e.target);
io.unobserve(e.target);
}
});
}, { threshold: 0.4 });
counters.forEach((c) => io.observe(c));
data-target attribute on each .num tells the script the final value. Strings like "12k+" become numbers (12000) plus an optional data-suffix ("+") appended after.A progress ring — no SVG needed
A single conic-gradient draws the ring; a smaller circle on top punches the hole. Change one custom property (--p) to set the percentage.
HTML
<div class="rings"> <div class="ring" style="--p:0" data-percent="25"><span>0%</span></div> <div class="ring" style="--p:0" data-percent="60"><span>0%</span></div> <div class="ring" style="--p:0" data-percent="90"><span>0%</span></div> </div>
CSS
.rings { display: flex; gap: 26px; flex-wrap: wrap; align-items: center; }
.ring {
width: 104px; height: 104px; border-radius: 50%;
display: grid; place-items: center;
background: conic-gradient(#38bdf8 calc(var(--p) * 1%), #e5e7eb 0);
transition: --p 1.2s ease; /* needs @property for browsers that support it */
}
.ring span { /* center hole + label */
width: 78px; height: 78px; border-radius: 50%;
background: #fff; display: grid; place-items: center;
font-weight: 800; color: #111; font-size: 1.1rem;
}
@property --p { syntax: '<number>'; inherits: false; initial-value: 0; }
JavaScript — animate the ring fill on scroll-into-view
// Animates each .ring's --p from 0 to data-percent when scrolled into view.
// Updates both the conic-gradient (via the CSS variable) and the label.
const rings = document.querySelectorAll('.ring[data-percent]');
function animateRing(ring) {
const target = parseFloat(ring.dataset.percent);
const label = ring.querySelector('span');
const duration = 1200;
let start = null;
function step(t) {
if (start === null) start = t;
const p = Math.min((t - start) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3);
const current = target * eased;
ring.style.setProperty('--p', current);
if (label) label.textContent = Math.round(current) + '%';
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
const ringObserver = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
animateRing(e.target);
ringObserver.unobserve(e.target);
}
});
}, { threshold: 0.5 });
rings.forEach((r) => ringObserver.observe(r));
<svg> circle with stroke-dasharray gives you more control. The JS above works with the pure-CSS conic-gradient version by tweening the --p custom property.A linear bar that fills to a value
The horizontal counterpart to the ring above — a track with a coloured fill whose width is the percentage. Use it for uploads, completion, or any 0–100 value. Add a stripe or gradient for a richer look.
HTML
<div style="max-width:420px;display:grid;gap:16px;">
<div>
<div style="display:flex;justify-content:space-between;font-size:.8rem;color:#374151;margin-bottom:5px;"><span>Uploading…</span><span>35%</span></div>
<div class="bar"><i data-progress="35" style="width:0%;"></i></div>
</div>
<div>
<div style="display:flex;justify-content:space-between;font-size:.8rem;color:#374151;margin-bottom:5px;"><span>Profile complete</span><span>80%</span></div>
<div class="bar"><i class="grad" data-progress="80" style="width:0%;"></i></div>
</div>
</div>
CSS
.bar { background: #e5e7eb; border-radius: 999px; height: 12px; overflow: hidden; }
.bar i { display: block; height: 100%; border-radius: 999px;
background: #38bdf8; transition: width 1s ease; }
.bar i.grad { background: linear-gradient(90deg, #38bdf8, #22c55e); }
JavaScript — fill the bar when it scrolls into view
// Animates each .bar i from 0% to its data-progress value on scroll-into-view.
// The CSS transition does the actual animation; JS just flips the width.
const fills = document.querySelectorAll('.bar i[data-progress]');
const barObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const fill = entry.target;
const target = fill.dataset.progress + '%';
// requestAnimationFrame so the transition actually fires from 0%
requestAnimationFrame(() => { fill.style.width = target; });
barObserver.unobserve(fill);
}
});
}, { threshold: 0.5 });
fills.forEach((f) => barObserver.observe(f));
<progress value="80" max="100"></progress> element works too — style it with ::-webkit-progress-value.Present what you offer
A service card pairs an icon, a service name, a short description, and a link — the “What we do” row on most business sites. A responsive grid lays out three or more; a subtle lift on hover adds polish.
HTML
<div class="services">
<div class="service">
<div class="icon">✏</div>
<h3>Web Design</h3>
<p>Clean, responsive layouts built to match your brand.</p>
<a href="/services/design">Learn more →</a>
</div>
<div class="service">
<div class="icon" style="background:#ede9fe;color:#7c3aed;">💻</div>
<h3>Development</h3>
<p>Fast, accessible sites coded with modern HTML & CSS.</p>
<a href="/services/development">Learn more →</a>
</div>
<div class="service">
<div class="icon" style="background:#dcfce7;color:#16a34a;">🚀</div>
<h3>Launch & SEO</h3>
<p>We deploy, optimize, and help people find you.</p>
<a href="/services/launch">Learn more →</a>
</div>
</div>
CSS
.services { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
.service { background: #fff; border: 1px solid #e5e7eb; border-radius: 14px; padding: 22px;
transition: transform .2s, box-shadow .2s; }
.service:hover { transform: translateY(-3px); box-shadow: 0 10px 24px rgba(0,0,0,.08); } /* lift */
.service .icon { width: 52px; height: 52px; border-radius: 14px; display: grid; place-items: center;
background: #e0f2fe; color: #0284c7; font-size: 1.6rem; margin-bottom: 14px; }
.service h3 { color: #111; font-size: 1.05rem; margin-bottom: 6px; }
.service p { color: #6b7280; font-size: .86rem; margin-bottom: 12px; }
.service a { color: #0284c7; font-weight: 700; font-size: .84rem; text-decoration: none; }
When to use: a “Services,” “Features,” or “What we offer” section. Keep each card to one icon, one heading, one sentence, and one link.
The whole thing as one file
Everything above — stat counters, a progress ring, progress bars, and service cards — combined into one complete HTML file. Copy it into a new file called index.html, open it in a browser, and it just works — no libraries, no build step. The stats even count up on load. Here's the live result, then the full code.
Live preview
Complete HTML — copy this whole file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visual Design Patterns</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #1f2433; background: #f6f7fb; line-height: 1.6; padding: 32px 20px; }
.page { max-width: 880px; margin: 0 auto; display: grid; gap: 32px; }
.page h1 { font-size: 2rem; letter-spacing: -.02em; }
.page h2 { font-size: 1.1rem; margin-bottom: 16px; color: #111; }
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 16px; padding: 26px 24px; }
/* ===== Statistics counter ===== */
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 18px; text-align: center; }
.stat .num { font-size: 2.2rem; font-weight: 800; color: #38bdf8; line-height: 1; }
.stat .lbl { font-size: .82rem; color: #6b7280; font-weight: 600; margin-top: 6px; }
/* ===== Circular progress ring ===== */
.rings { display: flex; gap: 26px; flex-wrap: wrap; align-items: center; }
.ring { width: 104px; height: 104px; border-radius: 50%;
display: grid; place-items: center;
background: conic-gradient(#38bdf8 calc(var(--p) * 1%), #e5e7eb 0); }
.ring span { width: 78px; height: 78px; border-radius: 50%;
background: #fff; display: grid; place-items: center;
font-weight: 800; color: #111; font-size: 1.1rem; }
/* ===== Progress bars ===== */
.bars { max-width: 460px; display: grid; gap: 16px; }
.bar-row { display: flex; justify-content: space-between; font-size: .8rem;
color: #374151; margin-bottom: 5px; }
.bar { background: #e5e7eb; border-radius: 999px; height: 12px; overflow: hidden; }
.bar i { display: block; height: 100%; border-radius: 999px;
background: #38bdf8; transition: width .8s ease; }
.bar i.grad { background: linear-gradient(90deg, #38bdf8, #22c55e); }
/* ===== Service cards ===== */
.services { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
.service { background: #fff; border: 1px solid #e5e7eb; border-radius: 14px; padding: 22px;
transition: transform .2s, box-shadow .2s; }
.service:hover { transform: translateY(-3px); box-shadow: 0 10px 24px rgba(0,0,0,.08); }
.service .icon { width: 52px; height: 52px; border-radius: 14px; display: grid; place-items: center;
background: #e0f2fe; color: #0284c7; font-size: 1.6rem; margin-bottom: 14px; }
.service h3 { color: #111; font-size: 1.05rem; margin-bottom: 6px; }
.service p { color: #6b7280; font-size: .86rem; margin-bottom: 12px; }
.service a { color: #0284c7; font-weight: 700; font-size: .84rem; text-decoration: none; }
.service a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="page">
<h1>Visual Design Patterns</h1>
<section class="card">
<h2>By the numbers</h2>
<div class="stats">
<div class="stat"><div class="num" data-to="12000" data-suffix="+">0</div><div class="lbl">Active users</div></div>
<div class="stat"><div class="num" data-to="99" data-suffix="%">0</div><div class="lbl">Uptime</div></div>
<div class="stat"><div class="num" data-to="36">0</div><div class="lbl">Countries</div></div>
<div class="stat"><div class="num" data-to="4.9">0</div><div class="lbl">Avg. rating</div></div>
</div>
</section>
<section class="card">
<h2>Progress, two ways</h2>
<div class="rings">
<div class="ring" style="--p:25"><span>25%</span></div>
<div class="ring" style="--p:60"><span>60%</span></div>
<div class="ring" style="--p:90"><span>90%</span></div>
</div>
<div class="bars" style="margin-top:24px;">
<div>
<div class="bar-row"><span>Uploading…</span><span>35%</span></div>
<div class="bar"><i style="width:0" data-w="35%"></i></div>
</div>
<div>
<div class="bar-row"><span>Profile complete</span><span>80%</span></div>
<div class="bar"><i class="grad" style="width:0" data-w="80%"></i></div>
</div>
</div>
</section>
<section class="card">
<h2>What we offer</h2>
<div class="services">
<div class="service">
<div class="icon">✏</div>
<h3>Web Design</h3>
<p>Clean, responsive layouts built to match your brand.</p>
<a href="#">Learn more →</a>
</div>
<div class="service">
<div class="icon" style="background:#ede9fe;color:#7c3aed;">💻</div>
<h3>Development</h3>
<p>Fast, accessible sites coded with modern HTML & CSS.</p>
<a href="#">Learn more →</a>
</div>
<div class="service">
<div class="icon" style="background:#dcfce7;color:#16a34a;">🚀</div>
<h3>Launch & SEO</h3>
<p>We deploy, optimize, and help people find you.</p>
<a href="#">Learn more →</a>
</div>
</div>
</section>
</div>
<script>
/* Count the stat numbers up from 0 on load */
document.querySelectorAll('.num[data-to]').forEach(function (el) {
var to = parseFloat(el.dataset.to);
var suffix = el.dataset.suffix || '';
var decimals = (el.dataset.to.indexOf('.') > -1) ? 1 : 0;
var start = null, dur = 1400;
function fmt(n) {
if (decimals) return n.toFixed(1);
return Math.round(n) >= 1000 ? Math.round(n / 1000) + 'k' : Math.round(n).toString();
}
function step(t) {
if (start === null) start = t;
var p = Math.min((t - start) / dur, 1);
var eased = 1 - Math.pow(1 - p, 3);
el.textContent = fmt(to * eased) + suffix;
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
});
/* Fill the progress bars on load */
window.addEventListener('load', function () {
document.querySelectorAll('.bar i[data-w]').forEach(function (fill) {
requestAnimationFrame(function () { fill.style.width = fill.dataset.w; });
});
});
</script>
</body>
</html>
<script> for static values. Keep the class names (.stats, .ring, .bar, .service) as-is or rename them in both the markup and the <style>.Keep building
More visual pieces in Cards Lab, Hero Lab, and Dashboard Lab — or browse the full Component Library.