La conception d'une « snackBar » ou « toast » n'est pas compliquée, mais l'accessibilité sur ce composant peut l'être.
S'agissant d'un composant de plus en plus utilisé, voici une proposition qui intègre la partie HTML, les styles (pictogrammes compris) et, bien entendu le Javascript.
Cette version propose deux possibilités pour générer le conteneur :
- une version par défaut classique avec une balise
section, sans blocage d'arrière plan. Le modèle le plus connu. - une version plus moderne avec la balise
dialog.
Celle-ci a l'avantage de gérer nativement la fermeture avec la toucheEsc, de ne pas nécessiter d'attributaria-qui sont implicites et de rendre inerte tout autre contenu. La spécification est disponible sur MDN.
Si le navigateur ne connait pas la balise dialog et que l'option modal: true est activée, le script utilise la version section. C'est Toto Matique !
Exemples
Application
C'est exactement ce que vous allez voir en cliquant sur les blocs « Copier le code » ci-après.
Ou bien, essayer d'envoyer le formulaire de contact vide en moins de 10 secondes…
document.querySelectorAll('button.sample').forEach((button, index) => {
button.addEventListener('click', () => {
switch (index) {
case 1:
window.snackBar({
content: "Version avec section. Contenu texte, sans titre, fermeture automatique 10s, style 'alert' et placement haut et centré.",
time: 10000,
css: 'alert',
position: 'top-center'
});
break;
case 2:
window.snackBar({
content: "Version avec section. Contenu texte, titre, fermeture manuelle (ou focus out), style 'error' et placement haut à gauche (défaut). Largeur définie à auto",
hide: false,
title: 'Message d\'erreur',
css: 'error',
position: 'top-left',
width: 'auto',
});
break;
case 3:
window.snackBar({
content: "Version avec dialog.<br>Contenu HTML, sans titre fermeture auto, style 'success' et placement par haut à droite.<br>Largeur 37.5rem (600px)",
modal: true,
width: "37.5rem",
type: 'html',
css: 'success',
position: 'top-right'
});
break;
default:
window.snackBar({
title: "SnackBar modal",
content: "Version avec dialog.<br>Contenu HTML, fermeture manuelle, couleurs par défaut et placement par défaut.<br>Largeur 37.5rem (600px)",
modal: true,
hide: false,
width: "37.5rem",
type: 'html'
});
}
})
});
Les options
content: obligatoire. Contenu text/plain ou HTML.css:nullpar défaut. Valeurs possibles de surcharge :successalerterror
label: Nom accessible (aria-label) de la modale (fallback i18n par défaut « Boite de dialogue ».hide:truepar défaut. Masquage automatique à la valeur de l'optiontimemodal: sitrue: balisedialog, sinonsectionopener:document.activeElementpar défaut. Si précisé, remet le focus à la fermeture de la snackBar sur l'élément passé en paramètre.position:top-left(par défaut)top-centertop-rightbottom-leftbottom-rightbottom-centercenter
type:textpar défauthtml
time: millisecondes de temps d'affichage sihide: true. 3000 par défaut.title: contenu text/plain affiché comme "titre" de la modale.width: 18.75rem (300px) par défaut. Utiliser une valeur avec unités (px, em, rem,…) ouautopour pleine largeur.
Javascript
La fonction intègre un gestionnaire de z-index pour s'assurer que le composant est toujours en superposition de la page.
Taille brute : 9,58 kilo-octets (~3,38 kilo-octets gzip)
/**
* Source Kortic https://www.kortic.com/le-blog-kortic/2025/05/28/composant-snackbar-toast-html-css-accessibles.html
* licence Creative Commons CC BY-NC-SA 4.0
* https://creativecommons.org/licenses/by-nc-sa/4.0/deed.fr
* Attribution, partage dans les mêmes conditions, pas d'utilisation commerciale
*/
((w, d) => {
'use strict';
/**
* @fileoverview snackBar — Composant de notification accessible
* Crée une boîte de dialogue (modale ou non) affichant un message temporaire ou persistant.
* Supporte nativement `<dialog>` avec fallback `<section>`, gère le focus, le trap focus,
* l'inert sur l'arrière-plan et la fermeture automatique.
*
* Dépendances optionnelles attendues sur `window` :
* - `w.getMaxZIndex()` : retourne le z-index max courant du document
* - `w.trapFocus(el)` : trap du focus clavier dans `el`
* - `w.i18n.poppins_close_modal` : libellé localisé du bouton fermer
* - `w.i18n.poppins_dialog_label` : aria-label localisé de la boîte de dialogue
*/
/**
* Crée et affiche un snack-bar (notification ou modale).
*
* Un appel alors qu'un snack-bar est déjà présent supprime l'instance précédente
* et annule son timer avant d'en créer un nouveau.
*
* @param {Object} options
* @param {string} [options.content='Il faut ajouter un texte…'] - Contenu affiché. HTML si `type:'html'`, texte brut sinon.
* @param {string|null} [options.css=null] - Suffixe de classe CSS `type-{css}` ajouté sur le conteneur.
* @param {boolean} [options.hide=true] - `true` : fermeture automatique après `time` ms. `false` : affiche un bouton de fermeture.
* @param {string|null} [options.label=null] - Valeur de l'aria-label. Défaut : `i18n.poppins_dialog_label` ou `'Boîte de dialogue'`.
* @param {boolean} [options.modal=false] - `true` : utilise `<dialog showModal()>` avec inert sur l'arrière-plan.
* @param {HTMLElement} [options.opener=activeElement] - Élément qui récupère le focus à la fermeture.
* @param {string} [options.position='top-left'] - Position via classe CSS `pos-{position}`.
* @param {'text'|'html'} [options.type='text'] - Mode d'insertion du contenu (`textContent` vs `innerHTML`).
* @param {number} [options.time=3000] - Délai en ms avant fermeture automatique (ignoré si `hide:false`).
* @param {string|null} [options.title=null] - Titre optionnel affiché en tête de snack-bar.
* @param {string|null} [options.width=null] - Largeur CSS inline (`'300px'`, `'20rem'`…). Défaut : `'18.75rem'`.
* @param {'prepend'|'append'|Element} [options.insert='prepend'] - Stratégie d'insertion :
* `'prepend'` (défaut) : `body.prepend()`,
* `'append'` : `body.appendChild()`,
* `Element` : insertion après l'élément via `insertAdjacentElement('afterend', …)`.
*/
w.snackBar = (options) => {
// Suppression de l'instance précédente et annulation de son timer
const existingDialog = d.getElementById('snack-bar');
if (existingDialog) {
clearTimeout(w.snackBarTimer);
existingDialog.remove();
}
const settings = Object.assign({
content: 'Il faut ajouter un texte ou du HTML dans les paramètres de la fonction…',
css: null,
hide: true,
label: null,
modal: Boolean(options?.modal),
opener: d.activeElement,
position: 'top-left',
type: ['text', 'html'].includes(options?.type) ? options.type : 'text',
time: 3000,
title: null,
width: null,
insert: options?.insert ?? 'prepend'
}, options);
// Cas où l'opener a été retiré du DOM (ex. suppression d'un bloc)
if (!settings.opener.isConnected || settings.opener === d.documentElement) {
d.body.setAttribute('tabindex', '-1');
settings.opener = d.body;
}
// Fallback <section> si le navigateur ne supporte pas <dialog>
if (!('HTMLDialogElement' in w) && settings.modal) settings.modal = false;
const dialog = d.createElement(settings.modal ? 'dialog' : 'section');
dialog.id = 'snack-bar';
dialog.className = `pos-${settings.position}` + (settings.css ? ` type-${settings.css}` : '');
dialog.style.width = settings.width ?? '18.75rem';
// aria-label sur les deux éléments : <dialog> et <section role="dialog">
dialog.setAttribute('aria-label',
settings.label ?? w?.i18n?.poppins_dialog_label ?? 'Boîte de dialogue'
);
// Attributs ARIA uniquement sur <section> : <dialog> les gère nativement
if (!settings.modal) {
setAttributes(dialog, {
// aria-modal posé uniquement à true (valeur false = attribut inutile)
...(!settings.hide && {'aria-modal': 'true'}),
// aria-live pertinent uniquement sur une notification auto-fermante sans interaction
...(settings.hide && {'aria-live': 'assertive'}),
'role': 'dialog'
});
// tabindex="-1" requis pour `dialog.focus()` sur un élément non focusable nativement
!settings.hide && dialog.setAttribute('tabindex', '-1');
dialog.style.zIndex = (w?.getMaxZIndex?.() ?? (() => {
const elements = [...d.querySelectorAll(':scope body *')]
.filter(el => getComputedStyle(el).zIndex !== 'auto');
return Math.max(...elements.map(el => Number(getComputedStyle(el).zIndex) || 0), 0);
})()) + 1;
}
if (settings.title) {
const titleElem = d.createElement('p');
titleElem.className = 'snack-bar--title';
titleElem.textContent = settings.title;
dialog.appendChild(titleElem);
}
// Insertion du contenu : innerHTML (html) ou textContent (text)
const contentElem = d.createElement(settings.type === 'html' ? 'div' : 'p');
settings.type === 'html'
? (contentElem.innerHTML = settings.content)
: (contentElem.textContent = decodeEntities(settings.content));
dialog.appendChild(contentElem);
// Insertion : prepend (défaut), append, ou après un élément
settings.insert === 'prepend' || !(settings.insert instanceof Element)
? d.body.prepend(dialog)
: settings.insert === 'append'
? d.body.appendChild(dialog)
: settings.insert.insertAdjacentElement('afterend', dialog);
settings.modal && dialog.showModal();
if (settings.hide) {
// Fermeture automatique : le timer est global pour être annulable par un appel suivant
clearTimeout(w.snackBarTimer);
w.snackBarTimer = setTimeout(() => {
if (dialog?.parentNode) {
settings.modal && dialog.close();
dialog.remove();
}
settings.opener?.focus();
}, settings.time);
settings.modal && dialog.focus();
} else {
const closeButton = createCloseButton();
dialog.appendChild(closeButton);
closeButton.addEventListener('click', () => {
if (dialog?.parentNode) {
// Levée de l'inert uniquement si posé par cette instance (modal:true)
if (settings.modal) {
d.querySelectorAll('body > [inert]').forEach(el => el.removeAttribute('inert'));
d.documentElement.classList.remove('snackbar-overflow');
}
settings.modal && dialog.close();
dialog.remove();
}
settings.opener?.focus();
});
// Escape : événement global pour <section>, événement 'close' natif pour <dialog>
!settings.modal
? d.addEventListener('keyup', e => e.key === 'Escape' && closeButton.click())
: dialog.addEventListener('close', () => closeButton.click());
// Inert sur l'arrière-plan uniquement en mode modal (hide:false + modal:true)
if (settings.modal) {
d.querySelectorAll('body > *').forEach(el => el !== dialog && el.setAttribute('inert', ''));
d.documentElement.classList.add('snackbar-overflow');
}
// Focus et trap différés d'un rAF : le DOM doit être peint avant le calcul des focusables
requestAnimationFrame(() => {
dialog.focus();
w?.trapFocus?.(dialog);
});
}
};
// ---------------------------------------------------------------------------
// UTILITAIRES PRIVÉS
// ---------------------------------------------------------------------------
/**
* Applique un ensemble d'attributs HTML sur un élément en une seule opération.
*
* @param {HTMLElement} element - Cible
* @param {Object} attributes - Map `{ attributName: value }`
*/
function setAttributes(element, attributes) {
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, `${value}`));
}
/**
* Décode les entités HTML d'une chaîne via le parseur natif du navigateur.
* Protège contre l'injection XSS en utilisant `textContent` (pas `innerHTML`) en sortie.
*
* @param {string} str - Chaîne potentiellement encodée (`&`, `<`…)
* @returns {string} Texte brut décodé
*/
function decodeEntities(str) {
return new DOMParser().parseFromString(str, 'text/html').body.textContent;
}
/**
* Crée un bouton de fermeture accessible avec libellé sr-only.
* Le libellé est résolu depuis `window.i18n.poppins_close_modal` en priorité,
* avec fallback sur la chaîne française.
*
* @returns {HTMLButtonElement}
*/
function createCloseButton() {
const closeButton = d.createElement('button');
closeButton.type = 'button';
closeButton.className = 'snack-bar--close';
const screenReaderSpan = d.createElement('span');
screenReaderSpan.className = 'screen-readers';
screenReaderSpan.textContent = w?.i18n?.poppins_close_modal ?? 'Fermer la modale';
closeButton.appendChild(screenReaderSpan);
return closeButton;
}
})(window, document);
Télécharger kortic-snackbar-toast.js
Utilitaire de capture de focus
Quand une modale est affichée en surimpression du contenu, il faut bloquer la navigation au clavier à l'intérieur de celle-ci pour que ce ne soit pas l'arrière plan qui prenne le focus.
C'est un point essentiel pour les personnes qui naviguent uniquement au clavier.
Taille brute : 2,49 kilo-octets (~1,05 kilo-octets gzip)
/**
* Source Kortic https://www.kortic.com/le-blog-kortic/2025/05/28/composant-snackbar-toast-html-css-accessibles.html
* licence Creative Commons CC BY-NC-SA 4.0
* https://creativecommons.org/licenses/by-nc-sa/4.0/deed.fr
* Attribution, partage dans les mêmes conditions, pas d'utilisation commerciale
*/
(() => {
/**
* Namespace global pour les utilitaires
* @namespace Utils
*/
window.Utils = window.Utils || {};
/**
* Sélecteur CSS pour tous les éléments pouvant recevoir le focus
* Inclut : liens, zones cliquables, résumés, contrôles de formulaire,
* éléments avec role="button", tabindex="0", et contenus éditables
* @type {string}
* @memberof Utils
*/
Utils.FOCUSABLE_SELECTORS = Utils.FOCUSABLE_SELECTORS ||
'a[href], area[href], summary, select, textarea, button, input:not([type="hidden"]):not([disabled])' +
'[role="button"], [tabindex="0"], [contenteditable]';
/**
* Confine le focus clavier à l'intérieur d'un élément (trap focus)
* Utile pour les modales, menus et dialogs conformes WCAG
*
* Comportement :
* - Tab depuis le dernier élément → retour au premier
* - Shift+Tab depuis le premier élément → retour au dernier
* - Si un seul élément focusable → le focus reste dessus
*
* Note WCAG : cette fonction confine le focus, mais l'élément parent DOIT
* fournir un moyen de fermeture au clavier (Échap, bouton, etc.) pour
* respecter le critère 2.1.2 niveau A (pas de piège au clavier)
*
* @param {HTMLElement} element - Élément conteneur dans lequel confiner le focus
* @returns {void}
* @global
*
* @example
* const dialog = document.querySelector('#myDialog');
* trapFocus(dialog);
*/
window.trapFocus = function (element) {
const focusableElements = Array.from(element.querySelectorAll(Utils.FOCUSABLE_SELECTORS));
// Sortie anticipée si aucun élément focusable
if (!focusableElements.length) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
element.addEventListener("keydown", (e) => {
if (e.key === "Tab") {
// Cas particulier : un seul élément focusable
if (firstElement === lastElement) {
e.preventDefault();
firstElement.focus();
// Shift+Tab sur le premier élément → boucle vers le dernier
} else if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
// Tab sur le dernier élément → boucle vers le premier
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
});
};
})();
Télécharger kortic-trapfocus.js
Feuille de styles
La feuille de styles intègre les variables SVG utiles, faciles à modifier. Quatre options de CSS permettent d'avoir une base de couleurs.
À adapter selon vos besoins.
Taille brute : 6,10 kilo-octets (~1,93 kilo-octets gzip)
/**
* la variable --fixed-position est définie sur le :root
* si besoin, utiliser
* :root {
* --fixed-position: calc(max(0, (100vw - var(--size-body, 100vw)) / 2, var(--half-gutter, 1.5625rem)));
* }
*/
#snack-bar {
--svg-close: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16,1A15,15,0,1,0,31,16,15,15,0,0,0,16,1Zm5.18,22.42L16,18.3l-5.15,5.19L8.57,21.2,13.7,16,8.5,10.86l2.29-2.29L16,13.7l5.13-5.2,2.31,2.31L18.3,16l5.19,5.13Z"/></svg>');
--radius: 0.5625rem;
background-color: var(--basic-color, white);
color: var(--basic-color-reverse, black);
border-radius: var(--radius);
font-size: var(--font-size, inherit);
max-width: calc(100vw - var(--gutter, 3.125rem));
max-height: calc(100dvh - var(--gutter, 3.125rem));
padding: 1rem 1.5rem 1rem 1.5rem;
position: fixed;
width: fit-content;
border: none;
overflow-y: auto;
&::backdrop {
background-color: transparent;
}
&:not([class*="type-"]) {
box-shadow: 0 0 0 1px var(--box-shadow), 0 0.4375rem 0.3125rem -0.3125rem rgba(0, 0, 0, 0.3);
}
&[class*="type-"] {
box-shadow: 0 0.4375rem 0.3125rem -0.3125rem rgba(0, 0, 0, 0.3);
&:not([class*="reverse"]) {
padding-left: 2.25rem;
&:before {
position: absolute;
top: 1.375rem;
left: 0.75rem;
content: "";
width: 1rem;
height: 1rem;
background-color: currentColor;
-webkit-mask-position: center center;
-webkit-mask-size: contain;
-webkit-mask-image: var(--svg-before);
mask-position: center center;
mask-size: contain;
mask-image: var(--svg-before);
}
}
}
&[class*="-center"] {
left: 50%;
}
&[style*="auto"] {
left: var(--fixed-position, 1.5625rem);
right: var(--fixed-position, 1.5625rem);
}
&.with-btn {
padding-right: 3rem;
}
&.pos-top-left {
top: var(--half-gutter, 1.5625rem);
left: var(--half-gutter, 1.5625rem);
@media (min-width: 90rem) {
left: var(--fixed-position, 1.5625rem);
}
}
&.pos-center {
top: 50%;
transform: translate(-50%, -50%);
}
&.pos-top-center {
top: var(--half-gutter, 1.5625rem);
transform: translateX(-50%);
}
&.pos-top-right {
top: var(--half-gutter, 1.5625rem);
right: var(--half-gutter, 1.5625rem);
left: initial;
@media (min-width: 90rem) {
right: var(--fixed-position, 1.5625rem);
}
}
&.pos-bottom-left {
top: initial;
bottom: var(--half-gutter, 1.5625rem);
left: var(--half-gutter, 1.5625rem);
@media (min-width: 90rem) {
left: var(--fixed-position, 1.5625rem);
}
}
&.pos-bottom-center {
top: initial;
bottom: var(--half-gutter, 1.5625rem);
transform: translateX(-50%);
}
&.pos-bottom-right {
top: initial;
bottom: var(--half-gutter, 1.5625rem);
right: var(--half-gutter, 1.5625rem);
left: initial;
@media (min-width: 90rem) {
right: var(--fixed-position, 1.5625rem);
}
}
&.type-reverse {
background-color: var(--heading-color, black);
color: var(--basic-color-reverse, white);
}
&.type-success {
--svg-before: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16,0A16,16,0,1,0,32,16,16.00584,16.00584,0,0,0,16,0Zm0,28.8A12.8,12.8,0,1,1,28.8,16,12.817,12.817,0,0,1,16,28.8ZM23.344,8.928,12.8,19.472,8.656,15.344,6.4,17.6,12.8,24,25.6,11.2Z"/></svg>');
background-color: var(--green, green);
color: white;
@media (prefers-color-scheme: dark) {
color: black;
}
}
&.type-alert {
--svg-before: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M14.06,9.7c0-.8.65-1.45,1.45-1.45h.97c.8,0,1.45.65,1.45,1.45v.97c0,.8-.65,1.45-1.45,1.45h-.97c-.8,0-1.45-.65-1.45-1.45v-.97ZM19.87,23.75h-7.75v-1.94h1.94v-5.81h-1.94v-1.94h5.81v7.75h1.94v1.94ZM16,.5C7.44.5.5,7.44.5,16s6.94,15.5,15.5,15.5,15.5-6.94,15.5-15.5c0-8.56-6.94-15.5-15.5-15.5,0,0,0,0,0,0ZM16,28.59c-6.96,0-12.59-5.64-12.59-12.59S9.04,3.41,16,3.41s12.59,5.64,12.59,12.59c0,6.96-5.64,12.59-12.59,12.59h0Z"/></svg>');
background-color: var(--yellow, yellow);
color: black;
}
&.type-error {
--svg-before: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M30.87,23.56L20.02,3.49c-1.18-2.29-3.93-3.15-6.15-1.94-.8.44-1.45,1.11-1.87,1.94L1.15,23.56c-1.33,2.37-.54,5.4,1.75,6.77.69.41,1.46.64,2.26.66h21.67c2.66-.08,4.75-2.38,4.67-5.13-.02-.81-.24-1.6-.62-2.31ZM16.01,26.55c-.99-.03-1.76-.89-1.73-1.91.03-.97.79-1.75,1.73-1.78.99.06,1.74.93,1.69,1.95-.05.94-.78,1.69-1.69,1.74ZM17.58,14.62c-.08,1.43-.16,2.85-.24,4.28-.04.46-.04.89-.04,1.34-.01.74-.59,1.33-1.3,1.34-.7,0-1.28-.57-1.3-1.3-.11-2.23-.24-4.41-.35-6.64-.04-.59-.08-1.18-.12-1.76-.05-.91.5-1.74,1.34-2.02.83-.21,1.68.23,2.01,1.05.12.29.17.61.16.93-.04.93-.12,1.86-.16,2.78h0Z"/></svg>');
background-color: var(--raspberry, red);
color: white;
}
& .snack-bar--title {
margin: 0 !important;
color: inherit;
font-size: inherit;
font-weight: 600;
}
& .snack-bar--close {
background-color: transparent;
border-radius: 0 var(--radius) 0 0;
color: inherit;
height: 2rem;
outline-color: currentColor;
outline-offset: -2px;
position: absolute;
right: 0;
top: 0;
width: 2rem;
z-index: 1;
&:after {
background-color: currentColor;
-webkit-mask-image: var(--svg-close);
-webkit-mask-position: center center;
-webkit-mask-size: 1rem 1rem;
-webkit-mask-repeat: no-repeat;
mask-image: var(--svg-close);
mask-position: center center;
mask-size: 1rem 1rem;
mask-repeat: no-repeat;
position: absolute;
width: 2rem;
height: 2rem;
content: "";
top: 0;
left: 0;
}
}
}
:root.snackbar-overflow {
height: 100dvh;
overflow: hidden;
& body {
overscroll-behavior: none;
& > [inert] {
-webkit-filter: blur(3px);
filter: blur(3px);
}
}
}
Télécharger kortic-snackbar-toast.css
That's all Folks !