BoutonsGratuit

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.

JSClickAnimation
Survolez ou cliquez la scène pour interagir

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

Connexion Content de vous revoir. E-mail marie@exemple.fr Mot de passe •••••••••• Mot de passe oublié ?

Cliquez sur le bouton

① Se connecter — carte de connexion

QuandFormulaire court : e-mail, mot de passe, un bouton plein largeur.
PourquoiL'onde au clic est le retour tactile hérité de Material Design ; sur un bouton de connexion, elle confirme que l'appui a été pris en compte pendant que la requête part — sans spinner ni changement de libellé.
RéglagesBouton pleine largeur, padding: 13px, 15 px, border-radius: 10px, fond #4f46e5 (6,3:1 avec le texte).
S02 · E42
Épisode 42 · 38 min Concevoir pour le pouce Zones d'atteinte, tailles de cible et les trois gestes dont dépend toute application mobile.
Reprendre à 12:40

Cliquez sur le bouton

② Écouter — carte d'épisode de podcast

QuandCarte média : pochette, durée, titre, un bouton de lecture.
PourquoiUn bouton de lecture est cliqué souvent et vite ; l'onde unique — la précédente est retirée à chaque clic — reste propre sous les clics répétés, et l'échelle 4 couvre le bouton depuis n'importe quel point.
RéglagesBouton compact (padding: 10px 18px, 14 px), border-radius: 999px, onde rgba(255, 255, 255, .3) en .5s.
Récapitulatif de commande
Sous-total · 2 articles40,00 €
Livraison8,00 €
Total48,00 €
Paiement sécurisé · Annulation gratuite 14 jours

Cliquez sur le bouton

③ Payer — récapitulatif de commande

QuandPanier ou étape de paiement : lignes de total, un bouton d'achat plein largeur.
PourquoiSur un bouton de paiement, l'utilisateur veut la certitude d'avoir cliqué une seule fois : l'onde part exactement de son pointeur et une seule onde vit à la fois — un double-clic nerveux ne produit pas deux animations superposées.
RéglagesBouton pleine largeur, padding: 15px, 16 px, courbe ease-out, durée .7s, fond #4f46e5.

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 bien click, mais avec clientX = clientY = 0 (et detail = 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 de e.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. #4f46e5 donne 6,3:1 sans changer l'onde. Pendant l'onde, le libellé passe sur #a1a3f7 au 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 à .25 si le texte est long.
  • prefers-reduced-motion absent du code. Attention au réflexe animation: none : sans animation, animationend ne 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 de createRipple.
  • Lecteurs d'écran : le span est vide, inséré dans le <button>, sans texte — le nom accessible ne change pas. Ajoutez circle.setAttribute('aria-hidden', 'true') par sécurité. pointer-events: none garantit que l'onde n'intercepte jamais le clic suivant.
  • Focus et tactile : le code écrit border: none sans toucher à outline, l'anneau de focus natif reste (non rogné par overflow: hidden). Au toucher, le tap produit un click avec 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.

Chrome 49+✓ Complet
Firefox 45+✓ Complet
Safari 10+✓ Complet
Edge 79+✓ Complet
Mobile iOS✓ Complet
Android Chrome✓ Complet

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.

HTML
<button class="demo-btn btn-ripple" onclick="createRipple(event)">Ripple Effect</button>
CSS
.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;
   }
}
JavaScript (fx-0102)
// 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éfautEffet
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').