Carrousel accessible v2

Carrousel accessible v2

Publié par Fabrice le 5 mai 2026 - Temps de lecture estimé : 23 minutes
Catégories : Webmastering
Étiquettes :

Le temps était venu de moderniser ce composant JavaScript, même si son utilité reste toujours discutable !

On pourrait longuement palabrer sur l'utilité d'un carrousel, mais en attendant l'arrivée du masonry layout avec display: grid-lanes; et de sa mise en page façon Pinterest - voir d'opter pour un défilement horizontal, l'élaboration de ce type de projet demeure une expérience enrichissante du fait des compétences mises en œuvre : accessibilité, CSS et JavaScript.
Cette nouvelle version est évidemment responsive et entièrement en langages natifs. De plus, ce composant fonctionne avec des images de différentes tailles, elles sont contenues à l'intérieur du bloc et centrées verticalement et horizontalement : la magie de object-fit couplé à grid.

Rappel : l'accessibilité d'un carrousel

Vous pouvez vous référer à l'article de la première version du carrousel pour des explications plus exhaustives.

  • Les contenus en mouvement doivent être contrôlables : bouton pause en début de code et raccourcis clavier pour le contrôle du carrousel
  • Zone live ARIA : aria-live="polite" et focus sur l'élément actif permet la lecture du panneau en cours
  • Attributs ARIA : aria-controls, aria-selected et role="group" sur les boutons de la navigation par points, aria-roledescription pour indiquer le rôle de chaque contenu, aria-label complète les informations
  • Arrêt du carrousel à la prise de focus

Voir la démo sur CodePen

La base HTML et ses nouveautés

  • Séparation du carrousel et des éléments de contrôles, le bouton d'arrêt se situant en premier dans l'arbre DOM
  • Ajout de aria-live="off" par défaut sur le conteneur
  • Ajout de tabindex sur chaque panneau (0 pour le panneau actif et -1 pour les autres)
<section id="slides" aria-roledescription="Slideshow" aria-label="Description of Carousel">
	
	<!-- Slide Controls -->
	<div class="slides-panel-control">
		<div class="slides-control">
			<button type="button" class="slides-playpause" aria-controls="slides-items" aria-label="Stop Carousel" disabled>
				<svg class="playpause" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32" viewBox="0 0 32 32" role="img">
					<g class="play">
						<path d="m7.43 31c2.36 0 4.29-1.93 4.29-4.29v-21.4c0-2.36-1.93-4.29-4.29-4.29-2.36 0-4.29 1.93-4.29 4.29v21.4c0 2.36 1.93 4.29 4.29 4.29zm12.9-25.7v21.4c0 2.36 1.93 4.29 4.29 4.29s4.29-1.93 4.29-4.29v-21.4c0-2.36-1.93-4.29-4.29-4.29s-4.29 1.93-4.29 4.29z"></path>
					</g>
					<g class="pause">
						<path d="m6.29 0.988c1.75-0.00742 13.3 8.37 20 12.4 2.61 1.35 2.72 3.64 0.145 5.18-6.19 3.67-18.7 12.4-20.3 12.4-1.59 0.0055-2.31-1.16-2.36-2.73 0.0104-8.27-0.0208-16.5 0.0156-24.8-0.0349-1.19 0.787-2.46 2.53-2.47z"></path>
					</g>
				</svg>		
			</button>
			<button type="button" class="slides-prev" aria-controls="slides-items" aria-label="Previous Slide" disabled>
				<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32" viewBox="0 0 32 32" role="img">
					<path d="m22.1 31c2.03-0.108 3.01-2.54 1.62-4.03l-10.4-10.8 10.4-11c2.34-2.26-1.13-5.73-3.39-3.39l-12 12.6c-0.903 0.904-0.937 2.36-0.0775 3.3l12 12.5c0.483 0.548 1.19 0.846 1.92 0.808z"></path>
				</svg>
			</button>
			<button type="button" class="slides-next" aria-controls="slides-items" aria-label="Next Slide" disabled>
				<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32" viewBox="0 0 32 32" role="img">
					<path d="m9.9 31c-2.03-0.108-3.01-2.54-1.62-4.03l10.4-10.8-10.4-11c-2.34-2.26 1.13-5.73 3.39-3.39l12 12.6c0.903 0.904 0.937 2.36 0.0775 3.3l-12 12.5c-0.483 0.548-1.19 0.846-1.92 0.808z"></path>
				</svg>
			</button>
		</div>
	</div>
	
	<div id="slides-items" aria-live="off">
		
		<!-- Slide 1 // Active slide: class="slideactive" and tabindex="0" -->
		<div id="slide-1" class="slide-item slideactive" role="group" aria-roledescription="Slide" aria-label="1 of 3" tabindex="0">
			<figure>
				<img src="img1.webp" alt="Alternative image 1" width="1200" height="600" loading="lazy" decoding="async">
			</figure>
			<figcaption>Slide 1 Description</figcaption>
		</div>
		<!-- Slide 2 -->
		<div id="slide-2" class="slide-item" role="group" aria-roledescription="Slide" aria-label="2 of 3" tabindex="-1">
			<figure>
				<img src="img2.webp" alt="Alternative image 2" width="556" height="247" loading="lazy" decoding="async">
			</figure>
			<figcaption>Slide 2 Description</figcaption>
		</div>
		<!-- Slide 3 -->
		<div id="slide-3" class="slide-item" role="group" aria-roledescription="Slide" aria-label="3 of 3" tabindex="-1">
			<figure>
				<img src="img3.webp" alt="Alternative image 3" width="1200" height="1800" loading="lazy" decoding="async">
			</figure>
			<figcaption><strong>Slide 3 Description.</strong><br><span lang="lat">Venerem elegerit sexus mercenariae tabernaculum matrimonii in conductae post species.</span></figcaption>
		</div>
		
	</div>
	
