Navigation

v0.0.4
Media

Carousel

A row of slides that scrolls and snaps, built from CSS alone.

On this page

A carousel is a row of slides that scrolls and snaps. It is built from CSS alone: the track is a scroll container with scroll snapping, so touch, a trackpad, the keys and assistive technology all work as scrolling does. Where the browser supports them, the stylesheet adds previous and next buttons and a dot for each slide, which the browser generates as real, focusable controls.

Carousel

<div class="carousel" role="region" aria-label="Featured projects" tabindex="0">  <article class="carousel-slide card card-flat">    <header class="card-header">      <h3 class="card-title">Atlas</h3>      <p class="card-description">A map of every deployment, live.</p>    </header>    <div class="card-body">1.28M requests in the last 24 hours.</div>  </article>  <article class="carousel-slide card card-flat">    <header class="card-header">      <h3 class="card-title">Borealis</h3>      <p class="card-description">Edge functions close to the reader.</p>    </header>    <div class="card-body">Served from 14 regions.</div>  </article>  <article class="carousel-slide card card-flat">    <header class="card-header">      <h3 class="card-title">Cirrus</h3>      <p class="card-description">Storage that follows the project.</p>    </header>    <div class="card-body">7.4 of 10 GB in use.</div>  </article></div>

#The controls

Nothing in the markup stands for the buttons or the dots. They are pseudo-elements:

  • ::scroll-button(inline-start) and ::scroll-button(inline-end) scroll the track by a page. The browser disables one at each end, and the stylesheet lets it step back.
  • ::scroll-marker on each slide is a dot; ::scroll-marker-group gathers them under the track. The dot of the slide in view matches :target-current, lengthens and takes ink.

They are placed with anchor positioning against their own carousel, whose anchor name is scoped, so several carousels on a page do not share controls. In a browser without these pseudo-elements, the carousel is a swipeable, scrollable row and loses nothing else.

#Several in view

carousel-2 and carousel-3 show two or three slides at once. A slide's width is a share of the track less the gaps, so the last one in view ends on the track's edge. Set --carousel-slide to any width for a slide that peeks.

Several in view

<div class="carousel carousel-3" role="region" aria-label="Team" tabindex="0">  <div class="carousel-slide sheet sheet-flat flex flex-col items-center gap-xs p-md">    <span class="avatar avatar-lg">      <img class="avatar-image" src="/demo/portrait-1.svg" alt>    </span>    <span class="text-sm fg-text-strong">Ada</span>  </div>  <div class="carousel-slide sheet sheet-flat flex flex-col items-center gap-xs p-md">    <span class="avatar avatar-lg">      <img class="avatar-image" src="/demo/portrait-2.svg" alt>    </span>    <span class="text-sm fg-text-strong">Alan</span>  </div>  <div class="carousel-slide sheet sheet-flat flex flex-col items-center gap-xs p-md">    <span class="avatar avatar-lg">      <img class="avatar-image" src="/demo/portrait-3.svg" alt>    </span>    <span class="text-sm fg-text-strong">Katherine</span>  </div>  <div class="carousel-slide sheet sheet-flat flex flex-col items-center gap-xs p-md">    <span class="avatar avatar-lg" role="img" aria-label="Grace">GH</span>    <span class="text-sm fg-text-strong">Grace</span>  </div>  <div class="carousel-slide sheet sheet-flat flex flex-col items-center gap-xs p-md">    <span class="avatar avatar-lg" role="img" aria-label="Margaret">MH</span>    <span class="text-sm fg-text-strong">Margaret</span>  </div></div>

#Accessibility

  • Give the track role="region", a label and tabindex="0", so the arrow keys can scroll it.
  • The generated buttons are named "Previous slide" and "Next slide" by the stylesheet. Each dot takes the name of its slide's content.
  • A carousel does not move by itself. Content that advances on a timer needs a pause control, which is a script's job.
  • With reduced motion the track jumps between slides instead of gliding.

#Classes

ClassDescription
carouselThe scrolling, snapping track. Give it role="region", a label and tabindex="0" so the arrow keys can scroll it
carousel-slideA slide: it snaps to the start of the track and takes --carousel-slide of its width
carousel-2Two slides in view. With carousel
carousel-3Three slides in view. With carousel

#Custom properties

PropertyDefaultDescription
--carousel-gap1remSpace between slides
--carousel-slide100%Width of a slide; set any width for a slide that peeks
--carousel-control2remSize of the previous and next buttons; 2.75rem on touch screens

#Source

