GalleryFree

CSS-Only Carousel

A carousel without a single line of JavaScript: snapping scroll by finger, trackpad and keyboard everywhere, native arrows and dots in Chrome and Edge 135+.

CSS natif0 JSscroll-marker

Updated

Hover or click the scene to interact

This effect is free — the Gallery category contains 44 effects total, including 6 free. Effect.Labs has 840 vanilla effects. Explore the category →

Usage examples

Our gardens

Design, planting and upkeep of private gardens within 40 km.

Request a quote

① A landscaper's projects, browsed with a finger

WhenOn the Verdaline landscaper's website, a short introduction and the "Request a quote" button sit on the left; on the right, the row of five gardens (vegetable beds, pond, deck, meadow, hedge) with name and town as a caption. On a phone, the text disappears and the row takes the full width.
WhyVisitors browse the photos as in a phone gallery, without waiting for a script. The next slide sticks out on the right edge: it's obvious there is more to swipe.
Settings--coc-max-width: none, --coc-slide-width: 80%, --coc-ratio: 4/3, one <img> per slide (SVG illustration) and an HTML caption; dots recoloured green to stay visible on the beige background (scene CSS). The scene is live: try a swipe, Shift + wheel or the arrows.

Every cocktail exists without alcohol: ask at the bar.

② A bar's cocktails, one card per dot

WhenOn the Brumeline bar's online menu, Cocktails tab: one card at a time (illustrated glass, name, ingredients, strength, price), the dots under the card to jump straight to the fourth cocktail, the arrows on each side. A line reminds that everything comes alcohol-free too.
WhyEach cocktail gets the full space for its illustration and description, and the dots act as a table of contents. In Firefox and Safari the menu stays readable by swiping.
Settings--coc-slide-width: 100% (one card per view), --coc-ratio: auto and a 180 px height set by the scene, cards on a midnight-blue background. On a narrow screen the illustration is hidden and the text keeps the room.
November
Balance€2,418
Spent this month€1,163
Home
Food
Transport
Outings
Subscriptions
Tip of the day · ← → keys

③ Tip of the day in a budgeting app, by keyboard

WhenIn the Tirelune dashboard, next to the balance, the month's spending and a small chart by category, a purple "Tip of the day" box holds four short tips. Click the box (or Tab to it) and the ← → keys move from one tip to the next; the dots show where you are.
WhyIt's a small secondary detail: it must neither move by itself nor take space. Native keyboard scrolling is enough, no extra button needed.
Settings--coc-slide-width: 100%, --coc-ratio: auto with an 84 px minimum height, native arrows hidden by the scene CSS (::scroll-button { display:none }), dots kept.

How it works

Everything relies on the browser's own scrolling. The .coc-carousel row is a flex container that scrolls horizontally (overflow-x: auto) with snapping (scroll-snap-type: x mandatory): each .coc-slide is 78% of the width and stops centred. You move it with a finger, a trackpad, Shift + wheel, or ← and → when it has focus (it carries tabindex="0").

In Chrome and Edge 135 and later, the CSS adds what the browser can now generate by itself: two arrows ::scroll-button(left) / ::scroll-button(right) that scroll by one page and dim at the ends, and one ::scroll-marker dot per slide, grouped under the row (scroll-marker-group: after); the dot of the visible slide turns aqua thanks to :target-current. Firefox and Safari don't know these pseudo-elements yet: no arrows or dots, but a thin scrollbar stays visible for mouse users (it is hidden only where the dots exist, through @supports selector(::scroll-marker)).

The row takes its container's width, up to 640 px by default. Each slide accepts an <img> that covers it entirely; without one, the gradient from the --g variable shows. With no script involved, several carousels on a page are independent by nature.

