CSS Carousels: 16 Examples Built With Scroll Snap

Scroll snapping alone gets you a working CSS carousel with no slider script, and Chromium browsers can now generate the buttons and position markers too. Firefox and Safari still get the plain scroll-snap row.

Below are 16 CSS carousel examples you can try and copy, followed by a guide to scroll snap, the native carousel pseudo-elements and accessibility.

By Bogdan Sandu ยท Updated October 27, 2026

Scroll snap carousels

The base layer every browser runs: a scrolling row with scroll-snap-type and scroll-snap-align. No pseudo-elements, and script only where a link would otherwise jump the page.

Product card rail

  • scroll-snap-type: x mandatory
  • container query units
  • thin scrollbar

New in stoneware

6 pieces

  • Tide jug

    Celadon glaze, 1.1 l

    $48
  • Ember vase

    Iron red, 24 cm

    $62
  • Moss bottle

    Ash glaze, 18 cm

    $39
  • Dusk carafe

    Cobalt, 0.8 l

    $54
  • Honey crock

    Amber, lidded

    $44
  • Slate urn

    Matte black, 30 cm

    $88

Swipe, scroll sideways with a trackpad, or Tab into the row and use the arrow keys.

A shop row that shows two cards and a slice of the third on a phone and three and a half on desktop, sized in cqi so the card width follows the rail, not the window. The partly visible card and the thin scrollbar tell visitors the row scrolls, which matters in browsers that will never render native controls.

View code
.cr-rail {
  width: min(100%, 760px);
  container-type: inline-size;
  color: #1d1a16;
}
.cr-rail__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 12px;
  margin-bottom: 14px;
}
.cr-rail__title {
  color: inherit;
  margin: 0;
  font: 600 1.35rem/1.2 "Fraunces", serif;
}
.cr-rail__count {
  margin: 0;
  color: #5d564c;
  font-size: .9rem;
}
.cr-rail__track {
  display: flex;
  gap: 14px;
  margin: 0;
  padding: 4px 4px 14px;
  list-style: none;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  scroll-padding-inline: 4px;
  scrollbar-width: thin;
  scrollbar-color: #8c8172 transparent;
}
.cr-rail__card {
  position: relative;
  display: flex;
  flex: 0 0 clamp(130px, 40cqi, 200px);
  flex-direction: column;
  padding: 12px;
  border: 1px solid #e3ddd2;
  border-radius: 18px;
  background: #fff;
  scroll-snap-align: start;
}
.cr-rail__art {
  display: grid;
  place-items: center;
  aspect-ratio: 1;
  margin-bottom: 12px;
  border-radius: 12px;
  background: var(--cr-rail-bg);
}
.cr-rail__art::before {
  content: "";
  width: 44%;
  height: 64%;
  border-radius: 42% 42% 20% 20% / 34% 34% 14% 14%;
  background: var(--cr-rail-glaze);
  box-shadow: inset -9px 0 0 rgba(0, 0, 0, .12);
}
.cr-rail__name {
  margin: 0;
  font-weight: 600;
}
.cr-rail__meta {
  margin: 2px 0 12px;
  color: #5d564c;
  font-size: .84rem;
}
.cr-rail__row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-top: auto;
}
.cr-rail__price {
  font-weight: 700;
}
.cr-rail__add {
  min-height: 36px;
  padding: 0 14px;
  border: 1.5px solid #1d1a16;
  border-radius: 999px;
  background: transparent;
  color: #1d1a16;
  font: inherit;
  font-size: .85rem;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .2s, color .2s;
}
.cr-rail__add:hover {
  background: #1d1a16;
  color: #fff;
}
.cr-rail__add:focus-visible {
  outline: 3px solid #b4471f;
  outline-offset: 2px;
}
.cr-rail__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

Vertical snap feed in a phone

  • scroll-snap-type: y mandatory
  • scroll-snap-stop: always
  • overscroll-behavior
Orange sandstone cliffs lit by low sun above a rocky slope

@marta.walks

Golden hour on the rim trail

6.2 km loop, 310 m climb

1 / 4

Two yellow tents pitched on snow beside a stone wall, with snowy peaks behind

@highcamp.ivo

Night two at base camp

4,410 m, minus 12 degrees

2 / 4

A tall waterfall dropping into a mossy green gorge surrounded by pine forest

@lena.outdoors

The falls after three days of rain

Viewpoint 200 m from the car park

3 / 4

Granite cliffs above pine trees and a calm river with a sandy bank

@sam.and.dog

Lunch stop by the river

Flat, shaded, dog friendly

4 / 4

Scroll inside the phone.

The short-video feed pattern on the y axis. Every story is exactly one screen tall, scroll-snap-stop: always stops a hard flick from skipping stories, and overscroll-behavior: contain keeps the page behind from scrolling when the feed hits its end.

View code
.cr-feed {
  height: 100%;
  overflow-y: auto;
  overscroll-behavior: contain;
  background: #0b0d12;
  scroll-snap-type: y mandatory;
  scrollbar-width: none;
}
.cr-feed:focus-visible {
  outline: 3px solid #facc15;
  outline-offset: -3px;
}
.cr-feed__story {
  position: relative;
  height: 100%;
  margin: 0;
  scroll-snap-align: start;
  scroll-snap-stop: always;
}
.cr-feed__story img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.cr-feed__panel {
  position: absolute;
  right: 14px;
  bottom: 16px;
  left: 14px;
  padding: 14px 16px;
  border-radius: 16px;
  background: rgba(11, 13, 18, .82);
  color: #fff;
}
.cr-feed__who {
  margin: 0;
  color: #facc15;
  font-size: .85rem;
  font-weight: 600;
}
.cr-feed__title {
  color: inherit;
  margin: 4px 0;
  font: 600 1.2rem/1.25 "Fraunces", serif;
}
.cr-feed__meta {
  margin: 0;
  color: #d0d5dd;
  font-size: .86rem;
}
.cr-feed__pos {
  position: absolute;
  top: 14px;
  right: 14px;
  margin: 0;
  padding: 4px 10px;
  border-radius: 999px;
  background: rgba(11, 13, 18, .78);
  color: #fff;
  font-size: .8rem;
  font-variant-numeric: tabular-nums;
}

Buttons, dots and slide indicators

The same snap row with controls on top. The buttons call scrollTo or scrollBy, an IntersectionObserver reports the slide in view, and scroll-driven animations or scroll-state queries take over where the browser supports them.

Testimonial carousel with prev and next

  • scrollBy
  • aria-disabled
  • IntersectionObserver

What teams say about Ledgerly

  • We closed the books in two days instead of eight. The reconciliation view alone paid for the year.

    Amira Kanaan Finance lead, Porto Mills

  • Our contractors get paid on time now, and nobody has asked me for a spreadsheet since March.

    Dele Okafor Founder, Kiln Studio

  • Setup took one afternoon. The import matched 94 percent of our old categories on the first try.

    Jana Sedlak Operations, Brno Bikes

  • Support answered at 11 at night during our audit week. That is the reason we stayed.

    Rafael Moura CFO, Tidewater Foods

Review 1 of 4

