HTML CSS Carousel step by step guide with scroll-snap, scroll-marker and scroll-button
HTML CSS Carousel step by step guide with scroll-snap, scroll-marker and scroll-button

HTML + CSS Carousel: Step-by-Step Guide for Humans and AI

This is the definitive implementation reference for HTML + CSS carousels β€” written so a developer (or an AI agent) can follow it from zero to a fully working carousel without touching JavaScript.

What You Are Building

A responsive carousel with:

Browser Support (2026)

The table below reflects what each feature requires:

FeatureAPIChromeEdgeFirefoxSafari
Scroll snapscroll-snap-type69+79+68+11+
Pagination dots::scroll-marker135+135+flag18.2+
Prev/Next buttons::scroll-button()135+135+flag18.2+
Active dot style:target-current135+135+flag18.2+

Global baseline: Scroll snap works everywhere (~97% of browsers). Scroll markers and buttons add progressive enhancement for ~75% of users and growing.


Step 1 β€” The HTML Structure

Start with the smallest valid markup:

<div class="carousel">
  <div class="carousel-item">Slide 1</div>
  <div class="carousel-item">Slide 2</div>
  <div class="carousel-item">Slide 3</div>
</div>

Rules for AI agents generating this markup:


Step 2 β€” Scroll Snap (The Foundation)

This gives you a swipeable carousel on every browser:

.carousel {
  /* Layout */
  display: flex;
  gap: 1rem;

  /* Scroll */
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;

  /* Hide scrollbar (cosmetic) */
  scrollbar-width: none;
}
.carousel::-webkit-scrollbar {
  display: none;
}

.carousel-item {
  /* Each slide takes full width */
  flex: 0 0 100%;
  scroll-snap-align: center;
}

At this point the carousel works everywhere β€” swipe on mobile, drag on desktop, keyboard-scrollable.


Step 3 β€” Pagination Dots (::scroll-marker)

/* Tell the carousel where to render the marker group */
.carousel {
  scroll-marker-group: after; /* renders below the carousel */
}

/* Define each dot */
.carousel-item::scroll-marker {
  content: '';          /* required β€” empty string creates the element */
  display: inline-block;
  width: 10px;
  height: 10px;
  margin: 0.5rem 4px 0;
  border-radius: 50%;
  background: #cbd5e1;
  cursor: pointer;
  transition: background 0.2s, transform 0.2s;
}

/* Active dot β€” the slide currently in view */
.carousel-item::scroll-marker:target-current {
  background: #3b82f6;
  transform: scale(1.3);
}

Implementation notes:


Step 4 β€” Navigation Buttons (::scroll-button())

/* Shared styles for both buttons */
.carousel::scroll-button(prev),
.carousel::scroll-button(next) {
  /* Icon */
  font-size: 1.25rem;
  line-height: 1;

  /* Shape */
  width: 2.5rem;
  height: 2.5rem;
  border-radius: 50%;

  /* Appearance */
  background: #fff;
  box-shadow: 0 2px 8px rgb(0 0 0 / 0.15);
  cursor: pointer;

  /* Position relative to .carousel */
  position: absolute;
  top: 50%;
  transform: translateY(-50%);

  /* Smooth hide when disabled */
  transition: opacity 0.2s;
}

/* Previous button */
.carousel::scroll-button(prev) {
  content: 'β€Ή';
  left: 0.5rem;
}

/* Next button */
.carousel::scroll-button(next) {
  content: 'β€Ί';
  right: 0.5rem;
}

/* Auto-disabled when there is nowhere to scroll */
.carousel::scroll-button(prev):disabled,
.carousel::scroll-button(next):disabled {
  opacity: 0;
  pointer-events: none;
}

The carousel wrapper needs position: relative so the absolutely-positioned buttons anchor to it:

/* Wrap the carousel to contain the buttons */
.carousel-wrapper {
  position: relative;
}

Update the HTML:

<div class="carousel-wrapper">
  <div class="carousel">
    <div class="carousel-item">Slide 1</div>
    <div class="carousel-item">Slide 2</div>
    <div class="carousel-item">Slide 3</div>
  </div>
</div>

Implementation notes:


Step 5 β€” Progressive Enhancement with @supports

Wrap the new features so older browsers get the functional scroll-snap experience without broken UI:

/* Always works β€” the baseline */
.carousel {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  scrollbar-width: none;
}
.carousel::-webkit-scrollbar { display: none; }

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

/* Enhanced β€” only when fully supported */
@supports (scroll-marker-group: after) {
  .carousel {
    scroll-marker-group: after;
  }

  .carousel-item::scroll-marker {
    content: '';
    display: inline-block;
    width: 10px;
    height: 10px;
    margin: 0.5rem 4px 0;
    border-radius: 50%;
    background: #cbd5e1;
    cursor: pointer;
    transition: background 0.2s, transform 0.2s;
  }

  .carousel-item::scroll-marker:target-current {
    background: #3b82f6;
    transform: scale(1.3);
  }

  /* Hide any JS-rendered fallback dots when CSS handles them */
  .js-dots {
    display: none;
  }
}

