UI Patterns

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

Open in a new tab

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

PackageInstallation
embla-carousel
npm i -S embla-carousel
embla-carousel-wheel-gestures
npm i -S 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

For visual validation

For functional validation

References