Use the arrow buttons or swipe the quotes.

The buttons scroll by one container width and let mandatory snapping finish the move. They use aria-disabled instead of the disabled attribute at the ends, so a keyboard user who just pressed next does not lose focus when the button switches off. An observer updates the review counter.

View code
.cr-quote {
  width: min(100%, 620px);
  container-type: inline-size;
  padding: clamp(20px, 6cqi, 36px);
  border-radius: 24px;
  background: #fffaf2;
  color: #2a1f14;
  box-shadow: 0 1px 0 #eadfcd, 0 18px 40px rgba(42, 31, 20, .08);
}
.cr-quote__title {
  margin: 0 0 18px;
  color: #7a4b12;
  font-size: .95rem;
  font-weight: 600;
}
.cr-quote__track {
  display: flex;
  gap: 24px;
  margin: 0;
  padding: 0;
  list-style: none;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
}
.cr-quote__slide {
  flex: 0 0 100%;
  scroll-snap-align: start;
  scroll-snap-stop: always;
}
.cr-quote__text {
  margin: 0;
}
.cr-quote__text p {
  margin: 0;
  font: 500 clamp(1.15rem, 4.4cqi, 1.55rem)/1.4 "Fraunces", serif;
}
.cr-quote__who {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 20px 0 0;
  color: #5c4a36;
  font-size: .9rem;
}
.cr-quote__who strong {
  display: block;
  color: #2a1f14;
}
.cr-quote__avatar {
  display: grid;
  flex: none;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: #7a4b12;
  color: #fff;
  font-size: .85rem;
  font-weight: 700;
}
.cr-quote__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-top: 22px;
  padding-top: 16px;
  border-top: 1px solid #eadfcd;
}
.cr-quote__count {
  margin: 0;
  color: #5c4a36;
  font-size: .88rem;
  font-variant-numeric: tabular-nums;
}
.cr-quote__nav {
  display: flex;
  gap: 8px;
}
.cr-quote__btn {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #2a1f14;
  color: #fff;
  cursor: pointer;
  transition: background-color .2s;
}
.cr-quote__btn:hover {
  background: #7a4b12;
}
.cr-quote__btn[aria-disabled="true"] {
  background: #e4d9c8;
  color: #6f5d48;
  cursor: default;
}
.cr-quote__btn:focus-visible {
  outline: 3px solid #b45309;
  outline-offset: 3px;
}

Full-width hero slider with dots

  • hidden until scripted
  • aria-current
  • offsetLeft
  • A single-lane road winding through a green valley below steep grassy cliffs

    Drive the Quiet Coast

    Seven days, three islands, one rental car. From $1,240.

  • A downtown skyline of tall towers at sunrise beside a wide lake

    Long weekend, tall city

    Architecture walks and rooftop dinners. From $680.

  • An orange jellyfish with long trailing tentacles in deep blue water

    Reef week for beginners

    Snorkel lessons and a marine biologist guide. From $1,560.

Click a dot or swipe the slides.

The dots ship with the hidden attribute, and the script reveals them and hides the scrollbar in the same step. If the script never runs, visitors still get a swipeable row with a visible scrollbar instead of dead dots. Each dot is a 24px button, the minimum WCAG 2.2 target size.

View code
.cr-hero {
  position: relative;
  width: 100%;
  margin: 0;
  padding: 0;
  container-type: inline-size;
  background: #0f172a;
}
.cr-hero__track {
  position: relative;
  display: flex;
  margin: 0;
  padding: 0;
  list-style: none;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}
.cr-hero--ready .cr-hero__track {
  scrollbar-width: none;
}
.cr-hero__slide {
  position: relative;
  flex: 0 0 100%;
  height: clamp(340px, 52cqi, 460px);
  scroll-snap-align: start;
}
.cr-hero__slide img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.cr-hero__panel {
  position: absolute;
  bottom: clamp(16px, 5cqi, 40px);
  left: clamp(16px, 5cqi, 48px);
  width: min(380px, calc(100% - 32px));
  padding: 18px 20px;
  border-radius: 16px;
  background: #fff;
  color: #0f172a;
}
.cr-hero__title {
  color: inherit;
  margin: 0 0 6px;
  font: 600 clamp(1.25rem, 3.4cqi, 1.7rem)/1.15 "Fraunces", serif;
}
.cr-hero__text {
  margin: 0;
  color: #475569;
  font-size: .92rem;
}
.cr-hero__dots {
  position: absolute;
  top: 16px;
  right: 16px;
  display: flex;
  gap: 2px;
  padding: 4px 6px;
  border-radius: 999px;
  background: rgba(15, 23, 42, .78);
}
.cr-hero__dots[hidden] {
  display: none;
}
.cr-hero__dot {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
}
.cr-hero__dot::before {
  content: "";
  width: 10px;
  height: 10px;
  border: 2px solid #fff;
  border-radius: 50%;
  transition: background-color .2s;
}
.cr-hero__dot[aria-current="true"]::before {
  background: #fff;
}
.cr-hero__dot:focus-visible {
  outline: 2px solid #fde047;
  outline-offset: 0;
}

Snapped card highlight with scroll-state queries

  • container-type: scroll-state
  • @container scroll-state(snapped: x)
  • IntersectionObserver fallback

Slow Build, season 2

Episode 1 of 6

  • 01

    The first commit

    42 min

  • 02

    Naming things

    38 min

  • 03

    The rewrite trap

    51 min

  • 04

    Shipping on Friday

    44 min

  • 05

    Ten thousand users

    47 min

  • 06

    Selling the company

    55 min

Scroll the episodes. The centered one lights up.

Each list item is a scroll-state container, and @container scroll-state(snapped: x) styles the card that is currently snapped, with no script, in Chrome and Edge 133+. Other browsers get the same look from a short IntersectionObserver that watches a thin band in the middle of the row. The counter uses that observer everywhere.

View code
.cr-ep {
  width: min(100%, 700px);
  container-type: inline-size;
  color: #f4f1ea;
}
.cr-ep__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 12px;
  margin-bottom: 14px;
}
.cr-ep__title {
  color: inherit;
  margin: 0;
  font: 600 1.3rem/1.2 "Fraunces", serif;
}
.cr-ep__count {
  margin: 0;
  color: #b9b4a8;
  font-size: .88rem;
  font-variant-numeric: tabular-nums;
}
.cr-ep__track {
  display: flex;
  gap: 12px;
  margin: 0;
  padding: 6px calc(50cqi - 80px) 14px;
  list-style: none;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: thin;
  scrollbar-color: #57534e transparent;
}
.cr-ep__item {
  flex: 0 0 160px;
  container-type: scroll-state;
  scroll-snap-align: center;
}
.cr-ep__card {
  display: flex;
  flex-direction: column;
  height: 100%;
  min-height: 190px;
  box-sizing: border-box;
  padding: 16px;
  border: 1px solid #3a3733;
  border-radius: 18px;
  background: #1c1b19;
  transition: background-color .25s, border-color .25s, color .25s;
}
.cr-ep__num {
  color: #8f897d;
  font: 600 2rem/1 ui-monospace, Consolas, monospace;
}
.cr-ep__name {
  margin: auto 0 4px;
  font-weight: 600;
  line-height: 1.3;
}
.cr-ep__len {
  margin: 0;
  color: #b9b4a8;
  font-size: .85rem;
}
.cr-ep__item--on .cr-ep__card {
  border-color: #f59e0b;
  background: #f59e0b;
  color: #1c1b19;
}
.cr-ep__item--on .cr-ep__num,
.cr-ep__item--on .cr-ep__len {
  color: #1c1b19;
}
@container scroll-state(snapped: x) {
  .cr-ep__card {
    border-color: #f59e0b;
    background: #f59e0b;
    color: #1c1b19;
  }
  .cr-ep__num,
  .cr-ep__len {
    color: #1c1b19;
  }
}
@media (prefers-reduced-motion: no-preference) {
  .cr-ep__card {
    transition: background-color .25s, border-color .25s, color .25s, transform .25s;
  }
  .cr-ep__item--on .cr-ep__card {
    transform: translateY(-4px);
  }
  @container scroll-state(snapped: x) {
    .cr-ep__card {
      transform: translateY(-4px);
    }
  }
}