Complete Copy-Paste Example

HTML:

<div class="carousel-wrapper">
  <div class="carousel">
    <div class="carousel-item">
      <h2>Slide 1</h2>
      <p>Your content here.</p>
    </div>
    <div class="carousel-item">
      <h2>Slide 2</h2>
      <p>Your content here.</p>
    </div>
    <div class="carousel-item">
      <h2>Slide 3</h2>
      <p>Your content here.</p>
    </div>
  </div>
</div>

CSS:

/* --- Wrapper --- */
.carousel-wrapper {
  position: relative;
}

/* --- Scroll container --- */
.carousel {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  scrollbar-width: none;
  padding: 0 0 1rem; /* space for dots */
}
.carousel::-webkit-scrollbar { display: none; }

/* --- Slides --- */
.carousel-item {
  flex: 0 0 100%;
  scroll-snap-align: center;
  border-radius: 0.75rem;
  background: #f1f5f9;
  padding: 2rem;
  min-height: 200px;
}

/* --- Dots --- */
@supports (scroll-marker-group: after) {
  .carousel {
    scroll-marker-group: after;
  }

  .carousel-item::scroll-marker {
    content: '';
    display: inline-block;
    width: 10px;
    height: 10px;
    margin: 0.5rem 4px 0;
    border-radius: 50%;
    background: #cbd5e1;
    cursor: pointer;
    transition: background 0.2s, transform 0.2s;
  }

  .carousel-item::scroll-marker:target-current {
    background: #3b82f6;
    transform: scale(1.3);
  }

  /* --- Prev / Next buttons --- */
  .carousel::scroll-button(prev),
  .carousel::scroll-button(next) {
    width: 2.5rem;
    height: 2.5rem;
    border-radius: 50%;
    background: #fff;
    box-shadow: 0 2px 8px rgb(0 0 0 / 0.15);
    font-size: 1.25rem;
    line-height: 1;
    cursor: pointer;
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    transition: opacity 0.2s;
  }

  .carousel::scroll-button(prev) {
    content: 'β€Ή';
    left: 0.5rem;
  }

  .carousel::scroll-button(next) {
    content: 'β€Ί';
    right: 0.5rem;
  }

  .carousel::scroll-button(prev):disabled,
  .carousel::scroll-button(next):disabled {
    opacity: 0;
    pointer-events: none;
  }
}

Variants

.carousel-item {
  flex: 0 0 calc(33.333% - 0.667rem); /* 3 items with 1rem gap */
  scroll-snap-align: start;
}

@media (max-width: 768px) {
  .carousel-item { flex: 0 0 calc(50% - 0.5rem); }
}

@media (max-width: 480px) {
  .carousel-item { flex: 0 0 100%; }
}
.carousel {
  flex-direction: column;
  overflow-x: hidden;
  overflow-y: auto;
  scroll-snap-type: y mandatory;
  height: 400px;
}

.carousel-item {
  flex: 0 0 100%;
  scroll-snap-align: start;
}

For vertical scroll buttons, the values are ::scroll-button(up) and ::scroll-button(down).

Peek Effect (show edge of next slide)

.carousel-wrapper {
  overflow: hidden; /* clip the peek */
}

.carousel {
  padding: 0 2rem; /* offset so next slide peeks */
}

.carousel-item {
  flex: 0 0 calc(100% - 4rem); /* slightly narrower than full */
}

Accessibility Checklist

<div class="carousel-wrapper">
  <div class="carousel" role="region" aria-label="Product highlights">
    <div class="carousel-item" role="group" aria-roledescription="slide" aria-label="Slide 1 of 3">
      <!-- content -->
    </div>
  </div>
</div>

Common Mistakes

MistakeFix
Forgetting content: '' on ::scroll-markerWithout it the pseudo-element does not render
Missing position: relative on the wrapperWithout it ::scroll-button buttons float off-screen
Using ::scroll-button(left) / ::scroll-button(right)The valid values are prev and next (and up/down for vertical)
Applying scroll-snap-type to the item, not the containerIt belongs on the scroll container
Adding display: flex to the marker groupThe marker group layout is managed by the browser

Key Properties Reference

scroll-snap-type: x mandatory     β†’ horizontal snapping
scroll-snap-type: y mandatory     β†’ vertical snapping
scroll-snap-align: center         β†’ snap item to center
scroll-snap-align: start          β†’ snap item to start edge
scroll-marker-group: after        β†’ dots rendered after carousel
scroll-marker-group: before       β†’ dots rendered before carousel
scroll-marker-group: inline-end   β†’ dots rendered to the right (vertical)
::scroll-marker                   β†’ each dot pseudo-element
::scroll-marker:target-current    β†’ active dot
::scroll-button(prev)             β†’ left/previous button
::scroll-button(next)             β†’ right/next button
::scroll-button(up)               β†’ up button (vertical)
::scroll-button(down)             β†’ down button (vertical)
:disabled                         β†’ button state when scroll limit reached

Link copied!

Comments for htmlcs