Accessibility

  • Roles: the row is a named aria-roledescription="carousel" region, each slide a role="group" "1 of 5". Chrome reuses these names for the dots, exposed as tabs ("1 of 5, tab").
  • Named arrows: the alternative text of content (content: "‹" / "Previous") gives the native arrows the names "Previous" / "Next"; translate it in your CSS.
  • Keyboard everywhere: Tab brings focus to the row (aqua outline), then ← → scroll by one slide. In Chrome 135+, arrows and dots are reachable with Tab as well.
  • Reduced motion: under prefers-reduced-motion: reduce, smooth scrolling (scroll-behavior: smooth) is turned off: arrows and dots bring the slide in at once.
  • Touch: native scrolling, so a vertical gesture always scrolls the page and the momentum is the system's own.

Browser compatibility

Base: scroll-snap-type (Chrome 69, Firefox 99, Safari 11) and aspect-ratio (Chrome 88, Firefox 89, Safari 15). Arrows and dots: ::scroll-button, ::scroll-marker, scroll-marker-group and :target-current only exist in Chrome and Edge 135+; Firefox and Safari don't have them (@mdn/browser-compat-data 8.1.8). Measured in Chromium 151 (arrows, dots, names read out, clicking a dot), Firefox 153 and WebKit 26.5 (keyboard scrolling, no errors, full-width row in an 800 px area).

Chrome 135+✓ Full, arrows and dots (measured)
Edge 135+✓ Full (Chromium engine)
Firefox 99+✓ Snapping scroll without arrows or dots (measured)
Safari 15+✓ Snapping scroll without arrows or dots (measured in WebKit)
Mobile iOS 15+✓ Finger swipe, no dots; not checked on a device
Android Chrome 135+✓ Full, arrows and dots

Before Chrome 135 and in Firefox and Safari: a row that scrolls by finger, trackpad, Shift + wheel and keyboard, with a thin scrollbar. If your visitors mostly use a mouse without horizontal scrolling, add your own navigation links or buttons.

The code

Copy the two blocks (HTML and CSS) into your page. No JavaScript, no dependencies.

HTML
<div class="fx-0798-root">
  <div class="coc-wrap">
    <div class="coc-carousel" tabindex="0" role="region" aria-roledescription="carousel" aria-label="Carousel">
      <div class="coc-slide" role="group" aria-roledescription="slide" aria-label="1 of 5" style="--g:linear-gradient(135deg,#6d8bff,#5cffd8)">01</div>
      <div class="coc-slide" role="group" aria-roledescription="slide" aria-label="2 of 5" style="--g:linear-gradient(135deg,#ff7eb6,#a777ff)">02</div>
      <div class="coc-slide" role="group" aria-roledescription="slide" aria-label="3 of 5" style="--g:linear-gradient(135deg,#ffb86b,#ff5e8a)">03</div>
      <div class="coc-slide" role="group" aria-roledescription="slide" aria-label="4 of 5" style="--g:linear-gradient(135deg,#4dd2ff,#9b8cff)">04</div>
      <div class="coc-slide" role="group" aria-roledescription="slide" aria-label="5 of 5" style="--g:linear-gradient(135deg,#5cffd8,#3ad6b3)">05</div>
    </div>
  </div>
</div>
CSS
.fx-0798-root,
.fx-0798-root *,
.fx-0798-root *::before,
.fx-0798-root *::after {
  box-sizing: border-box
}

.fx-0798-root {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-height: 250px;
  padding: 20px 0;
  overflow: hidden;
  position: relative;
  background: rgb(26, 26, 36);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

/* Width: the container width, capped by --coc-max-width */
.coc-wrap {
  position: relative;
  width: 100%;
  max-width: var(--coc-max-width, 640px);
}

.coc-carousel {
  display: flex;
  gap: 14px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  padding: 6px;
  border-radius: 14px;
  scroll-marker-group: after;
  /* Fallback (Firefox, Safari): thin visible scrollbar for mouse users */
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, .35) transparent;
}

.coc-carousel:focus-visible {
  outline: 2px solid #5cffd8;
  outline-offset: 2px;
}