Course rail with a scroll-driven progress bar

  • scroll-timeline
  • timeline-scope
  • animation-timeline

Interface design track

  • Lesson 1

    Type scales that hold up

    18 min

  • Lesson 2

    Spacing with an 8px grid

    22 min

  • Lesson 3

    Color contrast in practice

    25 min

  • Lesson 4

    Forms people finish

    31 min

  • Lesson 5

    Motion with restraint

    19 min

  • Lesson 6

    Handing off to developers

    27 min

The bar is a sibling of the scroller, so the track names its timeline with scroll-timeline and the wrapper lifts that name with timeline-scope. Where scroll-driven animations are missing, or the visitor prefers reduced motion, the bar stays hidden and the normal scrollbar shows progress instead.

View code
.cr-prog {
  width: min(100%, 720px);
  container-type: inline-size;
  color: #111827;
}
.cr-prog__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px 16px;
  margin-bottom: 14px;
}
.cr-prog__title {
  color: inherit;
  margin: 0;
  font-size: 1.2rem;
  font-weight: 700;
}
.cr-prog__bar {
  display: none;
  width: min(180px, 100%);
  height: 6px;
  overflow: hidden;
  border-radius: 999px;
  background: #d9dde5;
}
.cr-prog__bar span {
  display: block;
  height: 100%;
  border-radius: inherit;
  background: #111827;
  transform-origin: left;
}
.cr-prog__track {
  display: flex;
  gap: 12px;
  margin: 0;
  padding: 0 0 12px;
  list-style: none;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: thin;
  scrollbar-color: #6b7280 transparent;
}
.cr-prog__card {
  display: flex;
  flex: 0 0 clamp(150px, 36cqi, 200px);
  flex-direction: column;
  min-height: 170px;
  padding: 16px;
  border-top: 6px solid var(--cr-prog-c);
  border-radius: 14px;
  background: #fff;
  scroll-snap-align: start;
}
.cr-prog__tag {
  margin: 0;
  color: var(--cr-prog-c);
  font-size: .8rem;
  font-weight: 700;
}
.cr-prog__name {
  margin: 8px 0 0;
  font: 600 1.05rem/1.3 "Fraunces", serif;
}
.cr-prog__time {
  margin: auto 0 0;
  color: #4b5563;
  font-size: .85rem;
}
@supports (animation-timeline: scroll()) and (timeline-scope: --cr-prog) {
  @media (prefers-reduced-motion: no-preference) {
    .cr-prog {
      timeline-scope: --cr-prog;
    }
    .cr-prog__track {
      padding-bottom: 0;
      scroll-timeline: --cr-prog x;
      scrollbar-width: none;
    }
    .cr-prog__bar {
      display: block;
    }
    .cr-prog__bar span {
      animation: cr-prog-fill linear both;
      animation-timeline: --cr-prog;
    }
  }
}
@keyframes cr-prog-fill {
  from {
    transform: scaleX(.08);
  }
  to {
    transform: scaleX(1);
  }
}

Keyboard-scrollable carousel region

  • tabindex="0"
  • role="region"
  • aria-roledescription
  • v4.2 Oct 21

    Offline drafts

    Edits made without a connection now sync when you are back online, with a merge view for conflicts.

  • v4.1 Sep 30

    Faster search

    Results for large workspaces appear about three times faster, and filters stay set between visits.

  • v4.0 Sep 2

    New editor

    Slash commands, real tables and keyboard shortcuts for every block type.

  • v3.9 Aug 12

    Audit log

    Admins can export every permission change from the last 12 months as CSV.

Use the left and right arrow keys to scroll.

Tab to the row, then press the left and right arrow keys.

The slides hold only text, so the scroller itself is the tab stop and the arrow keys scroll it. tabindex="0" keeps it reachable in browsers that do not make scrollers focusable by default, and the region gets the APG carousel role description with a label that does not repeat the word carousel. The list keeps announcing its item count.

View code
.cr-kb {
  width: min(100%, 680px);
  container-type: inline-size;
  color: #0c1a2b;
}
.cr-kb__track {
  overflow-x: auto;
  border-radius: 18px;
  scroll-snap-type: x mandatory;
  scroll-padding-inline: 0;
  scrollbar-width: thin;
  scrollbar-color: #64748b transparent;
}
.cr-kb__track:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 4px;
}
.cr-kb__list {
  display: flex;
  gap: 12px;
  margin: 0;
  padding: 0 0 12px;
  list-style: none;
}
.cr-kb__note {
  flex: 0 0 clamp(220px, 60cqi, 300px);
  box-sizing: border-box;
  padding: 18px 20px;
  border: 1px solid #d6dde8;
  border-radius: 18px;
  background: #fff;
  scroll-snap-align: start;
}
.cr-kb__ver {
  display: flex;
  justify-content: space-between;
  margin: 0;
  color: #1d4ed8;
  font: 600 .85rem/1.4 ui-monospace, Consolas, monospace;
}
.cr-kb__ver span {
  color: #526174;
}
.cr-kb__head {
  margin: 10px 0 6px;
  font-size: 1.1rem;
  font-weight: 700;
}
.cr-kb__body {
  margin: 0;
  color: #3b4a5c;
  font-size: .92rem;
  line-height: 1.5;
}
.cr-kb__help {
  margin: 6px 0 0;
  color: #526174;
  font-size: .85rem;
}

Native CSS carousel features

Browser-generated controls from ::scroll-button() and ::scroll-marker, plus a view() timeline. Each one sits inside @supports, so Firefox and Safari get a plain snap row with a visible scrollbar instead.

Native scroll buttons and dot markers

  • ::scroll-button()
  • ::scroll-marker
  • anchor positioning
  • Granite cliffs above pine trees and a calm river with a sandy bank
  • A deep blue fjord between grey granite cliffs, seen from a rocky ledge
  • A tall waterfall dropping into a mossy green gorge surrounded by pine forest
  • Orange sandstone cliffs lit by low sun above a rocky slope

