Le web pour tous
sans effort surhumain
L'accessibilité n'est pas une couche en plus : c'est du HTML bien posé et quelques outils natifs. Focus clavier, annonces vocales, contraste, mouvement, formulaires — six réflexes qui changent tout.
:focus-visible — focus clavier
Le bon compromis : afficher l'anneau de focus uniquement
quand l'utilisateur navigue au clavier, pas au clic souris.
:focus-visible le fait nativement. Clique un bouton,
puis navigue avec Tab pour voir la différence.
<button class="btn">Bouton A</button>
<button class="btn">Bouton B</button>
<button class="btn">Bouton C</button>
.btn {
padding: .5rem 1rem; color: #c9d4e8;
background: #1a2333; border: 1px solid #1e2d44; border-radius: 6px;
}
/* anneau de focus SEULEMENT au clavier */
.btn:focus-visible { outline: 3px solid #c9952a; outline-offset: 2px; }
/* jamais au clic souris */
.btn:focus:not(:focus-visible) { outline: none; }
ARIA & aria-live
Une région live annonce ses changements au lecteur
d'écran, sans déplacer le focus. Indispensable pour les
notifications, paniers, chargements. Clique et le message est
vocalisé (aria-live="polite").
<button id="add">Ajouter au panier</button>
<div id="live" role="status" aria-live="polite"></div>
let count = 0;
document.getElementById('add').addEventListener('click', () => {
count += 1;
// le lecteur d'écran annonce ce nouveau texte, sans voler le focus
document.getElementById('live').textContent =
`Article ajouté — ${count} article${count > 1 ? 's' : ''} dans le panier`;
});
Contraste WCAG — vérificateur
Le contraste texte/fond doit atteindre 4.5:1 (texte normal) ou 3:1 (grand texte) pour le niveau AA. Choisis deux couleurs et vérifie le ratio en direct — la formule officielle de luminance relative.
<div class="preview" id="preview">Texte d'exemple — Aa</div>
<label>Texte <input type="color" id="fg" value="#8492ac"></label>
<label>Fond <input type="color" id="bg" value="#0d1117"></label>
<div id="result" role="status" aria-live="polite"></div>
.preview { padding: 1.5rem; border-radius: 8px; text-align: center; font-size: 1.2rem; }
label { display: inline-flex; gap: .4rem; margin-right: 1rem; color: #c9d4e8; }
#result { margin-top: .75rem; font-family: monospace; font-size: .85rem; color: #c9d4e8; }
const fg = document.getElementById('fg');
const bg = document.getElementById('bg');
const preview = document.getElementById('preview');
const result = document.getElementById('result');
// luminance relative (formule officielle WCAG)
const chan = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
function luminance(hex) {
const h = hex.replace('#', '');
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
return 0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b);
}
function update() {
preview.style.color = fg.value;
preview.style.background = bg.value;
const l1 = luminance(fg.value), l2 = luminance(bg.value);
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
let verdict = 'échec — contraste insuffisant';
if (ratio >= 7) verdict = 'AAA ✓';
else if (ratio >= 4.5) verdict = 'AA ✓ texte normal';
else if (ratio >= 3) verdict = 'AA grand texte seulement';
result.textContent = `Ratio ${ratio.toFixed(2)}:1 — ${verdict}`;
}
fg.addEventListener('input', update);
bg.addEventListener('input', update);
update();
prefers-reduced-motion
Certaines personnes sont gênées, voire malades, face au
mouvement. @media (prefers-reduced-motion: reduce)
détecte leur réglage système et coupe les animations. L'anneau
ci-dessous s'arrête si tu as activé cette préférence.
<div class="spinner"></div>
<p id="status" role="status" aria-live="polite"></p>
.spinner {
width: 40px; height: 40px; border-radius: 50%;
border: 4px solid #1e2d44; border-top-color: #c9952a;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* respecte le réglage système → coupe l'animation */
@media (prefers-reduced-motion: reduce) {
.spinner { animation: none; }
}
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
const show = () => {
document.getElementById('status').textContent = mq.matches
? 'Réduction du mouvement demandée → animation coupée.'
: 'Aucune réduction demandée → animation active.';
};
show();
mq.addEventListener('change', show);
Formulaires accessibles
Un <label> relié, un message d'erreur annoncé
via aria-describedby + role="alert", et
aria-invalid : le lecteur d'écran sait quoi corriger.
Valide avec un e-mail incorrect pour voir.
<form id="form" novalidate>
<label for="email">Adresse e-mail</label>
<input type="email" id="email" required aria-describedby="err"
placeholder="prenom@exemple.be">
<p id="err" role="alert"></p>
<button type="submit">Valider</button>
</form>
const form = document.getElementById('form');
const email = document.getElementById('email');
const err = document.getElementById('err');
form.addEventListener('submit', (e) => {
e.preventDefault();
const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value.trim());
// aria-invalid + role="alert" → le lecteur d'écran annonce quoi corriger
email.setAttribute('aria-invalid', String(!valid));
err.textContent = valid
? 'Parfait, adresse valide ✓'
: 'Merci d’entrer une adresse e-mail valide.';
if (!valid) email.focus();
});
Skip-link & texte alternatif
Un lien d'évitement (skip-link) laisse sauter la
navigation d'un coup de Tab — invisible jusqu'au
focus. Et un bon alt décrit la fonction de l'image,
pas son nom de fichier. Navigue au clavier pour révéler le lien.
alt="Chouette perchée, logo OWL Concept" — décrit le sensalt="IMG_2043.png" — inutile pour l'utilisateuralt="" — correct si l'image est purement décorative<a class="skip-link" href="#main">Aller au contenu</a>
<!-- … navigation … -->
<main id="main" tabindex="-1">Contenu principal</main>
<!-- un bon alt décrit le SENS, pas le nom de fichier -->
<img src="owl.svg" alt="Chouette perchée, logo OWL Concept">
.skip-link {
position: absolute; left: -9999px; /* masqué… */
padding: .5rem 1rem; border-radius: 6px;
background: #c9952a; color: #0d1117;
}
.skip-link:focus { left: 1rem; top: 1rem; } /* …révélé au focus clavier */