CarouselStatusdevelopment
A horizontal list of related content items that users can move through with previous and next buttons, by swiping, or by tabbing.
Guidance
Use carousels with caution. Content that starts out of view is easy to miss, so do not put anything users need in a carousel. A carousel can be useful to show a set of related, optional items, such as related articles, in a small space, as long as the heading gives users enough context to decide whether to browse them.
This pattern does not use the common accessible carousel conventions: aria-roledescription values of 'carousel' and 'slide', 'slide 1 of 7' labels, hiding items that are out of view from screen readers and the tab order, and announcing changes in a live region. In our testing these confused screen reader users and made the content harder to navigate. Instead, the carousel is presented as a named region containing a simple list, in which every item can be reached by tabbing. When an item that is out of view receives focus, the carousel moves to show it.
The previous and next buttons come before the list in the source order, so keyboard users can use them without tabbing through every item. At the start and end of the list the buttons use aria-disabled rather than disabled, so focus stays on the button instead of being lost.
Do not autoplay a carousel. Movement that starts automatically distracts users and moves content away while they are reading it.
The pattern progressively enhances a list that scrolls horizontally. Without JavaScript, users can scroll through the list and every item can be reached. With JavaScript, Embla Carousel adds the buttons and moves the list, and movement is instant when the user prefers reduced motion. Embla does not respond to trackpad swipes, which the browser sends as wheel events, so the wheel gestures plugin is used to add them. It only responds to horizontal gestures, so scrolling vertically over the carousel still scrolls the page.
Example
Code
<section class="carousel js-carousel" aria-labelledby="carousel-heading">
<div class="carousel__hd">
<div>
<h2 class="carousel__heading" id="carousel-heading">Plan your visit</h2>
<p class="carousel__description">
Everything you need to know before you arrive
</p>
</div>
<div class="carousel__controls js-carousel__controls" hidden>
<button type="button" class="carousel__btn js-carousel__prev">
<span class="visuallyhidden">Previous</span>
<svg focusable="false" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 -960 960 960" fill="currentColor">
<path d="M400-240 160-480l241-241 43 42-169 169h526v60H275l168 168-43 42Z"></path>
</svg>
</button>
<button type="button" class="carousel__btn js-carousel__next">
<span class="visuallyhidden">Next</span>
<svg focusable="false" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 -960 960 960" fill="currentColor">
<path d="m560-242-43-42 168-168H160v-60h525L516-681l43-42 241 241-240 240Z"></path>
</svg>
</button>
</div>
</div>
<div class="carousel__viewport js-carousel__viewport">
<ul class="carousel__track" role="list">
<li class="carousel__slide">
<div class="carousel__card">
<svg class="carousel__card-img" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" width="720" height="405" viewBox="0 0 720 405">
<rect fill="#ddd" width="720" height="405"></rect>
</svg>
<div class="carousel__card-bd">
<h3 class="carousel__card-heading">
<a class="carousel__link" href="#">Planning your visit</a>
</h3>
<p>
Opening times, ticket prices and what to bring.
</p>
</div>
</div>
</li>
<li class="carousel__slide">
<div class="carousel__card">
<svg class="carousel__card-img" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" width="720" height="405" viewBox="0 0 720 405">
<rect fill="#ddd" width="720" height="405"></rect>
</svg>
<div class="carousel__card-bd">
<h3 class="carousel__card-heading">
<a class="carousel__link" href="#">Accessibility</a>
</h3>
<p>
Step-free routes, quiet spaces and assistance dogs.
</p>
</div>
</div>
</li>
<li class="carousel__slide">
<div class="carousel__card">
<svg class="carousel__card-img" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" width="720" height="405" viewBox="0 0 720 405">
<rect fill="#ddd" width="720" height="405"></rect>
</svg>
<div class="carousel__card-bd">
<h3 class="carousel__card-heading">
<a class="carousel__link" href="#">Getting here</a>
</h3>
<p>
Bus, train and cycle routes to the main entrance.
</p>
</div>
</div>
</li>
<li class="carousel__slide">
<div class="carousel__card">
<svg class="carousel__card-img" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" width="720" height="405" viewBox="0 0 720 405">
<rect fill="#ddd" width="720" height="405"></rect>
</svg>
<div class="carousel__card-bd">
<h3 class="carousel__card-heading">
<a class="carousel__link" href="#">Parking and drop-off</a>
</h3>
<p>
Blue badge spaces, coach parking and charging points.
</p>
</div>
</div>
</li>
<li class="carousel__slide">
<div class="carousel__card">
<svg class="carousel__card-img" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" width="720" height="405" viewBox="0 0 720 405">
<rect fill="#ddd" width="720" height="405"></rect>
</svg>
<div class="carousel__card-bd">
<h3 class="carousel__card-heading">
<a class="carousel__link" href="#">Food and drink</a>
</h3>
<p>
Cafés, picnic areas and dietary requirements.
</p>
</div>
</div>
</li>
<li class="carousel__slide">
<div class="carousel__card">
<svg class="carousel__card-img" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" width="720" height="405" viewBox="0 0 720 405">
<rect fill="#ddd" width="720" height="405"></rect>
</svg>
<div class="carousel__card-bd">
<h3 class="carousel__card-heading">
<a class="carousel__link" href="#">Group bookings</a>
</h3>
<p>
Discounts and guided tours for groups of ten or more.
</p>
</div>
</div>
</li>
<li class="carousel__slide">
<div class="carousel__card">
<svg class="carousel__card-img" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" width="720" height="405" viewBox="0 0 720 405">
<rect fill="#ddd" width="720" height="405"></rect>
</svg>
<div class="carousel__card-bd">
<h3 class="carousel__card-heading">
<a class="carousel__link" href="#">Frequently asked questions</a>
</h3>
<p>
Answers to the questions we are asked most often.
</p>
</div>
</div>
</li>
</ul>
</div>
</section>import EmblaCarousel from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
const node = document.querySelector('.js-carousel');
const viewport = node.querySelector('.js-carousel__viewport');
const controls = node.querySelector('.js-carousel__controls');
const prev = node.querySelector('.js-carousel__prev');
const next = node.querySelector('.js-carousel__next');
node.classList.add('is--enhanced');
viewport.scrollLeft = 0;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const embla = EmblaCarousel(viewport, {
align: 'start',
containScroll: 'trimSnaps',
slidesToScroll: 'auto',
duration: reducedMotion ? 0 : 25,
// Embla only follows focus that comes from the Tab key, so focus is handled below instead
watchFocus: false
}, [WheelGesturesPlugin()]);
const update = () => {
controls.hidden = !embla.canScrollPrev() && !embla.canScrollNext();
prev.setAttribute('aria-disabled', String(!embla.canScrollPrev()));
next.setAttribute('aria-disabled', String(!embla.canScrollNext()));
};
const isInView = element => {
const item = element.getBoundingClientRect();
const view = viewport.getBoundingClientRect();
return item.left >= Math.floor(view.left) && item.right <= Math.ceil(view.right);
};
const bringIntoView = element => {
// Instant scrolls render synchronously, so step towards the item until it's in view
for (let i = embla.scrollSnapList().length; i > 0 && !isInView(element); i--) {
const isBefore = element.getBoundingClientRect().left < viewport.getBoundingClientRect().left;
if (isBefore ? !embla.canScrollPrev() : !embla.canScrollNext()) break;
if (isBefore) embla.scrollPrev(true);
else embla.scrollNext(true);
}
};
const isMoving = () => Math.abs(embla.scrollProgress() - embla.scrollSnapList()[embla.selectedScrollSnap()]) > 0.001;
let pendingFocus = null;
prev.addEventListener('click', () => embla.scrollPrev());
next.addEventListener('click', () => embla.scrollNext());
viewport.addEventListener('focusin', e => {
// The browser scrolls the clipped viewport to show a focused item, which puts it out of step with Embla
viewport.scrollLeft = 0;
// Ignore focus from a pointer, so pressing on a partly visible item to drag doesn't move the carousel
if (!e.target.matches(':focus-visible')) return;
bringIntoView(e.target);
// An item in view now may be moved out of view by a scroll that's still running, so check again when it stops
if (isMoving()) pendingFocus = e.target;
});
embla.on('settle', () => {
const element = pendingFocus;
pendingFocus = null;
if (element && element === document.activeElement) bringIntoView(element);
});
// The user has taken over, so don't move the carousel back to the focused item
embla.on('pointerDown', () => { pendingFocus = null; });
embla.on('select', update);
embla.on('reInit', update);
update();
/* Without JS the viewport scrolls natively, snapping to each item */
.carousel__viewport {
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
}
/* Once enhanced, Embla moves the track */
.carousel.is--enhanced .carousel__viewport {
overflow: hidden;
scroll-snap-type: none;
}
.carousel__track {
display: flex;
margin-left: calc(var(--gutter) * -1);
touch-action: pan-y pinch-zoom;
}
.carousel__slide {
flex: 0 0 85%;
min-width: 0;
padding-left: var(--gutter);
scroll-snap-align: start;
}
/* The viewport clips anything outside a slide, so draw the focus ring inside the card */
.carousel__card:has(.carousel__link:focus-visible) {
outline: 4px solid var(--highlight);
outline-offset: -4px;
}The number of items in view at each breakpoint is set by the width of .carousel__slide in CSS. Full documentation of the options is available on the Embla Carousel website.
Dependencies and installation
| Package | Installation |
|---|---|
| embla-carousel | |
| embla-carousel-wheel-gestures | |
Acceptance criteria
The following is a list of example acceptance criteria to test against when using this pattern. These criteria should test that the specific markup requirements are met, and that the carousel behaves visually and functionally as expected.
For validation in developer tools / web inspector
- The carousel container should be a
<section>
element, or have a role of region, with an accessible name taken from its heading. Userole="group"
, which is not a landmark, if the carousel is not a significant region of the page - The items should be list items in a list. Add
role="list"
to the list, as some browsers remove list semantics when list styles are removed - The items should not have an
aria-roledescription
, or an accessible name describing their position, such as 'slide 1 of 7' - Items that are out of view should not be hidden from screen readers with
aria-hidden
orvisibility: hidden
- The previous and next controls should be HTML
<button>
elements with accessible names - At the start or end of the list, the relevant button should have
aria-disabled="true"
, not thedisabled
attribute
For visual validation
- The heading should give users enough context about the items in the carousel
- Part of the next item should be visible on small screens, to show that there is more content
- The buttons should be at least 24 by 24 pixels, and should look inactive when they have no effect
- The buttons and every item should have a clearly visible focus style, which is not cut off by the edges of the carousel
- There should be sufficient contrast between the foreground and background of text, links and buttons
For functional validation
- The carousel should not move unless the user moves it
- The previous and next buttons should come before the list in the focus order
- The previous and next buttons should move the carousel by the number of items in view
- Focus should stay on a button when it is used, including when the start or end of the list is reached
- Every item should be reachable by tabbing, and an item that is out of view should be moved into view when it receives focus
- Swiping on a touch screen should move the carousel
- Swiping horizontally on a trackpad should move the carousel, and scrolling vertically over it should scroll the page
- Movement should be instant when the user has set a preference for reduced motion
- The buttons should be hidden when all items fit in view
- Without JavaScript, the list should scroll horizontally and the buttons should be hidden