The generated buttons and markers render in Chrome and Edge 135+. Other browsers show the snap fallback.

No script at all. ::scroll-button(left) and (right) become real buttons that disable themselves at the ends, and each slide generates a ::scroll-marker dot that :target-current highlights. The buttons are placed with anchor positioning, and their accessible names come from the alt text after the slash in content.

View code
.cr-nat {
  position: relative;
  width: min(100%, 640px);
  container-type: inline-size;
}
.cr-nat__track {
  display: flex;
  gap: 12px;
  margin: 0;
  padding: 0 0 12px;
  list-style: none;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: thin;
  scrollbar-color: #6b7280 transparent;
}
.cr-nat__slide {
  flex: 0 0 100%;
  scroll-snap-align: center;
  scroll-snap-stop: always;
}
.cr-nat__slide img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 10;
  object-fit: cover;
  border-radius: 18px;
}
@supports selector(::scroll-button(*)) {
  .cr-nat__track {
    padding-bottom: 0;
    anchor-name: --cr-nat;
    scrollbar-width: none;
    scroll-marker-group: after;
  }
  .cr-nat__track::scroll-button(*) {
    position: absolute;
    position-anchor: --cr-nat;
    top: calc(anchor(center) - 22px);
    width: 44px;
    height: 44px;
    border: 0;
    border-radius: 50%;
    background: #fff;
    color: #111827;
    font: 700 1.5rem/44px "Fraunces", serif;
    text-align: center;
    box-shadow: 0 6px 18px rgba(17, 24, 39, .25);
    cursor: pointer;
  }
  .cr-nat__track::scroll-button(left) {
    content: "\2039" / "Previous photo";
    left: calc(anchor(left) + 12px);
  }
  .cr-nat__track::scroll-button(right) {
    content: "\203A" / "Next photo";
    right: calc(anchor(right) + 12px);
  }
  .cr-nat__track::scroll-button(*):disabled {
    background: #e5e7eb;
    color: #6b7280;
    box-shadow: none;
    cursor: default;
  }
  .cr-nat__track::scroll-button(*):focus-visible {
    outline: 3px solid #2563eb;
    outline-offset: 2px;
  }
}
@supports selector(::scroll-marker) {
  .cr-nat__track::scroll-marker-group {
    display: flex;
    justify-content: center;
    gap: 2px;
    margin-top: 10px;
  }
  .cr-nat__slide::scroll-marker {
    content: "" / attr(data-label);
    box-sizing: border-box;
    width: 24px;
    height: 24px;
    border: 7px solid transparent;
    border-radius: 50%;
    background: #9ca3af content-box;
  }
  .cr-nat__slide::scroll-marker:target-current {
    background-color: #111827;
  }
  .cr-nat__slide::scroll-marker:focus-visible {
    outline: 3px solid #2563eb;
  }
}
@media (prefers-reduced-motion: no-preference) {
  .cr-nat__track {
    scroll-behavior: smooth;
  }
}

Recipe steps with labelled markers

  • scroll-marker-group: before
  • content: attr()
  • :target-current
  1. Step 1 of 4 15 min

    Mix the dough

    Stir 500 g flour, 350 g water and 100 g active starter until no dry flour is left. Rest 30 minutes, then add 10 g salt.

  2. Step 2 of 4 2 h

    Stretch and fold

    Every 30 minutes, lift one side of the dough and fold it over. Four rounds, then leave it covered.

  3. Step 3 of 4 12 h

    Shape and cold proof

    Shape a tight round, place it seam up in a floured basket and refrigerate overnight.

  4. Step 4 of 4 45 min

    Bake in a hot pot

    Bake at 250 °C with the lid on for 20 minutes, then at 230 °C uncovered until deep brown.

The generated buttons and markers render in Chrome and Edge 135+. Other browsers show the snap fallback.

Markers do not have to be dots. Each step passes its short name to ::scroll-marker through attr(), so the group above the slides reads like a row of tabs, and the visible text is also the accessible name. Without marker support, every slide still shows its own step number.

View code
.cr-step {
  width: min(100%, 560px);
  container-type: inline-size;
  padding: 20px;
  border-radius: 22px;
  background: #fbf7ef;
  color: #2b2118;
  box-sizing: border-box;
}
.cr-step__track {
  display: flex;
  gap: 16px;
  margin: 0;
  padding: 0 0 12px;
  list-style: none;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: thin;
  scrollbar-color: #8a7a66 transparent;
}
.cr-step__slide {
  flex: 0 0 100%;
  scroll-snap-align: start;
}
.cr-step__num {
  display: flex;
  justify-content: space-between;
  margin: 0;
  color: #2f5d3a;
  font-size: .85rem;
  font-weight: 700;
}
.cr-step__num span {
  color: #6b5a47;
  font-weight: 500;
}
.cr-step__title {
  margin: 10px 0 8px;
  font: 600 1.4rem/1.2 "Fraunces", serif;
}
.cr-step__text {
  margin: 0;
  color: #4a3d30;
  line-height: 1.55;
}
@supports selector(::scroll-marker) {
  .cr-step__track {
    padding-bottom: 0;
    scrollbar-width: none;
    scroll-marker-group: before;
  }
  .cr-step__track::scroll-marker-group {
    display: flex;
    gap: 6px;
    height: 38px;
    margin-bottom: 18px;
  }
  .cr-step__slide::scroll-marker {
    content: attr(data-step);
    padding: 8px 16px;
    text-decoration: none;
    border: 1.5px solid #2f5d3a;
    border-radius: 999px;
    color: #2f5d3a;
    font: 600 .9rem/1.2 "DM Sans", sans-serif;
    cursor: pointer;
  }
  .cr-step__slide::scroll-marker:target-current {
    background: #2f5d3a;
    color: #fff;
  }
  .cr-step__slide::scroll-marker:focus-visible {
    outline: 3px solid #b45309;
    outline-offset: 2px;
  }
}
@media (prefers-reduced-motion: no-preference) {
  .cr-step__track {
    scroll-behavior: smooth;
  }
}

Coverflow tilt with a view() timeline

  • animation-timeline: view(inline)
  • perspective()
  • @supports

Field recordings, vol. 1 to 6

  • Deep Water

  • High Camp

  • First Light

  • Back Streets

  • Red Rim

  • Falls

Scroll the covers sideways.

Each cover runs one keyframe animation tied to its own position in the scroller with view(inline): tilted and smaller at the edges, flat and full size in the center. Scroll-driven animations ship in Chrome 115, Safari 26 and Firefox 159, and the whole effect sits inside @supports and a no-preference motion query, so the fallback is a flat snap row.

