Prérequis
- Niveau : bases HTML/CSS/JS, notions Flexbox + Grid + Canvas.
- Outils : VS Code + Live Server, navigateur moderne.
- Temps estimé : 2 h.
Pourquoi un dashboard sans framework ?
Construire un dashboard en HTML/CSS/JS pur vous fait maîtriser les briques qu’utilisent React, Vue ou Angular ensuite. C’est aussi suffisant pour 80 % des projets internes : pas de build, pas de dépendances, déploiement instantané sur un simple Apache ou GitHub Pages.
Ce que vous allez construire
Un tableau de bord (dashboard) complet avec des cartes de statistiques, un graphique, une liste de transactions récentes et un layout responsive avec sidebar. Ce type d’interface est utilisé dans les outils de gestion, les panels admin, et les applications SaaS. Tout est construit en HTML, CSS et JavaScript vanilla.
La structure HTML
<div class="dashboard">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-brand">
<h2>MonApp</h2>
</div>
<nav class="sidebar-nav">
<a href="#" class="nav-item actif">📊 Dashboard</a>
<a href="#" class="nav-item">👥 Clients</a>
<a href="#" class="nav-item">📦 Produits</a>
<a href="#" class="nav-item">💰 Ventes</a>
<a href="#" class="nav-item">⚙️ Paramètres</a>
</nav>
</aside>
<!-- Contenu principal -->
<main class="main-content">
<header class="top-bar">
<h1>Tableau de bord</h1>
<div class="user-info">
<span>Bienvenue, Mamadou</span>
</div>
</header>
<!-- Cartes de statistiques -->
<div class="stats-grid">
<div class="stat-card">
<span class="stat-label">Revenu du mois</span>
<span class="stat-value">2 450 000 FCFA</span>
<span class="stat-change positive">+12.5%</span>
</div>
<div class="stat-card">
<span class="stat-label">Nouveaux clients</span>
<span class="stat-value">38</span>
<span class="stat-change positive">+8.2%</span>
</div>
<div class="stat-card">
<span class="stat-label">Commandes</span>
<span class="stat-value">156</span>
<span class="stat-change negative">-3.1%</span>
</div>
<div class="stat-card">
<span class="stat-label">Taux de conversion</span>
<span class="stat-value">4.7%</span>
<span class="stat-change positive">+0.8%</span>
</div>
</div>
<!-- Graphique + Transactions -->
<div class="content-grid">
<div class="chart-card">
<h3>Revenus mensuels</h3>
<canvas id="revenueChart" height="300"></canvas>
</div>
<div class="transactions-card">
<h3>Transactions récentes</h3>
<ul id="transactionList" class="transaction-list"></ul>
</div>
</div>
</main>
</div>
Le CSS du dashboard
* { margin: 0; padding: 0; box-sizing: border-box; }
.dashboard {
display: grid;
grid-template-columns: 250px 1fr;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* Sidebar */
.sidebar {
background: #1a1a2e;
color: white;
padding: 20px 0;
}
.sidebar-brand {
padding: 0 20px 20px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.nav-item {
display: block;
padding: 12px 20px;
color: rgba(255,255,255,0.7);
text-decoration: none;
transition: all 0.2s;
}
.nav-item:hover, .nav-item.actif {
background: rgba(255,255,255,0.1);
color: white;
}
/* Contenu principal */
.main-content {
background: #f5f7fa;
padding: 24px;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
/* Cartes statistiques */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.stat-label {
display: block;
font-size: 0.85rem;
color: #666;
margin-bottom: 8px;
}
.stat-value {
display: block;
font-size: 1.8rem;
font-weight: 700;
color: #1a1a2e;
}
.stat-change {
font-size: 0.85rem;
font-weight: 600;
}
.stat-change.positive { color: #27ae60; }
.stat-change.negative { color: #e74c3c; }
/* Graphique et transactions */
.content-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
}
.chart-card, .transactions-card {
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.transaction-list {
list-style: none;
}
.transaction-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.transaction-item .amount.credit { color: #27ae60; }
.transaction-item .amount.debit { color: #e74c3c; }
/* Responsive */
@media (max-width: 768px) {
.dashboard { grid-template-columns: 1fr; }
.sidebar { display: none; } /* Menu hamburger en JS */
.content-grid { grid-template-columns: 1fr; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
}
Le JavaScript : graphique simple avec Canvas
// Dessiner un graphique en barres basique
function drawBarChart(canvasId, data) {
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext('2d');
canvas.width = canvas.parentElement.clientWidth - 40;
canvas.height = 300;
const maxValue = Math.max(...data.map(d => d.value));
const barWidth = (canvas.width / data.length) - 10;
const chartHeight = canvas.height - 40;
data.forEach((item, index) => {
const barHeight = (item.value / maxValue) * chartHeight;
const x = index * (barWidth + 10) + 5;
const y = chartHeight - barHeight;
// Barre
ctx.fillStyle = '#4a90d9';
ctx.beginPath();
ctx.roundRect(x, y, barWidth, barHeight, 4);
ctx.fill();
// Label
ctx.fillStyle = '#666';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(item.label, x + barWidth / 2, canvas.height - 5);
// Valeur
ctx.fillStyle = '#333';
ctx.fillText((item.value / 1000000).toFixed(1) + 'M', x + barWidth / 2, y - 8);
});
}
// Données de revenu mensuel
const revenueData = [
{ label: 'Jan', value: 1800000 },
{ label: 'Fév', value: 2100000 },
{ label: 'Mar', value: 1950000 },
{ label: 'Avr', value: 2300000 },
{ label: 'Mai', value: 2450000 },
{ label: 'Jun', value: 2200000 }
];
drawBarChart('revenueChart', revenueData);
// Liste des transactions
const transactions = [
{ client: 'Fatou N.', desc: 'Site vitrine', amount: 350000, type: 'credit' },
{ client: 'Orange SN', desc: 'Hébergement', amount: -25000, type: 'debit' },
{ client: 'Moussa D.', desc: 'Application mobile', amount: 800000, type: 'credit' },
{ client: 'Namecheap', desc: 'Domaine .io', amount: -12000, type: 'debit' },
{ client: 'Awa S.', desc: 'Refonte e-commerce', amount: 500000, type: 'credit' }
];
const transactionList = document.getElementById('transactionList');
transactionList.innerHTML = transactions.map(t =>
'<li class="transaction-item">' +
'<div><strong>' + t.client + '</strong><br><small>' + t.desc + '</small></div>' +
'<span class="amount ' + t.type + '">' +
(t.type === 'credit' ? '+' : '') + t.amount.toLocaleString('fr-FR') + ' F' +
'</span>' +
'</li>'
).join('');
Erreurs fréquentes
Canvas flou ou pixelisé sur écran HiDPI
Cause : on définit canvas.width mais pas en tenant compte de devicePixelRatio.
Solution : multipliez la résolution par window.devicePixelRatio et appliquez ctx.scale(dpr, dpr).
Sidebar qui prend toute la hauteur sur mobile
Cause : media query qui passe en 1 colonne mais sidebar non cachée.
Solution : display: none sur la sidebar en mobile + menu hamburger pour la rouvrir.
Identifiants/classes accentués
Cause : activé, négative dans les classes CSS — pose problème avec préprocesseurs et minificateurs.
Solution : ASCII partout (actif, negative).
Performance qui s’effondre avec plus de données
Cause : on redessine le canvas et reconstruit la liste à chaque rafraîchissement.
Solution : au-delà de 50 lignes, passez à Chart.js + DOM virtualisé. Pour beaucoup de données réelles, évaluez Plotly ou ECharts.
Exercice
- Ajoutez un menu hamburger pour afficher/masquer la sidebar sur mobile
- Remplacez le graphique Canvas par Chart.js pour plus de fonctionnalités
- Ajoutez un filtre de date sur les transactions
- Ajoutez un mode sombre avec un toggle dans le top-bar
Sur un angle proche
- Manipuler le DOM
- CSS Grid Layout
- Ajouter un mode sombre
- Bibliothèque graphique : Chart.js
- Référence : MDN — Canvas API
Étape 1 — Créer la structure HTML de base
Pourquoi un seul fichier index.html au départ : pour itérer vite sans bundler. On ajoute Vite ou Webpack uniquement quand le projet dépasse 500 lignes ou nécessite TypeScript.
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Tableau de bord PME</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="topbar"><h1>Tableau de bord</h1></header>
<aside class="sidebar"><nav>...</nav></aside>
<main class="content">
<section class="kpi-row"></section>
<section class="charts-row"></section>
<section class="table-row"></section>
</main>
<script type="module" src="app.js"></script>
</body>
</html>
Résultat type : ouvrir le fichier dans Chrome affiche le titre « Tableau de bord » et trois sections vides. Pas d’erreur dans la console (F12 → Console). Pour un setup serveur local rapide : npx serve . ou python -m http.server 8000.
Étape 2 — Poser le layout CSS avec Grid et Flex
Pourquoi cette répartition : Grid pour la macro-structure (2 colonnes, 2 lignes), Flex pour les éléments internes des cartes. C’est la combinaison standard d’un dashboard moderne.
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #111827; background: #f3f4f6; }
.topbar { grid-area: top; background: #fff; padding: 16px 24px; border-bottom: 1px solid #e5e7eb; }
.sidebar { grid-area: side; background: #1f2937; color: #f9fafb; padding: 16px; }
.content { grid-area: main; padding: 24px; overflow-y: auto; }
body {
display: grid;
grid-template-columns: 240px 1fr;
grid-template-rows: 64px 1fr;
grid-template-areas: "side top" "side main";
min-height: 100vh;
}
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
.charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 24px; }
@media (max-width: 768px) {
body { grid-template-columns: 1fr; grid-template-areas: "top" "main"; }
.sidebar { display: none; }
.charts-row { grid-template-columns: 1fr; }
}
Indicateur de succès : sur desktop, sidebar fixe à 240 px, content scrollable. Sur mobile (DevTools → 360×800), sidebar masquée, contenu pleine largeur. Aucune barre de scroll horizontale.
Étape 3 — Afficher 4 KPI dynamiques
Cas d’usage PME à Dakar ou Abidjan : chiffre d’affaires du jour, commandes en cours, panier moyen, taux de conversion. Données simulées au départ, branchées sur l’API plus tard.
// app.js
const kpis = [
{ label: "CA du jour", value: 1845000, suffix: " FCFA" },
{ label: "Commandes", value: 42, suffix: "" },
{ label: "Panier moyen", value: 43928, suffix: " FCFA" },
{ label: "Taux conv.", value: 3.2, suffix: " %" }
];
const fmt = new Intl.NumberFormat("fr-FR");
const row = document.querySelector(".kpi-row");
row.innerHTML = kpis.map(k => `
<div class="kpi-card">
<div class="kpi-label">${k.label}</div>
<div class="kpi-value">${fmt.format(k.value)}${k.suffix}</div>
</div>`).join("");
Vous devriez obtenir : 4 cartes blanches avec une étiquette grise et une valeur en gros. La valeur « 1 845 000 FCFA » apparaît avec espace insécable comme séparateur de milliers (locale fr-FR). Si vous voyez « 1,845,000 », la locale n’est pas appliquée — vérifiez l’argument de Intl.NumberFormat.
Étape 4 — Styliser les cartes KPI
Pourquoi peu de couleurs : un dashboard se lit en 3 secondes. Trop de couleurs distrait. On garde blanc + gris + un accent (bleu ou vert) par défaut.
.kpi-card {
background: #fff;
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,.04);
display: flex;
flex-direction: column;
gap: 8px;
}
.kpi-label { font-size: 13px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; }
.kpi-value { font-size: 24px; font-weight: 700; color: #111827; }
Validation visuelle : les cartes ont l’air « respirantes » — bords arrondis, ombre douce, pas de bordure agressive. Sur Lighthouse, l’accessibilité doit rester ≥ 95 (contraste suffisant entre #6b7280 et #fff = 4.5:1).
Étape 5 — Ajouter un graphique simple sans librairie
Pourquoi pas Chart.js ou ApexCharts dès le départ : un graphique en barres tient en 30 lignes de SVG. Sur une connexion 3G à Bamako, charger 80 Ko de Chart.js juste pour 7 barres ralentit la première peinture.
const ventes = [120000, 180000, 95000, 240000, 310000, 280000, 220000];
const max = Math.max(...ventes);
const svg = document.querySelector("#chart-ventes");
const barW = 40, gap = 16, h = 200;
svg.setAttribute("viewBox", `0 0 ${(barW+gap)*ventes.length} ${h}`);
ventes.forEach((v, i) => {
const bh = (v / max) * (h - 30);
const x = i * (barW + gap);
const y = h - bh - 20;
svg.innerHTML += `<rect x="${x}" y="${y}" width="${barW}" height="${bh}" fill="#2563eb" rx="4"/>`;
svg.innerHTML += `<text x="${x+barW/2}" y="${h-4}" text-anchor="middle" font-size="11" fill="#6b7280">J${i+1}</text>`;
});
Sortie attendue : 7 barres bleues alignées, hauteur proportionnelle au CA quotidien. Étiquettes J1 à J7 sous chaque barre. Le SVG s’adapte automatiquement au conteneur via viewBox.
Étape 6 — Charger les données depuis une API
Cas réel : votre PME utilise WooCommerce ou un backend Express. On remplace les données simulées par un fetch. Mode dégradé : afficher les anciennes valeurs si l’API échoue.
async function loadKpis() {
try {
const r = await fetch("/api/kpis", { headers: { Accept: "application/json" } });
if (!r.ok) throw new Error("HTTP " + r.status);
return await r.json();
} catch (e) {
console.warn("API KPI indisponible, fallback :", e.message);
return kpis; // valeurs par défaut
}
}
const data = await loadKpis();
renderKpis(data);
Indicateur de succès : avec API en ligne, valeurs fraîches affichées en moins de 500 ms. Avec API coupée (testez via DevTools → Network → Offline), valeurs par défaut affichées et avertissement console « API KPI indisponible ».
Étape 7 — Ajouter un tableau filtrable
Tableau des dernières commandes : numéro, client, montant FCFA, statut. Filtre simple par statut sans framework.
const orders = [
{ id: "C-1042", client: "A. Sow", total: 38500, status: "payée" },
{ id: "C-1041", client: "M. Diallo", total: 92000, status: "en cours" },
{ id: "C-1040", client: "F. Ndiaye", total: 17800, status: "payée" }
];
function renderOrders(filter = "") {
const body = document.querySelector("#orders-body");
body.innerHTML = orders
.filter(o => !filter || o.status === filter)
.map(o => `<tr><td>${o.id}</td><td>${o.client}</td><td>${fmt.format(o.total)} FCFA</td><td>${o.status}</td></tr>`)
.join("");
}
document.querySelector("#filter-status").addEventListener("change", e => renderOrders(e.target.value));
renderOrders();
Résultat type : tableau de 3 lignes au chargement. Sélectionner « payée » dans le filtre fait disparaître la ligne C-1041. Sélectionner « Tous » (valeur vide) affiche les 3.
Étape 8 — Tester le responsive et déployer
Avant déploiement, vérifier sur 3 résolutions : 360×800 (Tecno, Infinix), 768×1024 (tablette), 1366×768 (PC). Aucune zone tactile sous 44×44 px, aucun texte sous 14 px.
# Build statique
mkdir dist && cp index.html style.css app.js dist/
# Déploiement Netlify (gratuit, CDN edge inclus)
npx netlify deploy --prod --dir dist
Test concluant : Lighthouse mobile renvoie ≥ 90 sur Performance et Accessibilité. Le First Contentful Paint reste sous 1,8 s sur réseau 4G simulé. À lire ensuite, voyez le guide front-end principal et le tutoriel tableau de bord HTML CSS JS qui couvre l’auth et les rôles utilisateurs.
Étape 9 — Sécuriser l’accès au tableau de bord
Pourquoi cette étape avant production : un dashboard interne contenant CA, paniers et commandes ne doit pas être public. Trois lignes de défense suffisent au niveau frontal.
// Garde simple côté client (le vrai contrôle reste serveur)
const token = localStorage.getItem("dash_token");
if (!token) { window.location.href = "/login.html"; }
fetch("/api/kpis", { headers: { Authorization: "Bearer " + token } })
.then(r => r.status === 401 ? (window.location.href = "/login.html") : r.json());
Vous devriez obtenir : sans token, redirection vers /login.html en moins de 100 ms. Avec token expiré, le serveur renvoie 401 et la même redirection se déclenche. Côté serveur (Express), validez systématiquement le JWT dans un middleware et limitez les requêtes à 60/min par IP via express-rate-limit pour éviter le scraping. Sur un VPS Hetzner CX22 ≈ 4,51 EUR par mois (≈ 2 960 FCFA), ces protections tiennent un trafic PME confortable sans coût supplémentaire.