Bar chart (pure CSS)
No library needed — each bar is a div whose height is the value, animated up on load.
<div class="bar-chart"> <div class="bar-col"><div class="bar" style="height:75%"></div><span>Tue</span></div> <!-- one column per value; height = the value % --> </div>
.bar-chart { display: flex; align-items: flex-end; gap: 16px; height: 180px; }
.bar-col { flex: 1; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; }
.bar { background: linear-gradient(180deg,#2dd4bf,#14b8a6); border-radius: 8px 8px 0 0;
transform: scaleY(0); transform-origin: bottom; animation: grow .9s ease forwards; }
@keyframes grow { to { transform: scaleY(1); } } /* bars rise on load */
Line chart (SVG)
A single <polyline> plots the points; a stroke animation draws it left to right.
<svg viewBox="0 0 300 140">
<polyline fill="none" stroke="#14b8a6" stroke-width="3"
points="0,110 60,70 120,90 180,30 240,55 300,20" />
</svg>
<!-- each "x,y" is a data point; lower y = higher value -->
polyline { stroke-dasharray: 600; stroke-dashoffset: 600;
animation: draw 1.6s ease forwards; }
@keyframes draw { to { stroke-dashoffset: 0; } } /* "draws" the line */
Donut chart & stat counters
A conic-gradient makes a donut with no SVG, and a little JS counts numbers up from zero for that dashboard "wow". Press play:
<div class="donut-wrap">
<div class="donut"></div>
<div class="legend">
<span><i style="background:#14b8a6;"></i> Direct — 62%</span>
<span><i style="background:#6366f1;"></i> Social — 25%</span>
<span><i style="background:#e5e7eb;"></i> Other — 13%</span>
</div>
</div>
<div class="counters">
<div class="counter"><div class="n" data-to="12480">0</div><div class="l">Users</div></div>
<div class="counter"><div class="n" data-to="98">0</div><div class="l">% uptime</div></div>
<div class="counter"><div class="n" data-to="340">0</div><div class="l">Sales</div></div>
</div>
.donut-wrap { display: flex; align-items: center; gap: 24px; flex-wrap: wrap; }
.donut { width: 130px; height: 130px; border-radius: 50%;
background: conic-gradient(#14b8a6 0 62%, #6366f1 0 87%, #e5e7eb 0); /* slices add up */
display: flex; align-items: center; justify-content: center; }
.donut::before { content: ''; width: 86px; height: 86px; background: #fff; border-radius: 50%; } /* hole */
.legend { display: grid; gap: 8px; font-size: .85rem; }
.legend span { display: flex; align-items: center; gap: 8px; }
.legend i { width: 12px; height: 12px; border-radius: 3px; }
.counters { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 16px; text-align: center; }
.counter .n { font-size: 2.2rem; font-weight: 800; color: #0f766e; font-variant-numeric: tabular-nums; }
.counter .l { font-size: .8rem; color: #6b7280; }
document.querySelectorAll('[data-to]').forEach(el => {
const end = +el.dataset.to; let n = 0;
const step = end / 60; // ~1 second at 60fps
const t = setInterval(() => {
n += step;
if (n >= end) { n = end; clearInterval(t); }
el.textContent = Math.floor(n).toLocaleString();
}, 16);
});
Make a form actually submit
A plain HTML form can't email you by itself — you need a backend. The easiest for a capstone: a free service like Formspree or Netlify Forms. You just point the form's action at them.
<form action="https://formspree.io/f/YOUR_ID" method="POST"> <input name="name" type="text" required> <input name="email" type="email" required> <textarea name="message" required></textarea> <button type="submit">Send message</button> </form> <!-- sign up free at formspree.io, paste your form ID -->
<form name="contact" method="POST" data-netlify="true"> …same fields… </form> <!-- deploy to Netlify; submissions appear in your dashboard -->
Multi-step form
Break a long form into friendly steps with a progress bar. Try it — Next / Back move between steps, and the bar fills up.
<form class="form" id="msForm" onsubmit="return false"> <div class="steps-bar" id="stepsBar"><span class="on"></span><span></span><span></span></div> <div class="step active"><label>1 · Your name</label><input type="text" placeholder="Ada Lovelace"></div> <div class="step"><label>2 · Your email</label><input type="email" placeholder="you@example.com"></div> <div class="step"><label>3 · A message</label><textarea rows="3" placeholder="Hi…"></textarea></div> <div class="step-nav"><button class="btn ghost" type="button" id="msBack">← Back</button><button class="btn" type="button" id="msNext">Next →</button></div> <div class="msuccess" id="msDone">✓ All done — submitted!</div> </form>
.form { display: grid; gap: 12px; max-width: 420px; }
.form label { font-size: .78rem; font-weight: 700; color: #374151; }
.form input, .form textarea { width: 100%; padding: 11px 13px; border: 1px solid #d1d5db; border-radius: 9px; font: inherit; }
.btn { border: none; background: #14b8a6; color: #fff; font-weight: 700; padding: 11px 20px; border-radius: 9px; cursor: pointer; }
.btn.ghost { background: #f0fdfa; color: #0f766e; }
.steps-bar { display: flex; gap: 6px; margin-bottom: 16px; }
.steps-bar span { flex: 1; height: 6px; border-radius: 3px; background: #e5e7eb; }
.steps-bar span.on { background: #14b8a6; }
.step { display: none; }
.step.active { display: grid; gap: 12px; }
.step-nav { display: flex; justify-content: space-between; margin-top: 6px; }
.msuccess { text-align: center; color: #0f766e; font-weight: 700; padding: 20px; display: none; }
.msuccess.show { display: block; }
const form = document.getElementById('msForm');
const steps = form.querySelectorAll('.step');
const bars = document.querySelectorAll('#stepsBar span');
const back = document.getElementById('msBack');
const next = document.getElementById('msNext');
const done = document.getElementById('msDone');
let step = 0;
function show(i) {
steps[step].classList.remove('active');
step = Math.max(0, Math.min(steps.length - 1, i));
steps[step].classList.add('active');
for (let k = 0; k <= step; k++) bars[k].classList.add('on'); // fill progress
back.style.visibility = step === 0 ? 'hidden' : 'visible';
next.textContent = step === steps.length - 1 ? 'Submit' : 'Next →';
}
next.onclick = () => {
if (step === steps.length - 1) { // last step → finish
form.querySelectorAll('.step, .step-nav').forEach(e => e.style.display = 'none');
done.classList.add('show');
} else show(step + 1);
};
back.onclick = () => show(step - 1);
show(0);
File upload with live preview
Let users pick an image and see it instantly before uploading — FileReader reads the file in the browser. Click the box and choose any image.
<div class="drop" id="drop">Click to choose an image</div> <input type="file" id="fileInput" accept="image/*" hidden> <div class="preview" id="preview"><img id="previewImg" alt="Preview"></div>
.drop { border: 2px dashed #99f6e4; border-radius: 12px; padding: 26px; text-align: center; color: #0f766e; cursor: pointer; }
.drop i { font-size: 2rem; display: block; margin-bottom: 6px; }
.preview { margin-top: 12px; display: none; }
.preview img { max-width: 100%; max-height: 160px; border-radius: 10px; }
.preview.show { display: block; }
const drop = document.getElementById('drop');
const file = document.getElementById('fileInput');
const preview = document.getElementById('preview');
const previewImg = document.getElementById('previewImg');
drop.onclick = () => file.click(); // open the file picker
file.onchange = () => {
const f = file.files[0];
if (!f) return;
const reader = new FileReader();
reader.onload = e => { // read as a data URL → show it
previewImg.src = e.target.result;
preview.classList.add('show');
drop.textContent = f.name;
};
reader.readAsDataURL(f);
};
The takeaway
Charts need no library: div heights for bars, an SVG polyline for lines,
a conic-gradient for donuts, and a tiny loop to count numbers up. For forms,
point action at Formspree or Netlify to actually receive
submissions, split long ones into steps, and use FileReader for instant
upload previews.