View code
.cr-cov {
  width: min(100%, 760px);
  container-type: inline-size;
  color: #f5f5f4;
}
.cr-cov__title {
  color: inherit;
  margin: 0 0 16px;
  font: 600 1.25rem/1.2 "Fraunces", serif;
  text-align: center;
}
.cr-cov__track {
  display: flex;
  gap: 14px;
  margin: 0;
  padding: 10px calc(50cqi - 85px) 16px;
  list-style: none;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: thin;
  scrollbar-color: #57534e transparent;
}
.cr-cov__item {
  flex: 0 0 170px;
  scroll-snap-align: center;
}
.cr-cov__item img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 1;
  object-fit: cover;
  border-radius: 10px;
  box-shadow: 0 16px 30px rgba(0, 0, 0, .5);
}
.cr-cov__name {
  margin: 10px 0 0;
  color: #d6d3d1;
  font-size: .9rem;
  font-weight: 600;
  text-align: center;
}
@supports (animation-timeline: view(inline)) {
  @media (prefers-reduced-motion: no-preference) {
    .cr-cov__item {
      animation: cr-cov-tilt linear both;
      animation-timeline: view(inline);
    }
  }
}
@keyframes cr-cov-tilt {
  0% {
    transform: perspective(700px) rotateY(-42deg) scale(.8);
  }
  50% {
    transform: perspective(700px) rotateY(0) scale(1);
  }
  100% {
    transform: perspective(700px) rotateY(42deg) scale(.8);
  }
}

Autoplay and marquee strips

Content that moves on its own needs a visible pause control under WCAG 2.2.2. All three start still for visitors who ask for reduced motion and fall back to a manual row.

Auto-advance with a rotation control

  • WCAG 2.2.2
  • APG rotation control
  • aria-live
  • A hiker with a large orange backpack standing on a rock above a hazy mountain valley

    Packs from 38 to 65 litres

    20% off through November 10.

  • Two yellow tents pitched on snow beside a stone wall, with snowy peaks behind

    Four-season tents

    Free footprint with every tent.

  • A tall waterfall dropping into a mossy green gorge surrounded by pine forest

    Rain shells, tested in Norway

    New colours for the autumn line.

  • Granite cliffs above pine trees and a calm river with a sandy bank

    Repair days in store

    Bring a jacket, we fix the zip for free.

Rotation stops when you focus or hover the carousel.

Built to the APG carousel pattern: the stop and start button comes first in the tab order, keyboard focus anywhere else in the carousel stops rotation until the button restarts it, and hovering pauses it. aria-live is off while slides rotate and polite once they stop. Visitors who prefer reduced motion get a carousel that starts stopped.

View code
.cr-auto {
  width: min(100%, 640px);
  container-type: inline-size;
  color: #102a1f;
}
.cr-auto__controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 12px;
}
.cr-auto__toggle {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 0 18px 0 14px;
  border: 0;
  border-radius: 999px;
  background: #14532d;
  color: #fff;
  font: inherit;
  font-size: .92rem;
  font-weight: 600;
  cursor: pointer;
}
.cr-auto__toggle:hover {
  background: #166534;
}
.cr-auto__play {
  display: none;
}
.cr-auto:not(.cr-auto--playing) .cr-auto__pause {
  display: none;
}
.cr-auto:not(.cr-auto--playing) .cr-auto__play {
  display: inline;
}
.cr-auto__nav {
  display: flex;
  gap: 8px;
}
.cr-auto__btn {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 1.5px solid #14532d;
  border-radius: 50%;
  background: #fff;
  color: #14532d;
  cursor: pointer;
}
.cr-auto__toggle:focus-visible,
.cr-auto__btn:focus-visible {
  outline: 3px solid #ca8a04;
  outline-offset: 2px;
}
.cr-auto__track {
  display: flex;
  margin: 0;
  padding: 0;
  list-style: none;
  overflow-x: auto;
  border-radius: 20px;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
}
.cr-auto__slide {
  position: relative;
  flex: 0 0 100%;
  scroll-snap-align: start;
}
.cr-auto__slide img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 10;
  object-fit: cover;
}
.cr-auto__panel {
  position: absolute;
  right: 14px;
  bottom: 14px;
  left: 14px;
  padding: 12px 16px;
  border-radius: 14px;
  background: #fff;
}
.cr-auto__head {
  margin: 0;
  font: 600 1.1rem/1.25 "Fraunces", serif;
}
.cr-auto__text {
  margin: 2px 0 0;
  color: #3f5a4c;
  font-size: .88rem;
}

CSS-only autoplay with a pause switch

  • @keyframes
  • animation-play-state
  • :has(:checked)
  • Old gabled houses along a canal with small white boats moored at the water

    Canal loft 2 guests, from $140 a night

  • A narrow brick alley with ivy and fire escapes, tall towers at its far end

    Warehouse studio 3 guests, from $165 a night

  • A road cut into a green hillside above the sea, with headlands fading into haze

    Cliff cabin 4 guests, from $210 a night

Turn on the switch to pause the slides.

The track slides through three cards on a 15 second keyframe loop, with a hidden copy of the first card at the end so the loop wraps without rewinding. A labelled checkbox before the slides pauses it through :has() and animation-play-state, which is a real WCAG 2.2.2 pause mechanism. Hover and focus pause it too, and reduced motion turns it into a manual snap row.

View code
.cr-kf {
  display: grid;
  justify-items: start;
  gap: 12px;
  width: min(100%, 600px);
  container-type: inline-size;
  color: #1f1d2b;
}
.cr-kf__check {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  opacity: 0;
}
.cr-kf__switch {
  position: relative;
  display: none;
  align-items: center;
  min-height: 44px;
  padding-left: 56px;
  font-size: .92rem;
  font-weight: 600;
  cursor: pointer;
}
.cr-kf__switch::before,
.cr-kf__switch::after {
  content: "";
  position: absolute;
  top: 50%;
  box-sizing: border-box;
  border-radius: 999px;
  transform: translateY(-50%);
}
.cr-kf__switch::before {
  left: 0;
  width: 44px;
  height: 26px;
  border: 2px solid #4338ca;
  background: #fff;
}
.cr-kf__switch::after {
  left: 5px;
  width: 16px;
  height: 16px;
  background: #4338ca;
}
.cr-kf__check:checked + .cr-kf__switch::before {
  background: #4338ca;
}
.cr-kf__check:checked + .cr-kf__switch::after {
  left: 23px;
  background: #fff;
}
.cr-kf__check:focus-visible + .cr-kf__switch::before {
  outline: 3px solid #f59e0b;
  outline-offset: 2px;
}
.cr-kf__view {
  width: 100%;
  overflow-x: auto;
  border-radius: 20px;
  scroll-snap-type: x mandatory;
}
.cr-kf__track {
  display: flex;
  margin: 0;
  padding: 0;
  list-style: none;
}
.cr-kf__slide {
  position: relative;
  flex: 0 0 100%;
  scroll-snap-align: start;
}
.cr-kf__slide--clone {
  display: none;
}
.cr-kf__slide img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 10;
  object-fit: cover;
}
.cr-kf__cap {
  position: absolute;
  bottom: 14px;
  left: 14px;
  max-width: calc(100% - 28px);
  margin: 0;
  padding: 10px 14px;
  box-sizing: border-box;
  border-radius: 12px;
  background: #1f1d2b;
  color: #e7e5f2;
  font-size: .88rem;
}
.cr-kf__cap strong {
  display: block;
  color: #fff;
  font: 600 1.05rem/1.25 "Fraunces", serif;
}
@media (prefers-reduced-motion: no-preference) {
  .cr-kf__switch {
    display: inline-flex;
  }
  .cr-kf__view {
    overflow: hidden;
  }
  .cr-kf__slide--clone {
    display: block;
  }
  .cr-kf__track {
    animation: cr-kf-run 15s ease-in-out infinite;
  }
  .cr-kf:has(.cr-kf__check:checked) .cr-kf__track,
  .cr-kf__view:hover .cr-kf__track {
    animation-play-state: paused;
  }
}
@keyframes cr-kf-run {
  0%, 28% {
    transform: translateX(0);
  }
  33.3%, 61.6% {
    transform: translateX(-100%);
  }
  66.6%, 95% {
    transform: translateX(-200%);
  }
  100% {
    transform: translateX(-300%);
  }
}

