La galerie de photos modale est un composant incontournable pour afficher des images en grand format sur un site web. Beaucoup de solutions existantes négligent l'accessibilité ou nécessitent des dépendances externes lourdes.
Dans cet article, nous allons explorer une implémentation moderne d'une visionneuse d'images JavaScript en Vanilla, sans aucune dépendance, qui respecte les standards d'accessibilité WCAG. Cette solution offre une navigation fluide au clavier, un focus trap efficace, et gère le diaporama automatique.
Pour une meilleure accessibilité, support de l'élément natif <dialog>, avec fallback si le navigateur ne le gère pas.
Exemples en situation
2 options :
- Sur une page liste de photographie ou une page de photographie détaillée ;
- et directement ici.
-
Pour l'exemple, une image sur deux a un déclencheur sur la totalité de son layout au lieu d'un bouton positionné en bas et à droite.
C'est une option décrite ci-après par l'ajout de la classe lightbox-overlay-trigger.
La classe trigger-centered permet de centrer le bouton.
Dans tous les cas, sur écran tactile, le bouton ne recouvre pas le bloc, même si lightbox-overlay-trigger est spécifié.
Les sources
Incluez les fichiers CSS et JavaScript dans votre page :
<link rel="stylesheet" href="lightbox.css">
<script src="trapfocus.js"></script>
<script src="lightbox.js"></script>
Javascript
Utilitaire de trap focus
Taille brute : 2,49 kilo-octets (~1,05 kilo-octets gzip)
(() => {
/**
* 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();
}
}
});
};
})();
La fonction lightbox
Taille brute : 28,24 kilo-octets (~7,77 kilo-octets gzip)
/**
* Lightbox en Vanilla JS
* Permet d'afficher des images en plein écran avec navigation et diaporama automatique
*
* Vérifie si le navigateur supporte la syntaxe CSS Nesting native avant d'initialiser
* la lightbox. Si le nesting n'est pas supporté, crée une classe stub pour éviter
* les erreurs d'exécution.
*
* @module Lightbox
* @requires CSS Nesting Level 1 specification
*
* ====================================================================
* DÉTECTION DU SUPPORT CSS NESTING
* ====================================================================
*
* Méthode de détection :
* 1. Injection d'une règle CSS avec nesting dans le DOM
* 2. Lecture de la custom property via getComputedStyle
* 3. Nettoyage immédiat de la balise <style>
*
* Règle de test :
* :root {
* body {
* --supports-nesting: 1;
* }
* }
*
* Si le navigateur interprète correctement le nesting, la propriété
* --supports-nesting sera définie sur le body avec la valeur "1".
*
* - Crée un élément <style> temporaire
* - Injecte la règle de test dans textContent
* - Append au <head>
* - Vérifie via getComputedStyle(body).getPropertyValue()
* - Supprime immédiatement le <style>
*
* @returns {boolean} nesting - True si CSS nesting supporté, false sinon
*
* ====================================================================
* GESTION DES NAVIGATEURS NON COMPATIBLES
* ====================================================================
*
* Si nesting === false :
* - Création d'une classe Lightbox vide (stub pattern)
* - Log d'avertissement en console
* - Return immédiat pour court-circuiter le reste du module
*
* Avantages du stub :
* - Évite les ReferenceError si du code externe tente new Lightbox()
* - Fournit un feedback explicite via console.warn()
* - Permet une dégradation gracieuse sans crash
*
* Support navigateurs (décembre 2024) :
* - Chrome/Edge 112+
* - Safari 16.5+
* - Firefox 117+
* - Coverage globale : ~90%+
*
* ====================================================================
* PROGRESSIVE ENHANCEMENT
* ====================================================================
*
* Pattern utilisé : Feature detection + graceful degradation
*
* Flow d'exécution :
* 1. Test du support → nesting variable
* 2. Si false : stub + return (arrêt)
* 3. Si true : initialisation complète de Lightbox
*
* Ce pattern garantit :
* - Pas de crash sur navigateurs anciens
* - Feedback explicite en développement
* - Code moderne pour navigateurs récents
* - Conformité Progressive Enhancement
*/
(()=> {
'use strict';
let nesting = false;
/**
* IIFE de détection du CSS Nesting
* Exécuté immédiatement au chargement du module
*/
const supportsNesting = window.supportsNesting ??= CSS?.supports?.('selector(&)') ?? false;
/**
* Fallback pour navigateurs sans support CSS Nesting
* Crée une classe vide et log dans la console
*/
if (!supportsNesting) {
document.documentElement.classList.add('no-nesting-css');
window.Lightbox = class {
constructor() {
console.warn('Lightbox désactivée car Nesting CSS n\'est pas supporté');
}
};
// Pas de support CSS : on arrête là
return;
} else {
document.documentElement.classList.add('use-nesting-css');
}
/**
* Codes des touches clavier utilisées
* @constant {Object}
*/
const KEYCODES = {
TAB: 9,
ESC: 27,
LEFT: 37,
RIGHT: 39
};
/**
* Utilitaires de la lightbox
* @namespace
*/
const utils = {
/**
* Précharge une ou plusieurs images
* @param {...string|string[]} urls - URL(s) des images à précharger
* @example
* utils.preload('image1.jpg', 'image2.jpg');
* utils.preload(['image1.jpg', 'image2.jpg']);
*/
preload: function (...urls) {
const images = Array.isArray(urls[0]) ? urls[0] : urls;
images.forEach(url => {
const img = new Image();
img.src = url;
});
},
/**
* Récupère une traduction depuis l'objet i18n global
* @param {string} wording - Clé de traduction
* @param {string} [fallback=''] - Valeur par défaut si la clé n'existe pas
* @returns {string} Texte traduit ou fallback
* @example
* utils.means('lightbox_close', 'Close'); // "Fermer" ou "Close"
*/
means: function (wording, fallback = '') {
return (typeof window.i18n === 'object' && window.i18n[wording])
? window.i18n[wording]
: fallback;
},
/**
* Calcule le z-index maximum dans le DOM
* @returns {number} Z-index maximum + 1
*/
getMaxIndex: function (from) {
let elements;
if (!from) {
elements = document.querySelectorAll(':scope body *');
} else if (typeof from === 'string') {
elements = document.querySelectorAll(from);
} else if (from instanceof HTMLElement) {
elements = from.querySelectorAll('*');
} else {
elements = document.querySelectorAll(':scope body *');
}
/**
* @type {number[]}
*/
const zIndexes = [...elements]
.map(el => Number(getComputedStyle(el).zIndex) || 0)
.filter(z => z > 0);
return zIndexes.length ? Math.max(...zIndexes) + 1 : 1;
},
/**
* Remplace les %s dans une chaîne par les arguments fournis
* @param {string} str - Chaîne avec des %s
* @param {...string} args - Arguments de remplacement
* @returns {string} Chaîne formatée
* @example
* utils.sprintf("Image %s sur %s", "1", "10"); // "Image 1 sur 10"
*/
sprintf: function (str, ...args) {
let i = 0;
return str.replace(/%s/g, () => args[i++] || '');
}
};
/**
* Classe principale de la Lightbox
* Gère l'affichage d'images en plein écran avec navigation et diaporama
*
* @class
* @example
* // Initialisation simple
* const lb = new Lightbox('lightbox');
*
* // Avec un conteneur spécifique
* const container = document.querySelector('#gallery');
* const lb = new Lightbox('diaporama', container);
*/
window.Lightbox = class Lightbox {
/**
* Crée une instance de Lightbox
* @param {string} [attribute='lightbox'] - Nom de l'attribut data- à cibler (data-lightbox, data-diaporama, etc.)
* @param {HTMLElement} [fromDomNode=null] - Conteneur DOM à scanner (null = document.body)
*/
constructor(attribute = 'lightbox', fromDomNode = null) {
this.attribute = attribute;
this.blocs = Array.from((fromDomNode || document.body).querySelectorAll(`.lightbox[data-${attribute}]`));
if (!this.blocs.length) return;
/** @type {number} Timer pour le diaporama automatique */
this.timer = 0;
/** @type {boolean} État de la lecture automatique */
this.autostart = false;
/** @type {number} Index de l'image courante */
this.currentIndex = 0;
/** @type {number} Index de l'image suivante */
this.nextIndex = 0;
/** @type {number} Index de l'image précédente */
this.prevIndex = 0;
/** @type {string} Image placeholder (GIF transparent) */
this.placeholder = 'data:image/gif;base64,R0lGODlhCgAKAJECAAAAAP///wAAAAAAACH5BAEAAAIALAAAAAAKAAoAAAIIlI+py+0PYysAOw==';
/** @type {Array<HTMLElement>} Liste des éléments body > * pour l'attribut inert */
this.bodyChildren = [];
this.init();
}
/**
* Initialise la lightbox
* Réutilise le container existant si présent, sinon le crée
* @private
*/
init() {
// Récupère ou crée le container partagé
this.container = document.getElementById('lightbox');
if (!this.container) {
this.createLightbox();
this.bindEvents();
} else {
// Récupère les références des éléments existants
this.caption = this.container.querySelector('#lightbox_caption');
this.closeBtn = this.container.querySelector('#lightbox_close');
this.image = this.container.querySelector('#lightbox_image');
this.controls = this.container.querySelector('#lightbox_control');
// Récupère les boutons de contrôle s'ils existent
if (this.controls) {
this.firstBtn = this.controls.querySelector('#lightbox_first');
this.prevBtn = this.controls.querySelector('#lightbox_prev');
this.playBtn = this.controls.querySelector('#lightbox_play');
this.nextBtn = this.controls.querySelector('#lightbox_next');
this.lastBtn = this.controls.querySelector('#lightbox_last');
}
}
this.setupTriggers();
}
/**
* Crée la structure HTML de la lightbox
* @private
*/
createLightbox() {
// Container principal - utilise <dialog> si supporté, sinon <section>
const tagName = 'showModal' in document.createElement('dialog') ? 'dialog' : 'section';
this.container = this.createElement(tagName, {
id: 'lightbox',
tabindex: '0',
'aria-labelledby': 'lightbox_caption'
});
// Éléments principaux
this.caption = this.createElement('h2', {id: 'lightbox_caption'}, 'Diaporama');
this.closeBtn = this.createElement('button', {
id: 'lightbox_close',
type: 'button',
'aria-label': utils.means('lightbox_close', 'Fermer')
});
this.image = this.createElement('img', {
id: 'lightbox_image',
alt: '',
src: this.placeholder,
decoding: 'async',
style: 'min-width: 3.125rem'
});
// Assemblage (sans controls pour l'instant)
this.container.append(
this.caption,
this.closeBtn,
this.image
);
document.body.appendChild(this.container);
}
/**
* Crée ou récupère le container de contrôles
* @private
*/
ensureControls() {
if (this.controls && this.controls.parentElement) {
return; // Les contrôles existent déjà dans le DOM
}
// Récupère ou crée le container de contrôles
this.controls = this.container.querySelector('#lightbox_control');
if (!this.controls) {
this.controls = this.createElement('nav', {id: 'lightbox_control', 'aria-label': 'Navigation lightbox'});
// Crée les boutons de contrôle
this.firstBtn = this.createControlButton('lightbox_first', 'lightbox_first', 'Première');
this.prevBtn = this.createControlButton('lightbox_prev', 'lightbox_prev', 'Précédente');
this.playBtn = this.createControlButton('lightbox_play', 'lightbox_play', 'Lecture automatique');
this.nextBtn = this.createControlButton('lightbox_next', 'lightbox_next', 'Suivante');
this.lastBtn = this.createControlButton('lightbox_last', 'lightbox_last', 'Dernière');
this.playBtn?.setAttribute('aria-pressed', 'false');
this.controls.append(
this.firstBtn,
this.prevBtn,
this.playBtn,
this.nextBtn,
this.lastBtn
);
}
this.container.appendChild(this.controls);
}
/**
* Retire le container de contrôles du DOM
* @private
*/
removeControls() {
// Cherche dans le container partagé car this.controls peut ne pas être défini
const controls = this.container.querySelector('#lightbox_control');
if (controls) {
controls.remove();
}
}
/**
* Crée un élément HTML avec des attributs
* @private
* @param {string} tag - Nom de la balise
* @param {Object} [attributes={}] - Attributs à appliquer
* @param {string} [textContent] - Contenu texte optionnel
* @returns {HTMLElement} Élément créé
*/
createElement(tag, attributes = {}, textContent) {
const el = document.createElement(tag);
Object.entries(attributes).forEach(([key, value]) => {
key === 'style'
? el.style.cssText = value
: el.setAttribute(key, value);
});
if (textContent) el.textContent = textContent;
return el;
}
/**
* Crée un bouton de contrôle
* @private
* @param {string} id - ID du bouton
* @param {string} labelKey - Clé i18n pour l'aria-label
* @param {string} fallback - Valeur par défaut si i18n non disponible
* @returns {HTMLButtonElement} Bouton créé
*/
createControlButton(id, labelKey, fallback) {
return this.createElement('button', {
id: id,
type: 'button',
'aria-label': utils.means(labelKey, fallback)
});
}
/**
* Configure les déclencheurs (triggers) pour chaque élément lightbox
* @private
*/
setupTriggers() {
this.blocs.forEach((bloc, index) => {
const parent = bloc.parentElement;
const imgCaption = bloc.getAttribute('title') || bloc.getAttribute('data-title') || null;
// Ajoute la classe container si nécessaire
if (!parent.classList.contains('lightbox-container')) {
parent.classList.add('lightbox-container');
if (!['absolute', 'relative', 'fixed'].includes(getComputedStyle(parent).position)) {
parent.classList.add('position-relative');
}
// Crée le bouton trigger
const trigger = this.createElement('button', {
class: 'lightbox-trigger',
type: 'button',
'aria-haspopup': 'dialog',
'aria-label': utils.sprintf(
utils.means('lightbox_trigger_label', 'Agrandir l\'image %s en plein écran'),
imgCaption || ''
)
});
/**
* Si .lightbox a un attribut data-outline="color"
* Applique un outline color === color
*/
bloc.dataset.outline && trigger.style.setProperty('outline-color', bloc.dataset.outline, 'important');
trigger.style.zIndex = `${utils.getMaxIndex(document.querySelector('main'))}`;
const label = this.createElement('span', {
class: 'trigger-label screen-readers'
}, utils.means('lightbox_trigger', 'Agrandir l\'image'));
trigger.appendChild(label);
bloc.insertAdjacentElement('afterend', trigger);
}
// Gestion overlay
if (bloc.classList.contains('lightbox-overlay-trigger')) {
parent.classList.add('lightbox-overlay');
}
// Event click sur le trigger
parent.querySelector('.lightbox-trigger').addEventListener('click', (e) => this.openLightbox(e, bloc, index));
});
}
/**
* Ouvre la lightbox avec une image spécifique
* Met à jour les index et la collection d'images pour cette instance
* Affiche/masque dynamiquement les contrôles selon le nombre d'images de l'instance
* @param {Event} e - Événement click
* @param {HTMLElement} bloc - Élément contenant l'image
* @param {number} index - Index de l'image dans la collection
* @private
*/
openLightbox(e, bloc, index) {
e.stopPropagation();
e.preventDefault();
// Stocke l'instance courante dans le container pour les navigations
this.container._currentInstance = this;
const count = this.blocs.length;
this.currentIndex = index;
this.nextIndex = (index + 1) % count;
this.prevIndex = (index - 1 + count) % count;
const dataValue = bloc.dataset[this.attribute];
this.container.setAttribute('data-opener', dataValue);
this.container.setAttribute('data-attribute', this.attribute);
// Gestion des contrôles selon le nombre d'images
// IMPORTANT : toujours enlever d'abord, puis ajouter si besoin
this.removeControls();
this.container.classList.remove('multi-views');
if (count > 1) {
this.ensureControls();
this.container.classList.add('multi-views');
// Restaure l'état du bouton play si autostart est actif
if (this.autostart && this.playBtn) {
this.playBtn.classList.add('play');
this.playBtn.setAttribute('aria-pressed', 'true');
}
}
typeof dataValue === 'string' && utils.preload(dataValue);
// Affichage
if (!this.isVisible()) {
this.fadeIn();
}
// Caption
const imgCaption = bloc.getAttribute('title') || bloc.getAttribute('data-title') || '';
const imgAlt = bloc.dataset.alt || '';
this.caption.innerHTML = imgCaption.trim()
? `<span>${imgCaption}</span>`
: '<span>Diaporama</span>';
if (imgAlt.trim()) {
this.caption.appendChild(
this.createElement('span', {class: 'screen-readers'}, ` ${imgAlt}`)
);
}
// Index (seulement si plusieurs images et si les contrôles existent)
if (count > 1 && this.controls) {
this.controls.setAttribute('data-before',
`${utils.means('lightbox_enum', '')}${index + 1}${utils.means('lightbox_on', ' sur ')}${count}`);
}
// Chargement image
this.loadImage(bloc, dataValue);
}
/**
* Charge et affiche une image dans la lightbox
* Restaure systématiquement le focus sur le conteneur après chargement
* @param {HTMLElement} bloc - Élément source
* @param {string|Object} dataValue - URL de l'image ou objet avec src/srcset
* @private
*/
loadImage(bloc, dataValue) {
this.container.classList.add('lightbox-loading');
this.image.src = this.placeholder;
// Définit src et srcset
if (typeof dataValue !== 'string') {
try {
const values = JSON.parse(dataValue);
this.image.src = values.src;
if (values.srcset) {
this.image.srcset = values.srcset.join(',');
}
} catch (e) {
this.image.src = dataValue;
}
} else {
this.image.src = dataValue;
}
this.image.onload = () => {
if (this.container.tagName !== 'DIALOG') {
this.container.setAttribute('aria-live', 'polite');
this.container.setAttribute('aria-modal', 'true');
this.container.setAttribute('role', 'dialog');
}
// Calcul hauteur en utilisant les valeurs computées en rem
const closeHeight = parseFloat(getComputedStyle(this.closeBtn).height);
const controlsHeight = this.controls
? parseFloat(getComputedStyle(this.controls).height)
: closeHeight;
// Conversion px → rem basée sur la font-size du root
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
const totalHeightRem = (closeHeight + controlsHeight) / rootFontSize;
this.image.style.maxHeight = `calc(100% - ${totalHeightRem + 1}rem)`;
this.container.classList.remove('lightbox-loading');
// Scroll
if (bloc.dataset.scroll) {
window.location.hash = bloc.dataset.scroll;
}
// Active l'attribut inert sur le reste de la page, focus et trap focus
requestAnimationFrame(() => {
this.setInertOnBody(true);
this.container.focus();
setTimeout(() => {
window.trapFocus?.(this.container);
}, 100);
});
// Autostart
this.autostart && this.initTimer();
};
}
/**
* Active ou désactive l'attribut inert sur tous les enfants directs de body sauf la lightbox
* Remplace le focus trap JS pour une solution native
* @param {boolean} enable - true pour activer inert, false pour désactiver
* @private
*/
setInertOnBody(enable) {
if (enable) {
// Sauvegarde les enfants directs de body (sauf la lightbox)
this.bodyChildren = Array.from(document.body.children).filter(
child => child !== this.container
);
// Active inert sur chaque élément
this.bodyChildren.forEach(child => child.inert = true);
} else {
// Désactive inert sur tous les éléments sauvegardés
this.bodyChildren.forEach(child => child.inert = false);
this.bodyChildren = [];
}
}
/**
* Attache tous les événements de la lightbox
* N'attache les events qu'une seule fois (lors de la création du container)
* @private
*/
bindEvents() {
// Close
this.closeBtn.addEventListener('click', () => {
const instance = this.container._currentInstance;
if (instance) instance.fadeOut();
});
// Click sur container (ferme si clic en dehors)
this.container.addEventListener('click', (e) => {
// Vérifie si l'élément cliqué ne doit pas fermer la modale
const shouldNotClose = ['#lightbox_control', '#lightbox_image', '#lightbox_close', 'h2 span'].some(selector =>
e.target.matches(selector) || e.target.closest(selector)
);
// Ferme seulement si ce n'est PAS un des éléments interdits
if (!shouldNotClose) {
const instance = this.container._currentInstance;
if (instance) instance.fadeOut();
}
});
// Click sur image
this.image.addEventListener('click', () => {
const instance = this.container._currentInstance;
if (!instance) return;
if (instance.blocs.length > 1) {
instance.next();
}
});
// Délégation d'événements pour les contrôles (ils peuvent être ajoutés/retirés)
this.container.addEventListener('click', (e) => {
const instance = this.container._currentInstance;
if (!instance || instance.blocs.length <= 1) return;
const target = e.target.closest('button');
if (!target) return;
switch (target.id) {
case 'lightbox_first':
instance.goToIndex(0);
instance.stop();
break;
case 'lightbox_prev':
instance.prev();
instance.stop();
break;
case 'lightbox_next':
instance.next();
instance.stop();
break;
case 'lightbox_last':
instance.goToIndex(instance.blocs.length - 1);
instance.stop();
break;
case 'lightbox_play':
instance.togglePlay();
break;
}
});
// Navigation clavier
window.addEventListener('keyup', (e) => {
const instance = this.container._currentInstance;
if (!instance) return;
if (!instance.isVisible() && e.keyCode !== KEYCODES.ESC) return;
switch (e.keyCode) {
case KEYCODES.RIGHT:
if (instance.blocs.length > 1) instance.next();
break;
case KEYCODES.LEFT:
if (instance.blocs.length > 1) instance.prev();
break;
case KEYCODES.ESC:
instance.fadeOut();
break;
}
});
// Éco-conception : arrêt du diaporama si l'onglet devient invisible
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
const instance = this.container._currentInstance;
if (instance && instance.autostart) {
instance.stop();
}
}
});
}
/**
* Navigue vers une image spécifique par son index
* Remet systématiquement le focus sur le container de la modale après chargement
* @param {number} index - Index de l'image à afficher
* @public
*/
goToIndex(index) {
if (!this.isVisible()) return;
const bloc = this.blocs[index];
if (bloc) {
this.openLightbox({
stopPropagation: () => {
},
preventDefault: () => {
}
}, bloc, index);
}
}
/**
* Navigue vers l'image suivante
* @public
*/
next() {
this.goToIndex(this.nextIndex);
}
/**
* Navigue vers l'image précédente
* @public
*/
prev() {
this.goToIndex(this.prevIndex);
}
/**
* Active/désactive la lecture automatique
* @public
*/
togglePlay() {
this.playBtn.classList.toggle('play');
this.autostart = !this.autostart;
this.autostart ? this.initTimer() : this.resetTimer();
this.playBtn.setAttribute('aria-pressed', this.autostart ? 'true' : 'false');
}
/**
* Initialise le timer du diaporama automatique
* @private
*/
initTimer() {
this.resetTimer();
this.timer = setTimeout(() => this.next(), 5000);
}
/**
* Réinitialise le timer du diaporama
* @private
*/
resetTimer() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = 0;
}
}
/**
* Arrête la lecture automatique
* @public
*/
stop() {
this.autostart = false;
this.playBtn?.classList.remove('play');
this.playBtn?.setAttribute('aria-pressed', 'false');
this.resetTimer();
}
/**
* Affiche la lightbox avec une animation de fondu
* Utilise showModal() si <dialog> est supporté
* @private
*/
fadeIn() {
// Si c'est un <dialog>, utilise showModal() au lieu de display: block
if (this.container.tagName === 'DIALOG' && typeof this.container.showModal === 'function') {
this.container.showModal();
this.container.style.opacity = '0';
} else {
this.container.style.display = 'block';
this.container.style.opacity = '0';
}
setTimeout(() => {
this.container.style.transition = 'opacity 200ms';
this.container.style.opacity = '1';
}, 10);
}
/**
* Masque la lightbox avec une animation de fondu
* Restaure le focus sur l'élément déclencheur de l'instance courante
* Utilise close() si <dialog> est supporté
* @public
*/
fadeOut() {
this.container.style.transition = 'opacity 200ms';
this.container.style.opacity = '0';
setTimeout(() => {
// Si c'est un <dialog>, utilise close() au lieu de display: none
if (this.container.tagName === 'DIALOG' && typeof this.container.close === 'function') {
this.container.close();
} else {
this.container.style.display = 'none';
}
this.stop();
// Désactive inert sur le reste de la page
this.setInertOnBody(false);
// Retour focus sur le trigger de l'instance courante
const opener = this.container.getAttribute('data-opener');
const attribute = this.container.getAttribute('data-attribute');
document.querySelector(`[data-${attribute}="${opener}"]`)
?.parentElement.querySelector('.lightbox-trigger')?.focus();
// Nettoie la référence à l'instance et les event listeners du onload
if (this.image.onload) {
this.image.onload = null;
}
this.container._currentInstance = null;
}, 200);
}
/**
* Vérifie si la lightbox est visible
* @returns {boolean} true si visible, false sinon
* @public
*/
isVisible() {
// Pour <dialog>, vérifier l'attribut open
if (this.container.tagName === 'DIALOG') {
return this.container.open && this.container.style.opacity !== '0';
}
return this.container.style.display !== 'none' && this.container.style.opacity !== '0';
}
};
/**
* Fonction d'initialisation globale de la lightbox
*
* @function lightbox
* @global
* @param {string} [attribute='lightbox'] - Nom de l'attribut data- à cibler
* @param {HTMLElement} [fromDomNode=null] - Conteneur DOM à scanner
* @returns {Lightbox} Instance de la lightbox
*
* @example
* // Initialisation simple
* lightbox();
*
* @example
* // Avec un attribut personnalisé
* lightbox('diaporama');
*
* @example
* // Dans un conteneur spécifique
* lightbox('lightbox', document.querySelector('#gallery'));
*/
window.lightbox = function (attribute = 'lightbox', fromDomNode = null) {
return new Lightbox(attribute, fromDomNode);
};
})();
Télécharger kortic-lightbox.js
Feuille de styles
Taille brute : 14,34 kilo-octets (~3,83 kilo-octets gzip)
/**
* Nesting CSS : la lightbox ne s'active pas si le navigateur ne supporte pas cette syntaxe
*/
:root {
& body {
--lightbox-zoom: url('data:image/svg+xml,\
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">\
<path d="M17.35,8.36l-1.58-1.58c-.29-.29-.08-.78.32-.78h9.45c.25,0,.46.2.46.46v9.45c0,.41-.49.61-.78.32l-1.58-1.58c-.09-.09-.13-.2-.13-.32v-5.37c0-.25-.2-.46-.46-.46h-5.37c-.12,0-.24-.05-.32-.13ZM14.32,23.5h-5.37c-.25,0-.46-.21-.46-.46v-5.37c0-.12-.05-.24-.13-.32l-1.58-1.58c-.29-.29-.78-.08-.78.32v9.44c0,.25.21.46.46.46h9.44c.41,0,.61-.49.32-.78l-1.58-1.58c-.09-.09-.2-.13-.32-.13ZM21.5,20.99v-9.97c0-.28-.23-.51-.51-.51h-9.97c-.28,0-.51.23-.51.51v9.97c0,.28.23.51.51.51h9.97c.28,0,.51-.23.51-.51ZM11.5,19.99v-7.97c0-.28.23-.51.51-.51h7.97c.28,0,.51.23.51.51v7.97c0,.28-.23.51-.51.51h-7.97c-.28,0-.51-.23-.51-.51Z"/>\
</svg>');
--outline-color-lightbox: var(--outline-color, orangered);
}
/* Gestion épaisseur outline selon contraste */
&:not(.contrast) {
--outline-stroke-lightbox: 0.1875rem;
}
&.contrast {
--outline-stroke-lightbox: 0.25rem;
}
/* Outline commun */
.lightbox-trigger,
#lightbox *:focus {
outline-offset: calc(var(--outline-stroke-lightbox) * -1) !important;
outline-width: var(--outline-stroke-lightbox);
outline-color: var(--outline-color-lightbox);
border-radius: 3px;
}
/* Positionnement trigger selon variante */
.lightbox-container:not(.lightbox-overlay) {
.lightbox:not(.trigger-centered) ~ .lightbox-trigger {
right: 0.625rem;
top: 0.625rem;
height: 2.8125rem;
width: 2.8125rem;
}
.lightbox.trigger-centered ~ .lightbox-trigger {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
height: 3.75rem;
width: 3.75rem;
}
}
@media (any-pointer: coarse) {
.lightbox-container.lightbox-overlay {
.lightbox:not(.trigger-centered) ~ .lightbox-trigger {
right: 0.625rem;
top: 0.625rem;
height: 2.8125rem;
width: 2.8125rem;
}
.lightbox.trigger-centered ~ .lightbox-trigger {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
height: 3.75rem;
width: 3.75rem;
}
}
}
/* Trigger de base */
.lightbox-trigger {
background-color: rgba(0, 0, 0, 0.7);
color: inherit;
border-radius: var(--small-radius, 0.625rem) !important;
position: absolute !important;
max-width: 100%;
&:focus-visible {
background-color: rgba(0, 0, 0, 1);
}
@media (any-hover: hover) {
&:hover {
background-color: rgba(0, 0, 0, 1);
}
}
&:after {
content: '';
position: absolute;
inset: 0;
-webkit-mask-image: var(--lightbox-zoom);
-webkit-mask-size: 80%;
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-image: var(--lightbox-zoom);
mask-size: 80%;
mask-position: center;
mask-repeat: no-repeat;
background-color: white;
}
&:focus {
opacity: 1;
outline-offset: var(--outline-stroke-lightbox) !important;
outline-color: var(--outline-color-lightbox);
}
}
/* Overlay hover states */
@media (any-hover: hover) {
.lightbox-overlay .lightbox-trigger {
background-color: rgba(0, 0, 0, 0.5) !important;
inset: 0;
width: 100%;
height: auto !important;
cursor: zoom-in !important;
opacity: 0;
border-radius: 0 !important;
padding: 0;
transform: none;
.trigger-label {
opacity: 0;
width: 0;
overflow: hidden;
text-overflow: clip;
display: inline-block;
}
&:after {
mask-size: 2.5rem;
-webkit-mask-size: 2.5rem;
}
}
.lightbox-overlay:is(:hover, :focus-within) .lightbox-trigger {
opacity: 1;
}
@media (prefers-reduced-motion: no-preference) {
.lightbox-overlay .lightbox-trigger {
transition: opacity .2s ease-in-out;
}
}
}
/* Lightbox principale */
#lightbox {
/* Variables SVG */
--svg-wait: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M8.89,1c-.59,1.97-1.06,3.98-.82,5.91.27,2.11,1.35,4.19,4.24,6.17-3.46,4.83-5.56,13.19-3.83,17.92.7-3.48.92-6.31,3.45-9.33,5-1.65,7.99-2.43,9.05-8.11.4-.95.81-1.25,2.06-1.59.68-.19,1.43-.48,1.43-1.24-.01-3.18-1.83-4.41-3.72-4.38-1.9.03-3.87,1.33-4.22,3.21-.39.12-.77.29-1.14.52-4.06-2.82-5.3-5.54-6.5-9.08h0ZM2,5.64c.73,1.74,1.77,3.49,3.27,4.85,1.59,1.45,3.69,2.47,6.56,2.6-2.25-1.52-3.6-3.18-4.3-4.9-1.91-.67-3.67-1.54-5.52-2.55h0ZM21.3,8.08c.43,0,.79.35.79.79,0,.43-.35.79-.79.79-.43,0-.79-.35-.79-.79,0-.43.35-.79.79-.79ZM24.59,11.19c-.24.32-.49.64-1.07.81,2.12,1.72,4.89,5.15,6.42,7.05-.63-2.17-3.36-5.86-5.34-7.86h0Z"/></svg>');
--lightbox-first: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M5.66,0A1.66,1.66,0,0,0,4,1.66H4V30.34A1.66,1.66,0,0,0,5.66,32H8.71a1.66,1.66,0,0,0,1.65-1.66V1.66A1.66,1.66,0,0,0,8.71,0ZM28.15,1.37,27.67.94a2.65,2.65,0,0,0-3.74648.0941L23.89,1.07l-.1.11L11,14a2.7,2.7,0,0,0-.21,3.79A2.08051,2.08051,0,0,0,11,18L23.79,30.82a2.67,2.67,0,0,0,3.75984.34848L27.56,31.16l.11-.1.48-.48Z"/></svg>');
--lightbox-prev: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M15.15,18a2.86,2.86,0,0,1,0-4.05h0c3.21-3.21,6.39-6.39,9.6-9.54a2.17,2.17,0,0,0,.11-3.07.93.93,0,0,0-.14-.14L24.25.77a2.65,2.65,0,0,0-3.75,0,2.2,2.2,0,0,0-.2.23l-13,13a2.86,2.86,0,0,0,0,4h0l13,13a2.64,2.64,0,0,0,3.72.44l.23-.2.47-.48a2.12,2.12,0,0,0,.32-3,1.8,1.8,0,0,0-.19-.2C21.51,24.42,18.36,21.18,15.15,18Z"/></svg>');
--lightbox-last: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M23.44,0a1.67,1.67,0,0,0-1.65,1.66V30.34A1.67,1.67,0,0,0,23.44,32h3a1.66,1.66,0,0,0,1.66-1.66V1.66h0A1.66,1.66,0,0,0,26.49,0ZM4,30.58l.48.48.11.1h0a2.68,2.68,0,0,0,3.76-.35L21.15,18a2.08155,2.08155,0,0,0,.21-.21A2.7,2.7,0,0,0,21.15,14L8.36,1.18l-.1-.11h0A2.65,2.65,0,0,0,4.5159.90649L4.48.94,4,1.37Z"/></svg>');
--lightbox-next: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16.74,14a2.87,2.87,0,0,1,0,4h0c-3.21,3.21-6.39,6.39-9.59,9.54a2.18,2.18,0,0,0,0,3.08l.15.13.47.45a2.65,2.65,0,0,0,3.75,0,2.2,2.2,0,0,0,.2-.23l13-13a2.86,2.86,0,0,0,0-4.05h0L11.7,1A2.64,2.64,0,0,0,8,.57l-.23.2-.47.48a2.12,2.12,0,0,0-.32,3,1.8,1.8,0,0,0,.19.2C10.38,7.58,13.53,10.82,16.74,14Z"/></svg>');
--lightbox-play: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M9.11.35A1.6,1.6,0,0,0,6.84.64a1.69,1.69,0,0,0-.34,1V30.34a1.67,1.67,0,0,0,.91,1.5,1.6,1.6,0,0,0,.71.16,1.55,1.55,0,0,0,1-.35L26.84,17.31A1.69,1.69,0,0,0,27.19,15a1.93,1.93,0,0,0-.35-.35Z"/></svg>');
--lightbox-pause: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="m11.02,25h-.04c-.82,0-1.48-.66-1.48-1.48v-15.04c0-.82.66-1.48,1.48-1.48h.04c.82,0,1.48.66,1.48,1.48v15.04c0,.82-.66,1.48-1.48,1.48Zm10,0h-.04c-.82,0-1.48-.66-1.48-1.48v-15.04c0-.82.66-1.48,1.48-1.48h.04c.82,0,1.48.66,1.48,1.48v15.04c0,.82-.66,1.48-1.48,1.48Z"/></svg>');
--lightbox-close: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M30,0a1.95,1.95,0,0,1,2,2,1.91,1.91,0,0,1-.58,1.42L18.83,16,31.42,28.58A1.91,1.91,0,0,1,32,30a1.95,1.95,0,0,1-2,2,1.91,1.91,0,0,1-1.42-.58L16,18.83,3.42,31.42A1.91,1.91,0,0,1,2,32a1.95,1.95,0,0,1-2-2,1.91,1.91,0,0,1,.58-1.42L13.17,16,.58,3.42A1.91,1.91,0,0,1,0,2,1.95,1.95,0,0,1,2,0,1.91,1.91,0,0,1,3.42.58L16,13.17,28.58.58A1.91,1.91,0,0,1,30,0Z"/></svg>');
--lightbox-baseline: 2.5rem;
--lightbox-size-icons: 1rem;
position: fixed;
inset: 0;
width: 100svw;
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
color: white;
background-color: rgba(0, 0, 0, 0.9);
outline: none;
/* État loading */
&.lightbox-loading {
&:before {
content: attr(data-loading, "Chargement de l'image…");
position: absolute;
top: calc(var(--lightbox-baseline) * 2);
left: 50%;
font-weight: bold;
transform: translateX(-50%);
}
> img {
width: 9.375rem;
-webkit-mask-image: var(--svg-wait);
-webkit-mask-size: cover;
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-image: var(--svg-wait);
mask-size: cover;
mask-position: center;
mask-repeat: no-repeat;
background-color: white;
}
#lightbox_caption {
visibility: hidden;
}
& nav {
pointer-events: none;
}
}
&.multi-views #lightbox_image {
margin-top: -0.625rem;
}
}
/* Animation bounce pour loading */
@media (prefers-reduced-motion: no-preference) {
@keyframes animate-bounce {
0%, 100% {
transform: translate(-50%, -50%) scale(1) rotateY(0deg);
background-color: white;
}
50% {
transform: translate(-50%, -50%) scale(1.05) rotateY(180deg);
background-color: black;
}
}
#lightbox.lightbox-loading {
perspective: 62.5rem;
> img {
animation: animate-bounce 3s ease-in-out infinite;
}
}
}
/* Différences section/dialog */
& section#lightbox {
display: none;
}
& dialog#lightbox {
border: none;
background-color: transparent;
max-width: calc(100svw + var(--gutter, 0));
&::backdrop {
background-color: transparent;
background-image: radial-gradient(ellipse at center, transparent 0%, transparent 20%, rgba(0, 0, 0, 0.4) 60%, rgba(0, 0, 0, 0.95) 100%),
radial-gradient(ellipse at center, transparent 30%, rgba(0, 0, 0, 0.8) 100%);
}
}
/* Adaptation color-scheme */
@media (prefers-color-scheme: light) {
dialog#lightbox {
background-color: rgba(0, 0, 0, 0.85);
}
}
@media (prefers-color-scheme: dark) {
dialog#lightbox {
background-color: rgba(0, 0, 0, 0.5);
}
}
/* Caption */
#lightbox_caption {
position: sticky;
top: 0;
padding: 0.3125rem var(--lightbox-baseline) 0;
margin: 0 !important;
text-align: center;
font-size: 1rem;
& span:first-child {
line-height: calc(var(--lightbox-baseline) - 0.625rem);
display: inline-block;
color: black !important;
background-color: white;
padding: 0 calc(var(--lightbox-baseline) / 3);
border-radius: 0.3125rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 100%;
position: relative;
font-weight: bold;
}
&:before {
content: '';
position: absolute;
bottom: -0.0625rem;
left: 50%;
transform: translateX(-50%);
display: inline-block;
width: 0;
height: 0;
border-left: 1rem solid transparent;
border-right: 1rem solid transparent;
border-top: 0.625rem solid white;
}
}
/* Image principale */
#lightbox_image {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
height: auto;
width: auto;
max-width: calc(100vw - 0.625rem);
border-radius: var(--small-radius, 0);
box-shadow: 0 0 0 0.0625rem rgba(127, 127, 127, 1) inset;
padding: 0.0625rem;
}
/* Contrôles */
#lightbox_control {
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
padding-top: calc(var(--lightbox-baseline) / 2);
min-height: var(--lightbox-baseline);
white-space: nowrap;
&:before {
content: attr(data-before);
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
line-height: calc(var(--lightbox-baseline) / 2);
display: inline-block;
white-space: nowrap;
text-transform: uppercase;
font-size: 0.875rem;
}
& button {
height: var(--lightbox-baseline);
width: var(--lightbox-baseline);
cursor: pointer;
background-color: black;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.5) inset;
border-radius: var(--lightbox-baseline) !important;
vertical-align: sub;
+ button {
margin-left: 0.3125rem;
}
&:after {
content: '';
position: absolute;
inset: 0;
-webkit-mask-size: var(--lightbox-size-icons);
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-size: var(--lightbox-size-icons);
mask-position: center;
mask-repeat: no-repeat;
background-color: white;
}
}
}
/* Boutons de contrôle */
#lightbox_close {
background-color: transparent !important;
position: absolute;
right: 0;
top: 0;
height: var(--lightbox-baseline);
width: var(--lightbox-baseline);
cursor: pointer;
&:after {
content: '';
position: absolute;
inset: 0;
-webkit-mask-image: var(--lightbox-close);
-webkit-mask-size: var(--lightbox-size-icons);
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-image: var(--lightbox-close);
mask-size: var(--lightbox-size-icons);
mask-position: center;
mask-repeat: no-repeat;
background-color: white;
}
}
#lightbox_first:after {
mask-image: var(--lightbox-first);
-webkit-mask-image: var(--lightbox-first);
}
#lightbox_prev:after {
mask-image: var(--lightbox-prev);
-webkit-mask-image: var(--lightbox-prev);
}
#lightbox_play {
&.play {
background-color: white;
border-radius: 3.125rem;
&:after {
mask-image: var(--lightbox-pause);
-webkit-mask-image: var(--lightbox-pause);
background-color: black;
}
}
&:not(.play):after {
mask-image: var(--lightbox-play);
-webkit-mask-image: var(--lightbox-play);
background-color: white;
}
}
#lightbox_next:after {
mask-image: var(--lightbox-next);
-webkit-mask-image: var(--lightbox-next);
}
#lightbox_last:after {
mask-image: var(--lightbox-last);
-webkit-mask-image: var(--lightbox-last);
}
}
Télécharger kortic-lightbox.css
HTML recquis
Pour une image seule, utilisez l'attribut data-lightbox (ou un attribut personnalisé) :
<img decoding="async" loading="lazy"
class="lightbox"
title="Titre de l'image"
data-lightbox="/chemin/vers/image-grande.jpg"
alt="Description de l'image"
src="/chemin/vers/image-petite.jpg">
Par défaut data-lightbox, peut être remplacé par data-ATTRIBUT-AUTRE pour gérer plusieurs instances.
Classes CSS :
lightbox[obligatoire] : classe pour activer la lightboxlightbox-overlay-trigger[option]: affiche le bouton de zoom sur toute l'image au survol (desktop uniquement)
Diaporama (plusieurs images)
Pour créer un diaporama avec plusieurs images partageant le même attribut, data-diaporama par exemple, au lieu de data-lightbox :
<figure class="lightbox"
data-diaporama="/chemin/vers/image-1.jpg"
data-title="Légende de l'image 1"
data-scroll="ancre-image-1">
<source type="image/webp" srcset="/chemin/vers/image-1.webp">
<img decoding="async" loading="lazy"
src="/chemin/vers/image-1.jpg"
alt="Description image 1">
</figure>
[…]
Initialisation
Paramètres optionnels :
attribute(string, optionnel) : nom de l'attributdata-*à cibler (défaut :'lightbox')fromDomNode(HTMLElement, optionnel) : conteneur DOM à scanner (défaut :document.body)
document.addEventListener('DOMContentLoaded', () => {
// Utilise data-lightbox sur toute la page
lightbox();
// Utilise data-gallery sur toute la page
lightbox('gallery');
// ou new Lightbox('gallery')
// Utilise data-photos uniquement dans #galerie
lightbox('photos', document.querySelector('#galerie'));
});
Attributs HTML disponibles
| Attribut | Description | Exemple |
|---|---|---|
data-{attribute} |
URL de l'image haute résolution | data-lightbox="/img/hd.jpg" |
title ou data-title |
Légende affichée dans la lightbox | title="Ma photo" |
data-alt |
Texte alternatif pour la lightbox | data-alt="Description" |
data-scroll |
Ancre pour le scroll après chargement | data-scroll="photo-1" |
id |
Identifiant unique (recommandé) | id="photo-1" |
Fonctionnalités
Navigation clavier
- Flèche droite : image suivante
- Flèche gauche : image précédente
- Échap : fermer la lightbox
- Tab : navigation entre les contrôles
Contrôles du diaporama
Quand plusieurs images sont présentes :
- Premier : aller à la première image
- Précédent : image précédente
- Lecture/Pause : démarrer/arrêter le diaporama automatique (5 secondes par image)
- Suivant : image suivante
- Dernier : aller à la dernière image
Accessibilité
- Support natif de l'élément
<dialog> - Attribut
inertsur le reste de la page - Focus trap automatique
- Labels ARIA sur tous les boutons
- Navigation au clavier complète
- Respect de
prefers-reduced-motion
Éco-conception
- Arrêt automatique du diaporama quand l'onglet devient invisible
- Préchargement intelligent des images
- Pas de dépendance externe
- CSS optimisé avec masques SVG inline
Internationalisation
Le composant cherche automatiquement les traductions dans window.i18n :
window.i18n = {
lightbox_close: 'Fermer',
lightbox_trigger: 'Agrandir',
lightbox_trigger_label: 'Afficher %s en plein écran',
lightbox_first: 'Première',
lightbox_prev: 'Précédente',
lightbox_play: 'Lecture automatique',
lightbox_next: 'Suivante',
lightbox_last: 'Dernière',
lightbox_enum: '',
lightbox_on: ' / '
};
Notes techniques
- Compatible avec les navigateurs modernes supportant ES6
- Pas de dépendance (vanilla JS)
- Un seul container lightbox partagé entre toutes les instances
- Support responsive avec unités
remetsvh/svw - Gestion automatique du z-index
- Support des images responsive (
srcset)
That's all, Folks !