Cliquez sur le bouton
Ripple Effect
Au clic, un disque blanc à 40 % naît sous le pointeur, grandit jusqu'à 4 fois le grand côté du bouton et s'éteint en 600 ms, découpé par overflow hidden. L'attribut onclick du HTML appelle une fonction createRipple qui doit rester globale : enveloppée dans un module ou une IIFE, l'effet casse.
Cet effet est gratuit — la catégorie Boutons en contient 27 au total, dont 2 gratuits. Effect.Labs regroupe 811 effets vanilla. Découvrir la catégorie →
Exemples d'usage
Cliquez sur le bouton
Cliquez sur le bouton
Comment ça marche
Le HTML porte le déclencheur : <button class="demo-btn btn-ripple" onclick="createRipple(event)">. Un attribut onclick inline s'évalue dans la portée globale, la fonction createRipple doit donc être une déclaration de premier niveau du script — c'est ainsi qu'elle est livrée. Elle lit e.currentTarget (le bouton), son getBoundingClientRect(), et fixe le diamètre de l'onde à Math.max(clientWidth, clientHeight) : 162 px pour le bouton de la démo (162 × 50 mesurés dans Chromium), rayon 81.
Un <span> carré de 162 × 162 px est créé et positionné pour que son centre soit sous le pointeur : left = e.clientX − rect.left − rayon, top = e.clientY − rect.top − rayon (mesuré : un clic à 20 px du bord gauche donne left: −61px). Avant de l'ajouter, le script cherche un .ripple-circle déjà présent dans le bouton et le retire : une seule onde à la fois — deux clics à 100 ms d'écart laissent un seul span. Le span écoute animationend pour se retirer lui-même ; aucun setTimeout.
Côté CSS, .btn-ripple .ripple-circle — la règle est scopée sous la classe du bouton — pose position: absolute (le bouton est position: relative via .demo-btn), border-radius: 50%, background: rgba(255, 255, 255, .4), transform: scale(0), pointer-events: none et animation: ripple .6s linear. @keyframes ripple n'a qu'une étape finale, scale(4); opacity: 0 : le départ est l'état propre du span (échelle 0, opacité 1). Mesuré : échelle 0,28 à 50 ms, 2,1 à 300 ms, span retiré avant 700 ms. Le disque final fait 648 px ; l'overflow: hidden de .demo-btn le découpe aux 12 px d'arrondi.
La géométrie garantit la couverture : le diamètre étant le grand côté et l'échelle 4, l'onde recouvre tout le bouton depuis n'importe quel point de clic — le pire cas (un coin) demande un rayon égal à la diagonale, 170 px, atteint à l'échelle 2,1 soit vers 315 ms ; depuis le centre, 160 ms. La seconde moitié des 600 ms n'est que le fondu. linear : vitesse constante, sans accélération. Le transition: .3s de .btn-ripple n'a rien à animer dans le code livré — aucune règle :hover ni :active — il ne sert que si vous en ajoutez une.
Accessibilité
- Rien ne se passe au clavier dans Chromium et Firefox : Entrée ou Espace sur le
<button>déclenche bienclick, mais avecclientX = clientY = 0(etdetail = 0). L'onde naît alors au coin haut-gauche de la fenêtre — à −270 / −236 px du bouton de la démo — et reste hors du bouton, découpée : aucun retour. WebKit (Safari) passe le centre du bouton et l'onde fonctionne. Ajoutez en tête de fonction :if (e.detail === 0) { x = rect.width / 2; y = rect.height / 2 }et utilisez ces coordonnées à la place dee.clientX − rect.left. - Contraste à la limite : texte blanc de 16 px sur
#6366f1= 4,47:1, sous le seuil AA de 4,5:1 de 0,03.#4f46e5donne 6,3:1 sans changer l'onde. Pendant l'onde, le libellé passe sur#a1a3f7au front de vague (blanc à 40 % sur l'indigo) : 2,3:1 pendant une fraction des 600 ms — un état transitoire que WCAG n'évalue pas, mais un libellé qui blanchit ; baissez l'opacité de l'onde à.25si le texte est long. - prefers-reduced-motion absent du code. Attention au réflexe
animation: none: sans animation,animationendne vient jamais et le span reste dans le DOM (mesuré : 1 span orphelin 800 ms après le clic, invisible à l'échelle 0, retiré au clic suivant). Préférez couper à la source :if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;en première ligne decreateRipple. - Lecteurs d'écran : le span est vide, inséré dans le
<button>, sans texte — le nom accessible ne change pas. Ajoutezcircle.setAttribute('aria-hidden', 'true')par sécurité.pointer-events: nonegarantit que l'onde n'intercepte jamais le clic suivant. - Focus et tactile : le code écrit
border: nonesans toucher àoutline, l'anneau de focus natif reste (non rogné paroverflow: hidden). Au toucher, le tap produit unclickavec coordonnées : l'onde part sous le doigt — c'est le retour Material d'origine, et il fonctionne. Cible ≈ 50 px de haut, au-dessus du minimum de 44 px.
Compatibilité navigateur
Requiert ES2015 (const, fonction fléchée, gabarits de chaîne), Element.remove(), l'événement animationend non préfixé et une animation CSS sur transform/opacity — tous les navigateurs depuis 2016. Zéro dépendance. Deux contraintes d'intégration : la fonction doit rester globale (l'attribut onclick l'exige), et un attribut inline est refusé par une Content-Security-Policy sans 'unsafe-inline' — remplacez-le alors par addEventListener.
Sans JavaScript, le bouton est un bouton indigo ordinaire, cliquable : l'attribut onclick est inerte, rien ne se passe au clic. Si JavaScript est actif mais que la fonction manque ou n'est pas globale, chaque clic lève ReferenceError: createRipple is not defined dans la console — le bouton garde son rôle (un submit soumet toujours). Sans animations CSS, le span est ajouté à scale(0), invisible, et reste jusqu'au clic suivant.
Le code
Copiez les trois blocs dans votre page. Aucune dépendance.
<button class="demo-btn btn-ripple" onclick="createRipple(event)">Ripple Effect</button>
.demo-btn {
padding: 16px 32px;
font-size: 1rem;
font-weight: 600;
border-width: medium;
border-style: none;
border-color: currentcolor;
border-image: none;
border-radius: 12px;
cursor: pointer;
font-family: inherit;
position: relative;
overflow: hidden;
}
.btn-ripple {
background: rgb(99, 102, 241);
color: white;
transition: 0.3s;
}
.btn-ripple .ripple-circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
transform: scale(0);
animation: 0.6s linear 0s 1 normal none running ripple;
pointer-events: none;
}
@keyframes ripple {
100% {
transform: scale(4);
opacity: 0;
}
}
// L'attribut onclick="createRipple(event)" du bouton exige une fonction GLOBALE.
function createRipple(e) {
const btn = e.currentTarget;
const rect = btn.getBoundingClientRect();
// Au clavier (Entree/Espace), clientX/clientY valent 0 : l'onde naitrait
// hors du bouton, invisible. On la centre dans ce cas.
const auClavier = e.detail === 0;
const px = auClavier ? rect.left + rect.width / 2 : e.clientX;
const py = auClavier ? rect.top + rect.height / 2 : e.clientY;
const circle = document.createElement('span');
const diameter = Math.max(btn.clientWidth, btn.clientHeight);
const radius = diameter / 2;
circle.style.width = circle.style.height = `${diameter}px`;
circle.style.left = `${px - rect.left - radius}px`;
circle.style.top = `${py - rect.top - radius}px`;
circle.classList.add('ripple-circle');
const ripple = btn.querySelector('.ripple-circle');
if (ripple) ripple.remove();
circle.addEventListener('animationend', () => circle.remove());
btn.appendChild(circle);
}
Personnaliser
Options passées à l'API ou attributs data-* :
| Option / propriété | Défaut | Effet |
|---|---|---|
animation-duration (CSS — .ripple-circle) |
0.6s | Durée totale, couverture + fondu. .4s = sec ; 1s = onde lente. Le retrait du span suit animationend, rien à modifier dans le JS. |
scale (CSS — @keyframes ripple, étape finale) |
scale(4) | Taille finale = 4 × le grand côté. Le bouton est couvert dès 2,1 : scale(2.5) éteint l'onde juste après la couverture — plus vif à durée égale. |
background de l'onde (CSS — .ripple-circle) |
rgba(255, 255, 255, 0.4) | Couleur et opacité du disque. .2 = discret ; rgba(0, 0, 0, .15) pour un bouton clair. C'est l'opacité de départ, le fondu va jusqu'à 0. |
animation-timing-function (CSS — .ripple-circle) |
linear | Vitesse constante. ease-out démarre vite et ralentit — plus proche de l'onde Material, dont la phase d'expansion dure 225 ms. |
Diamètre (JS — Math.max(btn.clientWidth, btn.clientHeight)) |
grand côté du bouton (162 px) | Taille initiale du disque avant mise à l'échelle. Divisez par 2 pour une onde qui part plus petite et couvre juste le bouton à l'échelle 4 ; multipliez par 1,2 pour un départ plus franc. |
Une onde à la fois (JS — if (ripple) ripple.remove()) |
oui | Supprimez ces deux lignes pour empiler les ondes des clics rapides : chaque span se retire seul à animationend, il n'y a pas de fuite. |
background (CSS — .btn-ripple) |
#6366f1 | Couleur du bouton. #4f46e5 pour un contraste AA (6,3:1) avec le texte blanc ; l'onde blanche reste lisible dessus. |
FAQ
Au clic, la console dit « createRipple is not defined ». Pourquoi ?
Parce que l'attribut onclick="createRipple(event)" cherche la fonction dans la portée globale (window). Si votre script est un module (type="module", import dans un bundler Vite ou webpack) ou s'il est enveloppé dans une IIFE, createRipple n'y est plus — mesuré dans Chromium : typeof window.createRipple vaut undefined et le clic lève l'erreur. Deux corrections : exposer la fonction (window.createRipple = createRipple), ou mieux, retirer l'attribut et brancher document.querySelectorAll('.btn-ripple').forEach(b => b.addEventListener('click', createRipple)) — la fonction peut alors vivre dans n'importe quelle portée, et l'intégration passe une CSP sans 'unsafe-inline'.
Rien ne se passe quand j'active le bouton au clavier.
C'est mesuré, pas un réglage : dans Chromium et Firefox, le click déclenché par Entrée ou Espace porte clientX = clientY = 0. Le calcul e.clientX − rect.left − rayon place alors l'onde au coin haut-gauche de la fenêtre, loin du bouton, et overflow: hidden la cache entièrement. Safari passe le centre du bouton, d'où l'impression que « ça marche chez moi ». Corrigez avec e.detail === 0 (vrai pour un clic synthétique) : dans ce cas, partez du centre du bouton, rect.width / 2 et rect.height / 2.
Comment poser l'onde sur plusieurs boutons, ou sur un lien ?
La fonction est générique : elle lit e.currentTarget, donc tout élément qui l'appelle reçoit sa propre onde. Trois conditions par élément : la classe btn-ripple (la règle .ripple-circle est scopée dessous — sans elle le span n'a aucun style et reste invisible), position: relative et overflow: hidden (fournis par .demo-btn). Sur un <a>, ajoutez display: inline-block. Si vous déléguez l'écouteur à un parent, e.currentTarget devient ce parent : remplacez-le par e.target.closest('.btn-ripple').