Client name marquee with a pause button

  • translateX(-100%)
  • aria-hidden duplicate
  • aria-pressed

Studios that ship with Fieldnote

  • Halvorsen
  • QUILLFOLD
  • brightmere
  • Kettle & Stone
  • ORROW
  • lumenfield

Press Pause, or hover the strip.

Two identical lists move left by their own width, so the second takes over exactly where the first started and the loop has no seam. Screen readers get the names once because the copy is aria-hidden. The pause button and the hover pause only appear with the animation, and reduced motion shows a static, wrapped list.

View code
.cr-mq {
  width: min(100%, 760px);
  container-type: inline-size;
  color: #e9e7e1;
}
.cr-mq__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px 16px;
  margin-bottom: 18px;
}
.cr-mq__title {
  margin: 0;
  color: #c9c5bb;
  font-size: 1rem;
  font-weight: 600;
}
.cr-mq__toggle {
  display: none;
  min-width: 88px;
  min-height: 40px;
  padding: 0 16px;
  border: 1.5px solid #e9e7e1;
  border-radius: 999px;
  background: transparent;
  color: #e9e7e1;
  font: inherit;
  font-size: .88rem;
  font-weight: 600;
  cursor: pointer;
}
.cr-mq__toggle[aria-pressed="true"] {
  background: #e9e7e1;
  color: #0b0f1a;
}
.cr-mq__toggle:focus-visible {
  outline: 3px solid #fbbf24;
  outline-offset: 2px;
}
.cr-mq__view {
  display: flex;
  overflow: hidden;
  padding: 18px 0;
  border-block: 1px solid #2c3242;
}
.cr-mq__list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  flex: 1 1 100%;
  gap: 14px 40px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.cr-mq__list--copy {
  display: none;
}
.cr-mq__name {
  font-size: 1.35rem;
  white-space: nowrap;
}
.cr-mq__name--serif {
  font-family: "Fraunces", serif;
  font-style: italic;
  font-weight: 600;
}
.cr-mq__name--mono {
  font: 700 1.1rem/1 ui-monospace, Consolas, monospace;
  letter-spacing: .12em;
}
.cr-mq__name--heavy {
  font-weight: 800;
  letter-spacing: -.03em;
}
.cr-mq__name--wide {
  font-weight: 300;
  letter-spacing: .4em;
}
@media (prefers-reduced-motion: no-preference) {
  .cr-mq__toggle {
    display: inline-block;
  }
  .cr-mq__list {
    flex: none;
    flex-wrap: nowrap;
    padding-right: 40px;
    animation: cr-mq-move 28s linear infinite;
  }
  .cr-mq__list--copy {
    display: flex;
  }
  .cr-mq:has(.cr-mq__toggle[aria-pressed="true"]) .cr-mq__list,
  .cr-mq__view:hover .cr-mq__list {
    animation-play-state: paused;
  }
}
@keyframes cr-mq-move {
  to {
    transform: translateX(-100%);
  }
}

What is a CSS carousel?

The paging, the buttons and the position markers all come from CSS here, with no slider script involved. The browser handles the scrolling, the focus order and the button state.

People use the term for two different builds. One is the scroll-snap row, made from flexbox slides, overflow-x and scroll-snap-type, with navigation coming from hand-built buttons or a few lines of script. The other is the native carousel. It takes that same row and adds the ::scroll-button() and ::scroll-marker pseudo-elements, so the browser generates and maintains the controls.

The native version comes from the CSS Overflow Module Level 5, a specification of the W3C CSS Working Group. Chrome shipped it in version 135 (Chrome for Developers, April 2025).

On the design side it’s the familiar carousel UI pattern. Items sit in a row with one page in view, and controls move you between pages.

What changes is who keeps the controls in sync. MDN calls carousels built with JavaScript brittle, since scripts must match each marker to its item and keep every button current.

It isn’t a full slider engine, though. Looping sits outside the shipped features, and Chrome’s March 2025 post lists cyclical scrolling as planned work.

Chrome’s own Carousel Gallery runs without JavaScript, which shows how far the native features reach.

How to build a CSS carousel with scroll snap

The base layer is a flex row that scrolls and snaps. Scroll snap is Baseline widely available (MDN), so this part runs in every current browser.

  1. Write the slides as one ul with one li per slide.
  2. Make the list a row with display: flex and a gap.
  3. Give each item flex: 0 0 100% for one slide per page (the pattern in MDN’s single page demo).
  4. Add overflow-x: scroll so the row scrolls instead of the whole page.
  5. Set scroll-snap-type: x mandatory on the list.
  6. Set scroll-snap-align: center on each item.
.carousel {
  display: flex;
  gap: 4vw;
  overflow-x: scroll;
  scroll-snap-type: x mandatory;
}

.carousel li {
  flex: 0 0 100%;
  scroll-snap-align: center;
}

Skip the snap rules and you end up with a plain scrolling strip. MDN warns that buttons and markers won’t land cleanly between pages without snapping.

Flexbox or grid for the slide track

Flexbox is the default track. The flex: 0 0 part stops slides from shrinking, and that’s what forces the overflow. Slides keep the width you set, either fixed or as a percentage basis.

Grid works too. grid-auto-flow: column with grid-auto-columns gives equal columns from a single width rule, which is tidy when every slide is the same size.

Multi-column is the odd one out. The columns property plus the ::column pseudo-element lets one marker stand for a page of several items (MDN), though the naming cost of that route shows up in the controls section below.

I’d go with flexbox unless every slide has the same width and one grid rule reads cleaner. Multi-column suits paged groups of items.

Scroll snap values that matter in a carousel

PropertyCarousel settingEffect
scroll-snap-typex mandatoryLands on a slide after every scroll
scroll-snap-aligncenter or startSets where the slide stops
scroll-snap-stopalwaysBlocks skipping slides on fast swipes
scroll-paddinga length valueOffsets where slides snap, measured from the container edge

Writing the axis alone, as in scroll-snap-type: x, defaults to proximity (MDN). A carousel that must always land on a slide needs the mandatory keyword spelled out. Easy to forget.

How do ::scroll-button() and ::scroll-marker() work?

Previous and next buttons beside a scroll container come from ::scroll-button(). Position markers come from ::scroll-marker, which generates one navigation link per item and collects them in a ::scroll-marker-group. The browser places all of it as siblings of the scroller, with the right roles and tab order, and it keeps their state current (Chrome for Developers, March 2025).