.coc-slide {
  position: relative;
  overflow: hidden;
  flex: 0 0 var(--coc-slide-width, 78%);
  scroll-snap-align: center;
  aspect-ratio: var(--coc-ratio, 16 / 9);
  border-radius: 14px;
  display: grid;
  place-items: center;
  color: #fff;
  font-weight: 800;
  font-size: 20px;
  letter-spacing: -0.01em;
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
  background: var(--g, linear-gradient(135deg, #6d8bff, #5cffd8));
}

/* An <img> placed in a slide covers it entirely */
.coc-slide > img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Native arrows and dots: Chrome / Edge 135+; the scrollbar is no longer needed there */
@supports selector(::scroll-marker) {
  .coc-carousel {
    scrollbar-width: none;
  }
  .coc-carousel::-webkit-scrollbar {
    height: 0;
  }
}

.coc-slide::scroll-marker {
  content: "";
  width: 9px;
  height: 9px;
  margin: 0 4px;
  border: 0;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.28);
  cursor: pointer;
  transition: background .2s, transform .2s;
}

.coc-slide::scroll-marker:target-current {
  background: #5cffd8;
  transform: scale(1.3);
}

.coc-slide::scroll-marker:hover {
  background: rgba(255, 255, 255, 0.6);
}

.coc-slide::scroll-marker:focus-visible {
  outline: 2px solid #5cffd8;
  outline-offset: 2px;
}

.coc-carousel::scroll-marker-group {
  display: flex;
  justify-content: center;
  align-items: center;
  padding-top: 12px;
}

.coc-carousel::scroll-button(left),
.coc-carousel::scroll-button(right) {
  position: absolute;
  top: calc(50% - 28px);
  transform: translateY(-50%);
  z-index: 3;
  width: 38px;
  height: 38px;
  border: 0;
  border-radius: 50%;
  cursor: pointer;
  font-size: 18px;
  background: rgba(10, 14, 31, 0.65);
  color: #fff;
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
}

.coc-carousel::scroll-button(left) {
  left: 10px;
  content: "‹" / "Previous";
}

.coc-carousel::scroll-button(right) {
  right: 10px;
  content: "›" / "Next";
}

.coc-carousel::scroll-button(left):disabled,
.coc-carousel::scroll-button(right):disabled {
  opacity: .35;
  cursor: default;
}

.coc-carousel::scroll-button(left):focus-visible,
.coc-carousel::scroll-button(right):focus-visible {
  outline: 2px solid #5cffd8;
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .coc-carousel {
    scroll-behavior: auto;
  }
}
JavaScript

Pure CSS — no JavaScript needed.

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Maximum width (CSS — --coc-max-width) 640px The row takes its container's width up to this limit; none for the full width.
Slide width (CSS — --coc-slide-width) 78% 100% for one slide per view; under 50% to see several.
Proportions (CSS — --coc-ratio) 16 / 9 Width/height ratio of the slides; auto if you give them a height.
Slide background (HTML — style="--g: …") linear-gradient(135deg,#6d8bff,#5cffd8) Gradient or colour of each slide; an <img> inside covers it.
Active dot (CSS — ::scroll-marker:target-current) #5cffd8, scale 1.3 Colour of the visible slide's dot (Chrome / Edge 135+).
Arrow names (CSS — content: "‹" / "Previous") "Previous" / "Next" Text read by screen readers for the native arrows; translate it in your CSS.

FAQ

Why can't I see any arrows or dots?

You're on Firefox, Safari or a Chrome older than version 135: these browsers don't generate ::scroll-button and ::scroll-marker yet. The row stays usable by finger, trackpad, keyboard and the scrollbar.

How do I centre the first slide on load?

Snapping centres the slides, but the first one can't be centred since there's nothing on its left. Add an 11% padding-inline (half the free space) on .coc-carousel so it starts in the middle.

Can the slides scroll by themselves?

Not without JavaScript: this carousel only moves when someone scrolls it. That's also what makes it calm: no imposed animation, nothing to pause.