@master/ui/components/carousel.css
/* * Carousel: a row of slides that scrolls and snaps, built from CSS alone. * The track is a scroll container with scroll snapping, so touch, trackpad, keys and assistive technology * all work as scrolling does. Where the browser supports them, ::scroll-button() adds previous and next * buttons and ::scroll-marker adds a dot per slide that follows the scroll position; both are real, * focusable controls the browser generates. Elsewhere the carousel is a swipeable, scrollable row. */@layer components {  .carousel {    --carousel-gap: 1rem;    --carousel-slide: 100%;    --carousel-control: 2rem;    /* A neutral mid-grey chevron reads on moonstone and obsidian alike. */    --carousel-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23777' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6.5 4.5 3.5 3.5-3.5 3.5'/%3E%3C/svg%3E");    position: relative;    display: flex;    gap: var(--carousel-gap);    min-width: 0;    margin: 0;    padding: 0;    overflow: auto hidden;    overscroll-behavior-x: contain;    list-style: none;    scroll-snap-type: x mandatory;    scroll-behavior: smooth;    scrollbar-width: none;    &:focus-visible {      @apply --focus-ring;    }    /* The previous and next buttons are touch targets on a touch screen. */    @media (pointer: coarse) {      --carousel-control: 2.75rem;    }    @media (prefers-reduced-motion: reduce) {      scroll-behavior: auto;    }    @supports selector(::scroll-marker) {      /* Room under the track for the markers, which sit on the carousel's own edge. */      margin-bottom: 1.75rem;      /* The controls anchor to their own carousel: the name is scoped, so several on a page do not share it. */      anchor-name: --carousel;      anchor-scope: --carousel;      scroll-marker-group: after;      /* The dots: a row centered under the track. */      &::scroll-marker-group {        position: fixed;        position-anchor: --carousel;        top: calc(anchor(bottom) + .75rem);        left: anchor(left);        right: anchor(right);        display: flex;        justify-content: center;        gap: .375rem;      }      /* Previous and next: small raised squares on the track's edges, at its middle. */      &::scroll-button(inline-start), &::scroll-button(inline-end) {        position: fixed;        position-anchor: --carousel;        top: calc(anchor(center) - var(--carousel-control) / 2);        display: grid;        place-items: center;        width: var(--carousel-control);        height: var(--carousel-control);        padding: 0;        border: 0;        border-radius: 999px;        background: var(--carousel-chevron) center / .875rem no-repeat, var(--color-surface-floating);        box-shadow: var(--shadow-raised);        cursor: pointer;        touch-action: manipulation;        -webkit-tap-highlight-color: transparent;        transition: box-shadow var(--duration-quick) var(--easing-glide), opacity var(--duration-quick) var(--easing-glide);      }      &::scroll-button(inline-start) {        content: '' / 'Previous slide';        left: calc(anchor(left) + .5rem);        scale: -1 1;      }      &::scroll-button(inline-end) {        content: '' / 'Next slide';        right: calc(anchor(right) + .5rem);      }      /* Where slides run from the right, previous is on the right and both chevrons turn round. */      &:dir(rtl)::scroll-button(inline-start) {        right: calc(anchor(right) + .5rem);        left: auto;        scale: none;      }      &:dir(rtl)::scroll-button(inline-end) {        right: auto;        left: calc(anchor(left) + .5rem);        scale: -1 1;      }      @media (hover: hover) {        &::scroll-button(inline-start):hover, &::scroll-button(inline-end):hover {          box-shadow: var(--shadow-raised-hover);        }      }      &::scroll-button(inline-start):focus-visible, &::scroll-button(inline-end):focus-visible {        outline: 2px solid var(--color-focus);        outline-offset: 2px;      }      /* At either end there is nowhere to go: the button steps back. */      &::scroll-button(inline-start):disabled, &::scroll-button(inline-end):disabled {        opacity: 0;        cursor: default;      }    }    @media (forced-colors: active) {      &::scroll-button(inline-start), &::scroll-button(inline-end) {        border: 1px solid ButtonText;      }    }  }  /* A slide: it snaps to the start of the track and takes --carousel-slide of its width. */  .carousel-slide {    flex: 0 0 var(--carousel-slide);    min-width: 0;    scroll-snap-align: start;    @supports selector(::scroll-marker) {      &::scroll-marker {        content: '';        width: .375rem;        height: .375rem;        border-radius: 999px;        background: var(--color-line-mark);        transition: width var(--duration-base) var(--easing-glide), background-color var(--duration-quick) var(--easing-glide);      }      /* The slide in view: the dot lengthens and takes ink. */      &::scroll-marker:target-current {        width: 1rem;        background: var(--color-text-strong);      }      &::scroll-marker:focus-visible {        outline: 2px solid var(--color-focus);        outline-offset: 2px;      }      @media (forced-colors: active) {        &::scroll-marker {          background: GrayText;        }        &::scroll-marker:target-current {          background: Highlight;        }      }    }  }  /* Several slides in view: each takes a share of the track, less the gaps between them. */  .carousel-2 {    --carousel-slide: calc((100% - var(--carousel-gap)) / 2);  }  .carousel-3 {    --carousel-slide: calc((100% - var(--carousel-gap) * 2) / 3);  }}