Scroll buttons

Each press moves the scroller 85% of its visible area (Chrome for Developers, March 2025), where a built-in scrollbar arrow only nudges.

With one full-width slide in view, that feels like one slide per press. With several slides in view, it moves almost a full page. Chrome’s fix for exact one-item steps is mandatory snapping plus scroll-snap-stop: always on each slide.

A button only exists once content is set to something other than none (MDN). The direction goes in the argument, either left, right, up or down, or the logical block and inline values. You can have up to 4 buttons per scroll container, and each one disables itself at the scroll boundary, so :disabled styles the end of the track.

Scroll markers and the marker group

Markers are browser-made links, and a marker can stand for any item, not only a dot. Chrome’s example uses two markers labelled Season 1 and Season 2 for a ten-episode TV carousel.

scroll-marker-group takes before or after, which sets where the group sits in tab order and box order (MDN).

For the active state, :target-current styles the marker for the slide in view. :target-before and :target-after, added later in Chrome 142, cover the markers on either side.

Keyboard use is straightforward. Tab reaches the group, then arrow keys move between pages, and the container is exposed with tablist and tab semantics (MDN).

Positioning and naming the controls

The browser generates the controls, but you still have to place them and give them an accessible name.

Anchor positioning handles placement. Set anchor-name on the scroller, give each control position: absolute and a matching position-anchor, then offset it with anchor() inside calc() (MDN).

The name comes from alt text in content, written as an icon string, a slash, then the text alternative, as in content: ">" / "Next".

Getting this wrong is easy. Using content: "" on a marker produces a blank accessible name, a WCAG 4.1.2 Name, Role, Value violation (MDN). Markers generated on ::column fragments have no element to draw a name from, so that pattern creates the same violation (MDN). MDN also warns that alt text in content can still fall short of WCAG.

MDN’s workaround pulls the name from a data attribute on each list item with attr(). Automated translation services do not pick that value up.

Which browsers support the CSS carousel pseudo-elements?

Only Chromium-based browsers ship the pseudo-elements as of October 2026. Firefox and Safari don’t, though both still run the scroll-snap base.

The caniuse table for October 2026 lists Chrome and Edge as supported from 135, Opera from 120 and Samsung Internet from 29. Firefox is unsupported through 161 and Safari through 27.2. Global usage covered by ::scroll-marker support sits at 71.14% (caniuse, based on StatCounter usage data).

What Firefox and Safari users get

The row still swipes and snaps, just without buttons or markers. That makes the scroll-snap base the part to get right.

Chrome’s own demo caption describes a bare scroller as showing no clue except cut-off content. Add a visible scrollbar or a partly visible next slide so people can tell the row scrolls. Treat the controls as a layer on top, and plan your cross-browser compatibility checks around that base.

Keep ::scroll-button() rules in their own blocks. A selector list that contains a pseudo-element the browser does not recognize is dropped whole, so a grouped rule loses its supported selectors too. Wrap the control styles in an @supports selector(::scroll-button(*)) block, then test it in Firefox and Safari.

Safari support: where the sources disagree

SitePoint (May 2026) reports full Safari support from version 19 and flagged-only Firefox support. Safari has no version 19, because its numbering jumped from 18 to 26, so that claim does not hold up. The caniuse table tracks Safari through 27.2 and marks every release unsupported, with only Technology Preview listed as unknown. Chrome’s March 2025 post also lists both browsers as unsupported, and MDN’s compatibility data shows no Firefox flag for either pseudo-element.

The compatibility table is the primary source here, so this article follows it. Recheck it before shipping, because Safari Technology Preview status is still unknown.

Can a CSS carousel autoplay?

Yes, with @keyframes, but autoplay fails WCAG 2.2.2 unless visitors get a real pause control. The native scroll buttons and markers add navigation, not timers.

The usual build animates the track with CSS keyframes and toggles scroll-snap-align during the move, so mandatory snapping doesn’t fight the animation (ITNext tutorial).

WCAG 2.2.2 Pause, Stop, Hide (Level A) covers moving, scrolling and auto-updating content that starts on its own and shares the page with other content. Some summaries apply a five-second limit to carousels. W3C’s Understanding document says auto-updating content has no five-second exception and lists auto-advancing presentations in that category.

Two setups fail. Autoplay with no control at all is the obvious one (W3C failure F16 covers scrolling content without a pause and restart mechanism). A pause that works only while keyboard focus rests on the carousel fails as well, since W3C says that isn’t a pause mechanism.

The passing setups are simpler. A visible control that stops the motion and resumes it does the job (W3C technique G186). A carousel that starts paused and moves only after a button press also passes, since 2.2.2 applies to content that starts automatically. And a labelled checkbox that sets animation-play-state to paused keeps the pause control inside CSS.

Wrap the animation in a prefers-reduced-motion: no-preference media query. Visitors who ask their system for less motion then get a manual-scroll carousel.

How to make a CSS carousel accessible

Label the carousel region and keep every control reachable by keyboard. Then decide what off-screen slides do to the tab order. The native pseudo-elements supply working controls, but the region markup and the focus decisions stay with you.

Naming the region and the slides

W3C’s ARIA Authoring Practices Guide puts the carousel in a container with role region or group and sets aria-roledescription to carousel. The label comes from aria-labelledby when a visible title exists, and from aria-label otherwise.

Because the role description already says carousel, the label shouldn’t repeat the word. Slides in the same pattern are groups described as slide, named like 3 of 10 when no unique title exists.

That workaround exists because ARIA group elements carry no set size or position. A ul and its li items already expose both, so a list-based scroll-snap carousel keeps the region label and skips the per-slide roles.

Keyboard focus on the scroller

From Chrome 132, a scroller with no focusable children is keyboard focusable by default (Chrome for Developers, which first announced it for Chrome 130).

With text and image slides, the scroller is the only stop. Arrow keys scroll it once it has focus, so give it the region role and a label that says what received focus.

Slides with links or buttons work differently. The children become the stops and the scroller drops out of the tab order. Add tabindex="0" if the scroller itself should stay reachable, which is Chrome’s own recommendation.

Off-screen slides and the tab order

Slides outside the visible page stay in the tab order unless something removes them.

Leave them in and focusing a link on a hidden slide scrolls that slide into view. Make them inert and hidden slides leave the tab order, which means keyboard users depend on the buttons and markers to reach them.

The carousel explainer treats content on later pages as reachable only through the carousel’s own navigation, and Chrome’s Carousel Configurator exposes that as an inert switch.

Test with the keyboard alone first. Then work through a web accessibility checklist for the problems a keyboard pass misses.

How to keep a CSS carousel responsive and fast

Size slides with percentage-based widths and reserve each image’s space before it loads. web.dev sets a good Cumulative Layout Shift score at 0.1 or less, measured at the 75th percentile of page loads.

One slide or several per view

Flex-basis percentages resolve against the width of the flex container, so slide width alone decides how many slides fit.

For two per view, use flex: 0 0 calc((100% - var(--gap)) / 2), with the variable matching the flex gap. Three per view works the same way with flex: 0 0 calc((100% - 2 * var(--gap)) / 3).