</section>

Le code CSS et ses nouveautés

  • Passage en Vanilla CSS et ajout de @layer pour une meilleure intégration dans les projets
  • Effet de transition avec transition-behavior: allow-discrete; et @starting-style pour un support complet
  • Exit le positionnement relatif et absolu, place à grid stack et flex
  • Utilisation de display: none; sur les panneaux inactifs pour éviter l'interprétation par un lecteur d'écran
  • Ajout de object-fit pour contraindre l'image à l'intérieur du conteneur
@layer resetdemo, carousel;

@layer resetdemo {
	*,
	*::before,
	*::after {
		box-sizing: border-box; 
		min-width: 0;
	}
	body {
		margin: 0;
		font-size: 1rem;
		font-family: system-ui, sans-serif;
		line-height: 1.5;
	}
	img, figure  {
		max-width: 100%; 
		height: auto;
		margin: 0;
	}
	svg {
		vertical-align: middle;
	}
	button {
		padding: 0;
		margin: 0;
		border-style: none;
		touch-action: manipulation;
		display: inline-block;
		border: none;
		background: none;
		cursor: pointer;
		
		&:focus-visible {
			outline: max(2px, .08em) dashed currentColor;
			outline-offset: max(2px, .08em);
		}	
	}
}

@layer carousel {
	:root {
		--carousel-width: 1200px;
		--carousel-aspect-ratio: 12/6; 
		--carousel-color: DarkOrchid;
		--carousel-bg: AntiqueWhite;
	}
	#slides {
		max-width: var(--carousel-width);
		margin: 3.618em auto;
		
		.slides-panel-control {
			display: flex;
			justify-content: space-between;
		}
		/* Control Buttons */
		.slides-control, .slides-dots {		
			display: flex;
			flex-wrap: wrap;
			justify-content: center;
			margin-bottom: 1em;
			gap: 1em;
		}
		.slides-control {
			
			button {
				background-color: rgba(0, 0, 0, 0.7);
				border-radius: .3em;
				display: inline-block;
				padding: 1em;
				
				svg {
					width: 1.618em;
					height: 1.618em;
				}			
				&:disabled {
					display: none;
				}
				&:hover {
					background-color: var(--carousel-color);
				}
				:is(svg, g) {
					pointer-events: none;
					fill: #fff;
				}
			}
			.slides-playpause .play,
			.slides-playpause .playpause.paused .pause {
				display: none;
			}
			.slides-playpause .playpause.paused .play {
				display: block;
			}
		}
		/* Dots Buttons */
		.slides-dots {
			
			button {
				display: inline-block;
				padding: 1em;
				background-color: transparent;
			}
			svg {
				pointer-events: none;
				width: 1.618em;
				height: 1.618em;
				fill: rgba(0, 0, 0, 0.7);
			}
			:is(button:hover, button:focus, button:active) svg {
				fill: transparent;
				stroke: var(--carousel-color);
				stroke-width: .3ch;
			}
			button[aria-selected="true"] svg {
				fill: var(--carousel-color);
			}
		}
		@media (width < 36rem) {
			.slides-panel-control {
				display: block;
			} 
		}
	}
	
	#slides-items {
		display: grid;
		grid: 1fr / 1fr; /* Not necessary - for greater clarity */
	
		> .slide-item {
			grid-area: 1 / 1;
			overflow: hidden; /* For responsive */
			aspect-ratio: var(--carousel-aspect-ratio); /* For responsive */
			display: grid;	
			grid: 1fr / 1fr; /* Not necessary - for greater clarity */
			background: var(--carousel-bg);
	
			> * {
				grid-area: 1 / 1;
			}
			
			figure {
				align-self: center;
				justify-self: center;
				max-height: calc(var(--carousel-width) / (var(--carousel-aspect-ratio)));	
				
				img {
					aspect-ratio: var(--carousel-aspect-ratio); /* For responsive */
					max-height: calc(var(--carousel-width) / (var(--carousel-aspect-ratio)));
					object-fit: contain;
				} 
			}			
			figcaption {
				align-self: end;	
				font-size: clamp(.8em, .6em + .7vw, 1.2em);
				text-align: center;
				color: #fff;
				padding: .9em;
				background-color: rgba(0, 0, 0, 0.7);
			}
		}
		.slideactive:focus-visible {
			outline: max(2px, .08em) solid currentColor;
			outline-offset: 0;	
		}	
		/* CSS Transition */
		.slideactive {
			opacity: 1;
			transition: all 2s;
			transition-behavior: allow-discrete;
			@starting-style {
				opacity: 0;
			}
		}
		.slide-item:not(.slideactive) {
			opacity: 0;
			display: none;
		}		
	}
}

