Parce que faire défiler des images à la main, c'est 2005.
Un petit script JS qui fait tourner vos images en fondu enchaîné. Rien de plus, rien de moins.
Installation
Collez le script dans votre page ou appelez-le via une url. Voilà.
Haute technologie non ?
Le script
Taille brute : 10,04 kilo-octets (~3,25 kilo-octets gzip)
/**
* Source Kortic https://www.kortic.com/le-blog-kortic/2025/12/19/animation-d-images-purement-decoratives-en-fondu-enchaine-sans-framework.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
*/
/**
* Initialise les animations en fondu rotatif pour des images décoratives.
*
* @example
* <!-- Utilisation basique avec paramètres par défaut -->
* <figure data-fade>
* <!-- les images… -->
* </figure>
*
* @example
* <!-- Avec paramètres personnalisés (syntaxe simplifiée) -->
* <div data-fade="delay:3 fadeIn:0.5 fadeOut:0.5 marker">
* <!-- les images… -->
* </div>
*
* @example
* <!-- Syntaxe JSON -->
* <figure data-fade='{"delay": 3, "fadeIn": 0.5, "fadeOut": 0.5, "marker": true}'>
* <!-- les images… -->
* </figure>
*
* @param {Object} [params] - Paramètres configurables via data-fade
* @param {number} [params.delay=5] - Durée d'affichage de chaque image en secondes
* @param {number} [params.fadeIn=0.5] - Durée du fondu d'entrée en secondes
* @param {number} [params.fadeOut=0.5] - Durée du fondu de sortie en secondes
* @param {boolean} [params.marker=false] - Active l'affichage du marqueur n°/total
* @param {boolean} [params.loop=true] - Active la boucle infinie (false = s'arrête sur la dernière image)
*
* @remarks
* Syntaxes supportées pour data-fade :
* - Vide : `data-fade` (utilise les valeurs par défaut)
* - Simplifiée : `data-fade="delay:3 marker"` (espace comme séparateur, booléen sans valeur)
* - JSON : `data-fade='{"delay": 3, "marker": true}'` (legacy, toujours fonctionnel)
*
* Fonctionnalités d'accessibilité :
* - Respecte automatiquement les préférences d'accessibilité (prefers-reduced-motion)
* - Supporte une classe .no-motion sur <html> pour désactiver les animations
* - En mode sans animation, seule l'image avec data-fade-default (ou la première) est affichée
* - Les animations se mettent en pause quand le conteneur n'est pas visible (viewport)
* - Les animations se mettent en pause quand l'onglet perd le focus (Page Visibility API)
* - Les animations se mettent en pause au survol (hover) ou focus clavier
* - Les styles nécessaires sont injectés automatiquement
* - Le marqueur est créé automatiquement si params.marker est activé
*/
(() => {
'use strict';
function parseParams(attrValue) {
if (!attrValue) return {};
if (attrValue.trim().startsWith('{')) return JSON.parse(attrValue);
return attrValue.split(/\s+/).reduce((acc, pair) => {
const [key, value] = pair.split(':');
if (value === undefined) {
acc[key] = true;
} else if (value === 'true') {
acc[key] = true;
} else if (value === 'false') {
acc[key] = false;
} else if (!isNaN(value)) {
acc[key] = parseFloat(value);
} else {
acc[key] = value;
}
return acc;
}, {});
}
function initFade() {
const shouldReduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches || document.documentElement.classList.contains('no-motion');
const containers = Array.from(document.querySelectorAll('[data-fade]')).filter(el =>
!el.hidden && getComputedStyle(el).display !== 'none'
);
const markerIntervals = new WeakMap();
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const images = entry.target.querySelectorAll('img:not([inert]):not([data-animation-ended])');
const markerControl = markerIntervals.get(entry.target);
if (entry.isIntersecting && document.visibilityState === 'visible') {
images.forEach(img => img.style.animationPlayState = 'running');
markerControl?.resume();
} else {
images.forEach(img => img.style.animationPlayState = 'paused');
markerControl?.pause();
}
});
}, {root: null, rootMargin: '0px', threshold: 0.5});
document.addEventListener('visibilitychange', () => {
containers.forEach(container => {
const images = container.querySelectorAll('img:not([inert]):not([data-animation-ended])');
const markerControl = markerIntervals.get(container);
if (document.visibilityState === 'visible') {
observer.unobserve(container);
observer.observe(container);
markerControl?.resume();
} else {
images.forEach(img => img.style.animationPlayState = 'paused');
markerControl?.pause();
}
});
});
/**
* styles injectés par défaut
* Pour optimiser le CLS, si possible, preload styles
* [data-fade] {
* position: relative;
* max-width: 100%;
* overflow: hidden;
* &:not([data-fade-ratio]) {
* padding-bottom: 100%;
* }
*
* & img {
* position: absolute;
* top: 50%;
* left: 50%;
* transform: translate(-50%, -50%);
* width: auto;
* height: auto;
* min-width: 100%;
* min-height: 100%;
* object-fit: cover;
* }
* }
*/
document.head.appendChild(Object.assign(document.createElement('style'), {
id: 'css-fade-animate',
textContent: `
[data-fade] {
position: relative; overflow: hidden; max-width: 100%;
& img {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: auto; height: auto;
min-width: 100%; min-height: 100%; object-fit: cover;}
& .fade-marker {
position: absolute; padding: 0 .5rem; height: 2rem; line-height: 2rem; border-radius: 5px;
color: white; background-color: rgba(0, 0, 0, .55); text-align: center; top: 1rem;
left: 1rem; font-size: .875rem; font-family: arial, sans-serif;
}
&:focus img { animation-play-state: paused !important;}
}
@media (any-hover: hover) {
[data-fade]:hover img { animation-play-state: paused !important;}
}`
}));
containers.forEach(container => {
const keyframeName = `fade-animate-${Math.random().toString(36).slice(2, 11)}`;
container.id = `animated-${keyframeName}`;
container.setAttribute('aria-hidden', 'true');
if (!['absolute', 'relative'].includes(getComputedStyle(container.parentElement).position)) {
container.parentElement.style.position = 'relative !important';
}
container.parentElement.style.display = 'inline-block !important';
container.style.paddingBottom = `${container.dataset.fadeRatio || 100}%`;
const images = container.querySelectorAll('img:not([inert])');
if (images.length === 0) return;
const params = {
delay: 5,
fadeIn: .5,
fadeOut: .5,
loop: true, // Mis à true par défaut, mais ce n'est pas la meilleure idée…
marker: false, ...parseParams(container.dataset.fade)
};
if (shouldReduceMotion) {
const defaultImg = Array.from(images).find(img => img.hasAttribute('data-fade-default')) || images[0];
images.forEach(img => img === defaultImg ? img.style.opacity = '1' : img.remove());
return;
}
let markerElement = null;
if (params.marker) {
markerElement = container.querySelector('.fade-marker');
if (!markerElement) {
markerElement = document.createElement('span');
markerElement.className = 'fade-marker';
markerElement.setAttribute('aria-hidden', 'true');
container.appendChild(markerElement);
}
}
const duration = images.length * params.delay;
const fadeInPercent = (params.fadeIn / duration) * 100;
const displayPercent = (params.delay / duration) * 100;
let cssContent = `
@keyframes ${keyframeName} {
0% { opacity: 0; }
${fadeInPercent}% { opacity: 1; }
${displayPercent}% { opacity: 1; }
${displayPercent + (params.fadeOut / duration) * 100}% { opacity: 0; }
100% { opacity: 0; }
}`;
// Keyframe spéciale pour la dernière image si loop = false
if (!params.loop) {
cssContent += `
@keyframes ${keyframeName}-last {
0% { opacity: 0; }
${fadeInPercent}% { opacity: 1; }
100% { opacity: 1; }
}`;
}
// Ajout du style dans <head>
document.head.appendChild(Object.assign(document.createElement('style'), {
id: `dynamic-${keyframeName}`,
textContent: cssContent
}));
images.forEach((img, i) => {
img.style.opacity = '0';
const isLast = i === images.length - 1;
const animName = (!params.loop && isLast) ? `${keyframeName}-last` : keyframeName;
img.style.animation = params.loop
? `${duration}s ${i * params.delay}s infinite ${animName}`
: `${duration}s ${i * params.delay}s 1 ${isLast ? 'forwards ' : ''}${animName}`;
img.style.animationPlayState = 'paused';
// Marquer l'animation comme terminée pour ne plus la toucher
if (!params.loop && isLast) {
img.addEventListener('animationend', function () {
this.dataset.animationEnded = 'true';
});
}
});
if (markerElement) {
let currentIndex = 0;
let isPaused = false;
markerElement.textContent = `1\u00A0sur\u00A0${images.length}`;
const markerControl = {
pause: () => {
isPaused = true;
},
resume: () => {
isPaused = false;
}
};
markerIntervals.set(container, markerControl);
setInterval(() => {
if (!isPaused) {
currentIndex = (currentIndex + 1) % images.length;
markerElement.textContent = `${currentIndex + 1}\u00A0sur\u00A0${images.length}`;
}
}, params.delay * 1000);
['mouseenter', 'focus'].forEach(e => container.addEventListener(e, markerControl.pause));
['mouseleave', 'blur'].forEach(e => container.addEventListener(e, markerControl.resume));
}
observer.observe(container);
});
}
document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', initFade) : initFade();
})();
Télécharger kortic-fade-animate.js
Utilisation de base
Mettez vos images dans un <div> ou autre qui porte l'attribut data-fade. Le script fait le reste pendant que vous sirotez votre café.
<div data-fade>
<img src="chat1.jpg" alt="Chat qui dort">
<img src="chat2.jpg" alt="Chat qui mange">
<img src="chat3.jpg" alt="Chat qui juge vos choix de vie">
</div>
Par défaut :
- Chaque image reste affichée 5 secondes
- Fondu d'entrée : 0.5s
- Fondu de sortie : 0.5s
- Boucle : oui (mais ce n'est clairement pas une bonne idée pour l'accessibilité…)
- Ratio du container : 100% (carré parfait)
Configuration
Vous êtes du genre contrôle freak ? Parfait, on peut tout personnaliser.
Paramètres disponibles
Passez vos réglages en JSON dans l'attribut data-fade :
| Paramètre | Type | Défaut | Description |
|---|---|---|---|
delay |
number | 5 | Durée d'affichage de chaque image (en secondes) |
fadeIn |
number | 0.5 | Durée du fondu d'apparition (en secondes) |
fadeOut |
number | 0.5 | Durée du fondu de disparition (en secondes) |
marker |
boolean | true | Affiche le nombre de vues et la vue courante |
loop |
boolean | true | Ça tourne en boucle. Ou pas si false |
Ratio du container
Utilisez data-fade-ratio pour définir la hauteur du container en pourcentage de sa largeur :
<!-- Format panoramique (40% de hauteur) -->
<div data-fade data-fade-ratio="40">...</div>
<!-- Format carré (100% par défaut) -->
<div data-fade>...</div>
<!-- Format portrait (133% de hauteur) -->
<div data-fade data-fade-ratio="133">...</div>
Etc.
Vos images ne sont pas adaptées tout pile comme il faut ? Bah allez, on s'en occupe aussi : elles couvriront toujours l'espace, sans déformation. Le mieux étant tout de même de vous arranger pour que ce ne soit pas moche.
Exemples
rapide et nerveux
Mes neuf oeufs (comme on dit en Alsacien).
<figure data-fade='{"delay": 1, "fadeIn": 0.2, "fadeOut": 0.3}' class="border-radius">
<img src="https://www.kortic.com/assets/images/full/800/egg.jpg.webp" alt="">
<img src="https://www.kortic.com/assets/images/full/800/egg-1.jpg.webp" alt="">
<img src="https://www.kortic.com/assets/images/full/800/egg-2.jpg.webp" alt="">
<img src="https://www.kortic.com/assets/images/full/800/egg-3.jpg.webp" alt="">
<img src="https://www.kortic.com/assets/images/full/800/egg-4.jpg.webp" alt="">
<img src="https://www.kortic.com/assets/images/full/800/egg-5.jpg.webp" alt="">
<img src="https://www.kortic.com/assets/images/full/800/egg-6.jpg.webp" alt="">
<img src="https://www.kortic.com/assets/images/full/800/egg-7.jpg.webp" alt="">
<img src="https://www.kortic.com/assets/images/full/800/egg-8.jpg.webp" alt="">
</figure>
Slideshow contemplatif et panoramique (25:14 ~ 40%)
<div data-fade='{"delay": 5, "fadeIn": 2, "fadeOut": 3, "marker": true}' data-fade-ratio="40" aria-hidden="true">
<!-- les images -->
</div>
Styles CSS
Le script injecte du CSS de base pour que ça fonctionne, mais vous pouvez ajouter vos propres styles sur le container :
[data-fade] {
border-radius: 1rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.1); /* ce qui ne fonctionnera pas vu que overflow: hidden;*/
}
[data-fade] img {
filter: brightness(0.9); /* Un peu plus sombre */
}
/* bref, à vous de voir ! */
Accessibilité
Le script n'est pas un sauvage, il respecte vos utilisateurs :
prefers-reduced-motion: Si activé, seule la première image s'affiche (pas d'animation)- Pause automatique : L'animation se met en pause quand :
- Le container sort du viewport
- L'onglet perd le focus
- Vous survolez/focusez le container
Le mieux étant de mettre le container en aria-hidden="true" si les images ne sont là que pour faire joli ou énerver les TDAH.
Critère WCAG 2.2.2
L'entorse de la configuration de la boucle par défaut.
Le critère WCAG 2.2.2 précise bien que tout contenu en mouvement, clignotant ou défilant qui démarre automatiquement, dure plus de 5 secondes, et est présenté en parallèle d'autre contenu, doit pouvoir être mis en pause, arrêté ou masqué par l'utilisateur.
À vous de décider ce que vous en faites, en assumant les éventuelles conséquences.
Mode réduit manuel
Si vous gérez la suppression des animations, ce qui est plutôt une bonne idée, avec, par exemple, une classe .no-motion (comme sur ce site…), il faut adapter le script en conséquence.
Vous voulez tester ? Appuyez sur Touche clavier r r de votre clavier.
En cherchant dans les articles, vous trouverez même un exemple de widget d'accessibilité fait maison…
Fonctionnalités avancées
Exclure des images de l'animation
Ajoutez l'attribut inert sur une image pour qu'elle soit ignorée :
<div data-fade>
<img src="photo1.jpg" alt="Visible">
<img src="photo2.jpg" alt="Visible aussi">
<img src="watermark.png" alt="Toujours là" inert>
</div>
Plusieurs galeries sur une page
Pas de problème, mettez-en autant que vous voulez :
<div data-fade='{"delay": 3}'>
<img src="work1.jpg" alt="Projet 1">
<img src="work2.jpg" alt="Projet 2">
</div>
<div data-fade='{"delay": 5}'>
<img src="portrait1.jpg" alt="Portrait 1">
<img src="portrait2.jpg" alt="Portrait 2">
</div>
Chaque galerie a sa propre animation indépendante.
Troubleshooting
-
Les images ne s'affichent pas :
- Vérifiez que vos URLs d'images sont correctes
- Le container a bien l'attribut
data-fade? - Le script est chargé avant la fermeture du
</body>?
-
L'animation ne démarre pas :
- Ouvrez la console : y'a-t-il des erreurs ?
- Le JSON dans
data-fadeest-il valide ? (guillemets doubles obligatoires) - Vous avez
prefers-reduced-motionactivé ?
-
Ça ne marche vraiment pas ?
- Tant pis.
Notes techniques
- Le script utilise IntersectionObserver
- Les animations sont en CSS (performant, accéléré GPU)
- Chaque galerie a son propre
@keyframesunique (pas de conflit) - Le script s'auto-initialise au
DOMContentLoaded
That's all, Folks !