Changing the count at a min-width breakpoint is plain responsive design, with wider slides on phones and narrower ones on desktop.

Reserving image space

web.dev lists images with unknown dimensions among the usual causes of layout shift.

Set width and height attributes on each img, or give the slide an aspect ratio in CSS. With aspect-ratio: 16 / 9 auto, the browser uses the stated ratio until the image loads and then switches to the image’s own ratio (MDN).

.carousel img {
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9 auto;
}

Move things with transform, never with left, width or margins. In a manual carousel, shifts that follow a user input are excluded from CLS, but in an autoplay carousel they count and can repeat for as long as the page stays open (web.dev).

Native scrolling is safe. web.dev says a shift is not counted when an element’s start position and the scroll offset change together in the same frame.

Loading slides without extra cost

Chrome 121 gave horizontally scrolling images, such as carousel slides, the same loading thresholds as vertical scrolling. Slides now load before they scroll into view, at the price of more downloads (web.dev).

Slides belong in the HTML. web.dev names script-initiated image loading as probably the biggest carousel performance mistake, since it delays discovery and hurts Largest Contentful Paint, and a CSS carousel avoids it by default.

Keep the first slide eager, because large above-the-fold carousels often hold the page’s LCP element (web.dev). Images in later slides can take loading="lazy", and srcset is worth adding since carousels often carry some of a site’s largest images.

When does a CSS carousel stop working?

The technical failures are narrow. The bigger problem is usually putting content in a carousel that never belonged there.

Mandatory snapping across spaced-out targets

Mandatory snapping hides content when snap targets sit far apart.

The CSS Scroll Snap specification warns that mandatory snap positions on non-adjacent siblings can make the content between them inaccessible when it is longer than the screen. Oversized slides are not the problem, because the browser lets users scroll freely inside a snap area larger than the scrollport.

Put scroll-snap-align on every slide, or on every page boundary, and keep any unsnapped content between two targets shorter than the scrollport.

Changing item counts

A common claim is that CSS carousels can’t cope with a changing number of items. For the native markers, that’s out of date.

MDN’s responsive demo shows the marker count updating as the number of columns changes with the viewport. Add an li and the browser adds a marker.

Active-slide state without native markers

Browsers without ::scroll-marker need script for a custom indicator. The Intersection Observer API, Baseline widely available and supported across browsers since March 2019 (MDN), reports when an element crosses a visibility threshold relative to a root element.

  1. Create an observer with the scroller as root and a threshold such as 0.5.
  2. Observe each slide.
  3. In the callback, move an active attribute to the slide where isIntersecting is true, then style your own dots from it.

The callback runs on the main thread, so keep it short.

Content that does not belong in a carousel

Anything visitors must see is the wrong fit.

web.dev’s carousel guidance notes that most visitors will not view every slide, and that promotional or auto-advancing carousels are easily mistaken for ads. It recommends making each slide’s destination reachable by other navigation paths.

Product image galleries fit well, along with other cases where extra vertical space isn’t an option. Promotions and announcements don’t, and neither does any content nobody could prioritize.

CSS carousel or JavaScript library: which one fits?

Static slides in a swipeable row are a good match for plain CSS. A library earns its weight once the design needs infinite looping, synced carousels or built-in autoplay controls.

FeatureScroll-snap rowNative carouselJavaScript library
Infinite loopNoNoYes (Swiper, Embla, Splide, Slick)
AutoplayKeyframes plus a hand-built pauseKeyframes plus a hand-built pauseBuilt in, or by plugin
Navigation controlsHand-builtBrowser-generated, Chromium onlyLibrary-generated
Script addedNoneNoneSplide: 12 kB gzipped. Slick also needs jQuery

What each library adds

Swiper is tree-shakeable and free of dependencies, with loop, autoplay and lazy loading for images in inactive slides (Swiper documentation). Embla Carousel is dependency-free as well but sits at a lower level, with loop and drag handling plus a method API (Embla documentation).

Splide weighs 12 kB gzipped and has no dependencies, and it adds lazy loading, mouse drag and an autoplay play-pause toggle (Splide README). Slick needs jQuery, loops infinitely by default and can sync several sliders through asNavFor (Slick README).

Sources disagree on Slick’s health. Version 2.0, which its README describes as compatible with jQuery 4, reached npm in September 2026, while accessibility consultancy Allyant describes a library left unmaintained for several years.

Allyant, then still called Accessible360, responded by publishing accessible-slick, an accessible fork of Slick. Check the repository’s recent activity before adopting the original.

Bootstrap’s carousel component is a different kind of choice. It is built with CSS 3D transforms and a bit of JavaScript, supports touch swiping, and its documentation recommends avoiding autoplay or adding a pause control when autoplay is used (Bootstrap 5.3 documentation).

Choosing between them

Stay with CSS when the slides are static and nobody needs an infinite loop. A swipeable row has to be acceptable where the controls don’t render, too.

A library makes sense when the carousel must loop or several carousels have to stay in sync, such as a main image with a thumbnail strip. It also makes sense when autoplay needs a built-in play-pause toggle or progress bar.

A hybrid keeps the scroll-snap row as the base and adds a few lines of JavaScript only for the missing piece, such as loop or active-slide state. If the script fails, visitors still get a swipeable row.

CSS Carousel FAQ

How do you hide the scrollbar on a CSS carousel?

Set scrollbar-width: none on the scroller. Older WebKit browsers also need ::-webkit-scrollbar { display: none; }.

Hide it only where generated buttons or markers render. In Firefox and Safari those controls are absent, and the scrollbar is the only cue that the row scrolls.

Does a CSS carousel support mouse drag?

No. MDN lists swiping, dragging the scrollbar and pressing the scroll buttons as the ways to move through one, so desktop users can’t grab and drag the content itself.

Libraries add that behavior. Splide offers mouse drag and Slick enables desktop dragging by default.

Can a CSS carousel be vertical?

Yes. Chrome states that carousels work for vertical and bidirectional scrollers too.

Set scroll-snap-type: y mandatory on a container with a fixed height and overflow-y. The generated buttons then take the up and down directions.

How do you show a peek of the next slide?

Make each slide narrower than the scroller, such as flex: 0 0 85%, and align slides with scroll-snap-align: start. The remaining width reveals the start of the next slide.

For a peek of the previous slide as well, add scroll-padding on the scroller. The CSS Scroll Snap specification uses that offset in its own paging example.

The last slide cannot reach the start edge, so the browser scrolls as far as it can toward that position.

Which CSS Carousel Build to Ship First

Ship the scroll-snap row first, then add native buttons and markers for Chromium visitors. Every browser gets a working CSS carousel, and the generated controls arrive only where they render.

Start with the scroll-snap row and a visible scroll cue, then put the native controls on top inside a feature query. Script comes last, and only for looping or synced carousels. Each step stays useful when the next one fails to render.

You do end up testing two experiences. Chromium visitors see generated controls, everyone else swipes, and the page carries no added script in exchange.

As of October 2026, the advice changes once Firefox and Safari both ship the pseudo-elements. The hand-built fallback can then shrink, so recheck the caniuse tables before the next release.