Le code JavaScript et ses nouveautés

  • Ajout du focus sur l'élément en cours et gestion de tabindex (0 et -1) pour une meilleure restitution avec un lecteur d'écran
  • Détection de prefers-reduced-motion et désactivation de la rotation automatique, si nécessaire
  • Ajout du swipe pour mobile
  • Arrêt du carrousel à la prise de focus sur le panneau en cours lorsque la rotation automatique est activée
/*
* @Adilade Slideshow/Carousel
* @See www.adilade.fr/blog/carrousel-accessible-v2/
* @Version : 2.0
*
* Keyboard : 
* Previous : left arrow or ctrl + left arrow
* Next : right arrow or ctrl + right arrow
* Pause/Play : space
* 
* Free to use - No warranty
*/

var timeInterval = '7000'; // Time between slides in ms
var slidediv = document.querySelector('#slides');
var carousel = document.querySelector('#slides-items');
var items = document.querySelectorAll('.slide-item');
var panel = document.querySelector('.slides-control');

if (carousel !== undefined && items !== undefined && carousel !== null && items !== null) {
	var itemscount = items.length;
	var btnprev = document.querySelector('.slides-prev');
	var btnnext = document.querySelector('.slides-next');
	var btnplaypause = document.querySelector('.slides-playpause');
	var btnplaypausepath = document.querySelector('.playpause');			

	// If JavaScript enabled
	btnprev.removeAttribute('disabled');
	btnnext.removeAttribute('disabled');
	btnplaypause.removeAttribute('disabled');
			
	if (itemscount > 1) {
		
		// Create Dots
		var dotbox = document.createElement('div');
		dotbox.classList.add('slides-dots');
		dotbox.setAttribute('role', 'group');
		panel.after(dotbox); 		
		for (var i=0; i<itemscount; i++) {
			dotbox.insertAdjacentHTML('beforeend', '<button type="button" aria-controls="slide-'+(i+1)+'" aria-label="Slide number '+(i+1)+'" aria-selected="'+(document.querySelector('.slideactive').getAttribute('id').slice(-1) == (i+1) ? 'true' : 'false')+'"><svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32" viewBox="0 0 32 32" role="img"><circle cx="16" cy="16" r="13" /></svg></button>');
		}	
			
		var dots = document.querySelectorAll('.slides-dots button');
		var playpause = null;
	
		function slideprev() {
			var itemcurrent = document.querySelector('.slideactive');
			var dotcurrent = document.querySelector('.slides-dots button[aria-selected="true"]');
			var prevslide = itemcurrent.previousElementSibling;
			var prevdot = dotcurrent.previousElementSibling;
			if(prevslide === null) {
				prevslide = items[itemscount - 1];
				prevdot = dots[itemscount - 1]; 
			}
			// Remove current
			itemcurrent.classList.remove('slideactive');
			itemcurrent.blur();
			itemcurrent.setAttribute('tabindex', '-1');
			dotcurrent.setAttribute('aria-selected', 'false');
					
			// Add Next
			prevslide.setAttribute('tabindex', '0');
			prevslide.classList.add('slideactive');
			prevslide.focus();
			prevdot.setAttribute('aria-selected', 'true');
		}
		
		function slidenext() {
			var itemcurrent = document.querySelector('.slideactive');
			var dotcurrent = document.querySelector('.slides-dots button[aria-selected="true"]');
			var nextslide = itemcurrent.nextElementSibling;
			var nextdot = dotcurrent.nextElementSibling;
			if(nextslide === null) {
				nextslide = items[0];
				nextdot = dots[0];
			}
			// Remove current
			itemcurrent.setAttribute('tabindex', '-1');
			itemcurrent.classList.remove('slideactive');
			itemcurrent.blur();
			dotcurrent.setAttribute('aria-selected', 'false');
								
			// Add Next
			nextslide.setAttribute('tabindex', '0');
			nextslide.classList.add('slideactive');
			nextslide.focus();
			nextdot.setAttribute('aria-selected', 'true');
		}

		function slidepause() {
			clearInterval(playpause);
			playpause = null;
			btnplaypause.setAttribute('aria-label', 'Play Carousel');
			btnplaypausepath.classList.add('paused');
			carousel.setAttribute('aria-live', 'off');		
		}
		function slideplay() {
			playpause = setInterval(slidenext, timeInterval);
			btnplaypause.setAttribute('aria-label', 'Stop Carousel');
			btnplaypausepath.classList.remove('paused');							
		}						
		function slideplaypause() {
			if (playpause !== null) {
				slidepause();
				carousel.classList.add('btnpressed');
			} else {
				slideplay();
				carousel.classList.remove('btnpressed');
				//carousel.setAttribute('aria-live', 'off');
			}
		}
						
		// Dots Navigate
		[].map.call(dots, function(dot) {

			dot.addEventListener('click', function(e) {
				var itemcurrent = document.querySelector('.slideactive');
				var dotcurrent = document.querySelector('.slides-dots button[aria-selected="true"]');
				var dotclick = dot.getAttribute('aria-controls');
				var targetslide = document.querySelector('#'+ dotclick +'');
				var targetdot = document.querySelector('button[aria-controls="'+dotclick+'"]');

				// Remove current
				itemcurrent.setAttribute('tabindex', '-1');
				itemcurrent.classList.remove('slideactive');
				dotcurrent.setAttribute('aria-selected', 'false');
				itemcurrent.blur();
									
				// Add Target
				targetslide.setAttribute('tabindex', '0');
				targetslide.classList.add('slideactive');
				targetdot.setAttribute('aria-selected', 'true');
				targetslide.focus();
				
				if (playpause !== null) {
					slidepause();
				}
				carousel.setAttribute('aria-live', 'polite');
				
				e.preventDefault();
			
			});
			
		});
				
		// Navigate
		btnprev.addEventListener('click', function() {
			slideprev();
			if (playpause !== null) {
				slidepause();
			}
			carousel.setAttribute('aria-live', 'polite');
		});
		btnnext.addEventListener('click', function() {
			slidenext();			 
			if (playpause !== null) {
				slidepause();
			}
			carousel.setAttribute('aria-live', 'polite');
		});
		btnplaypause.addEventListener('click', slideplaypause);
		
		// Keyboard Navigate
		slidediv.addEventListener('keydown', keyHandler);		
		function keyHandler(e) {
			// Left Arrow
			if (e.keyCode === 37 || (e.ctrlKey && e.keyCode === 37)) {		
				
				slideprev();
				if (playpause !== null) {
					slidepause();
				}
				e.preventDefault();
			}
			// Right Arrow
			if (e.keyCode === 39 || (e.ctrlKey && e.keyCode === 39)) {		
				
				slidenext();
				if (playpause !== null) {
					slidepause();
				}
				e.preventDefault();
			}
			// Space
			if (e.keyCode === 32) {		
				
				slideplaypause();
				e.preventDefault();
			}
		}
		
		// Mobile/Swipe Navigate
		// Credit : https://www.kirupa.com/html5/detecting_touch_swipe_gestures.htm	
		carousel.addEventListener('touchstart', startTouch, false);
		carousel.addEventListener('touchmove', moveTouch, false);

		// Swipe Up / Down / Left / Right
		var initialX = null;
		var initialY = null;
		
		function startTouch(e) {
			initialX = e.touches[0].clientX;
			initialY = e.touches[0].clientY;
		};
		
		function moveTouch(e) {
			if (initialX === null) {
				return;
			}
		
			if (initialY === null) {
				return;
			}
		
			var currentX = e.touches[0].clientX;
			var currentY = e.touches[0].clientY;
			
			var diffX = initialX - currentX;
			var diffY = initialY - currentY;
			
			if (Math.abs(diffX) > Math.abs(diffY)) {
				// sliding horizontally
				if (diffX > 0) {
					// swiped left
					slidenext();
					if (playpause !== null) {
						slidepause();
					}
				} else {
					// swiped right
					slideprev();
					if (playpause !== null) {
						slidepause();
					}
				}  
			}				
			initialX = null;
			initialY = null;
			
			e.preventDefault();
		};				    	
				 
		// Animate Slides - Comment next line for disable auto-rotating
		playpause = setInterval(slidenext, timeInterval);
		
		// Stop Carousel on focusing slide only when Carousel auto-rotating
		document.querySelector('.slideactive').addEventListener('focus', function() {
			if(!carousel.classList.contains('btnpressed')) {
			  slidepause();
			}
		}); 
		
		// Prefers-reduced-motion
		var hasReducedMotion = (window.matchMedia('(prefers-reduced-motion: reduce)') === true || window.matchMedia('(prefers-reduced-motion: reduce)').matches === true);
		if (hasReducedMotion) {
			slidepause();
		} 
		
	} else { // End itemscount < 1 => Remove buttons controls
		btnprev.parentNode.removeChild(btnprev);
		btnnext.parentNode.removeChild(btnnext);
		btnplaypause.parentNode.removeChild(btnplaypause);
	}
	
} // End if test