CSS Slideshows: 16 Examples + 4 Ways to Build One

A lot of sliders load a script just to change which image is showing. A CSS slideshow handles that part on its own, with no script to load or keep updated.

Below are 16 CSS slideshow examples you can try and copy, followed by a guide to the four build methods, autoplay, and the WCAG pause rule.

By Bogdan Sandu · Updated October 28, 2026

CSS-only slideshows

Five slideshows with no script at all. A checked radio input or the URL fragment decides which slide is visible, and plain transitions handle the change. Each slide fills the same grid cell, so only one shows at a time.

Radio button crossfade with dots

  • :checked
  • general sibling ~
  • opacity crossfade
A deep blue fjord between grey granite cliffs, seen from a rocky ledge
Fjord ferry weekend 3 days from $420
Granite cliffs above a pine forest and a calm river with a sandy bank
Valley river camp 2 days from $180
Two yellow tents pitched on snow beside a stone wall, with snowy peaks behind
Alpine hut traverse 4 days from $690
A road cut into a green hillside above the sea, with headlands fading into haze
Coast road loop 3 days from $350

Click a dot, or tab to the dots and use the arrow keys.

One visually hidden radio per slide sits before the slides, and #ss-rc-2:checked ~ .ss-rc__view picks the visible one. The leaving slide keeps full opacity for the length of the fade, so the new slide fades in over it with no grey dip in between. Because the inputs share a name, the arrow keys move between slides natively.

View code
.ss-rc {
  position: relative;
  display: grid;
  gap: 10px;
  width: min(100%, 620px);
  color: #14213d;
}
.ss-rc__radio,
.ss-rc__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.ss-rc__view {
  display: grid;
  overflow: hidden;
  border-radius: 18px;
  background: #14213d;
}
.ss-rc__slide {
  position: relative;
  grid-area: 1 / 1;
  margin: 0;
  opacity: 0;
  visibility: hidden;
  transition: opacity 0s .7s, visibility 0s .7s;
}
.ss-rc__slide img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 10;
  object-fit: cover;
}
.ss-rc__slide figcaption {
  position: absolute;
  left: 14px;
  bottom: 14px;
  max-width: calc(100% - 28px);
  box-sizing: border-box;
  padding: 10px 14px;
  border-radius: 12px;
  background: #fff;
  font-size: .9rem;
  line-height: 1.35;
}
.ss-rc__slide strong {
  display: block;
  font: 600 1.1rem/1.25 "Fraunces", serif;
}
#ss-rc-1:checked ~ .ss-rc__view .ss-rc__slide:nth-child(1),
#ss-rc-2:checked ~ .ss-rc__view .ss-rc__slide:nth-child(2),
#ss-rc-3:checked ~ .ss-rc__view .ss-rc__slide:nth-child(3),
#ss-rc-4:checked ~ .ss-rc__view .ss-rc__slide:nth-child(4) {
  z-index: 1;
  opacity: 1;
  visibility: visible;
  transition: opacity .7s ease;
}
.ss-rc__dots {
  display: flex;
  justify-content: center;
  gap: 2px;
}
.ss-rc__dot {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: 12px;
  cursor: pointer;
}
.ss-rc__dot::before {
  content: "";
  width: 12px;
  height: 12px;
  border-radius: 999px;
  background: #7a859b;
}
.ss-rc__dot:hover::before {
  background: #4a556b;
}
#ss-rc-1:checked ~ .ss-rc__dots .ss-rc__dot:nth-child(1)::before,
#ss-rc-2:checked ~ .ss-rc__dots .ss-rc__dot:nth-child(2)::before,
#ss-rc-3:checked ~ .ss-rc__dots .ss-rc__dot:nth-child(3)::before,
#ss-rc-4:checked ~ .ss-rc__dots .ss-rc__dot:nth-child(4)::before {
  width: 30px;
  background: #14213d;
}
#ss-rc-1:focus-visible ~ .ss-rc__dots .ss-rc__dot:nth-child(1),
#ss-rc-2:focus-visible ~ .ss-rc__dots .ss-rc__dot:nth-child(2),
#ss-rc-3:focus-visible ~ .ss-rc__dots .ss-rc__dot:nth-child(3),
#ss-rc-4:focus-visible ~ .ss-rc__dots .ss-rc__dot:nth-child(4) {
  outline: 3px solid #e85d04;
  outline-offset: -3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ss-rc__dot::before {
    transition: width .3s ease, background-color .3s ease;
  }
}

Thumbnail strip gallery

  • :has(:checked)
  • labels as thumbnails
  • aspect-ratio
Red sandstone outcrops glowing orange at sunset above a rocky slope
Canyon at dusk Giclee print, 40 x 30 cm$64
A tall waterfall dropping into a mossy green gorge surrounded by pine forest
Moss gorge falls Giclee print, 40 x 30 cm$64
A single-lane road winding through a green valley below steep grassy cliffs under low cloud
Highland pass Giclee print, 60 x 45 cm$88
A hiker with a large orange backpack standing on a rock above a hazy mountain valley
Morning haze Giclee print, 40 x 30 cm$64
An orange jellyfish with long trailing tentacles in deep blue water
Drift Giclee print, 30 x 30 cm$52

Click a thumbnail.

The thumbnails are the labels of five hidden radios, so clicking a photo checks its input. Here the state is read with .ss-th:has(#ss-th-3:checked) on the wrapper instead of a sibling combinator, which means the radios could sit anywhere inside it. Each label carries hidden text, because a thumbnail with empty alt would otherwise give the control no name.

View code
.ss-th {
  position: relative;
  display: grid;
  gap: 12px;
  width: min(100%, 560px);
  padding: 12px;
  box-sizing: border-box;
  border-radius: 20px;
  background: #fff;
  box-shadow: 0 14px 40px rgba(44, 30, 18, .12);
  color: #2c1e12;
}
.ss-th__radio,
.ss-th__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.ss-th__view {
  display: grid;
}
.ss-th__slide {
  grid-area: 1 / 1;
  margin: 0;
  opacity: 0;
  visibility: hidden;
  transition: opacity .45s ease, visibility 0s .45s;
}
.ss-th__slide img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 12px;
}
.ss-th__slide figcaption {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 12px;
  padding: 12px 4px 0;
  font-size: .88rem;
  color: #5b4a3b;
}
.ss-th__slide strong {
  display: block;
  color: #2c1e12;
  font: 600 1.15rem/1.25 "Fraunces", serif;
}
.ss-th__slide b {
  color: #2c1e12;
  font-size: 1.05rem;
}
.ss-th:has(#ss-th-1:checked) .ss-th__slide:nth-child(1),
.ss-th:has(#ss-th-2:checked) .ss-th__slide:nth-child(2),
.ss-th:has(#ss-th-3:checked) .ss-th__slide:nth-child(3),
.ss-th:has(#ss-th-4:checked) .ss-th__slide:nth-child(4),
.ss-th:has(#ss-th-5:checked) .ss-th__slide:nth-child(5) {
  opacity: 1;
  visibility: visible;
  transition: opacity .45s ease;
}
.ss-th__strip {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 8px;
}
.ss-th__thumb {
  position: relative;
  display: block;
  min-height: 44px;
  border-radius: 8px;
  cursor: pointer;
}
.ss-th__thumb img {
  display: block;
  width: 100%;
  height: 100%;
  aspect-ratio: 1;
  object-fit: cover;
  border-radius: 8px;
  opacity: .6;
  transition: opacity .2s ease;
}
.ss-th__thumb:hover img {
  opacity: .9;
}
.ss-th:has(#ss-th-1:checked) .ss-th__thumb:nth-child(1) img,
.ss-th:has(#ss-th-2:checked) .ss-th__thumb:nth-child(2) img,
.ss-th:has(#ss-th-3:checked) .ss-th__thumb:nth-child(3) img,
.ss-th:has(#ss-th-4:checked) .ss-th__thumb:nth-child(4) img,
.ss-th:has(#ss-th-5:checked) .ss-th__thumb:nth-child(5) img {
  opacity: 1;
  box-shadow: 0 0 0 3px #fff, 0 0 0 5px #b5541d;
}
.ss-th:has(#ss-th-1:focus-visible) .ss-th__thumb:nth-child(1),
.ss-th:has(#ss-th-2:focus-visible) .ss-th__thumb:nth-child(2),
.ss-th:has(#ss-th-3:focus-visible) .ss-th__thumb:nth-child(3),
.ss-th:has(#ss-th-4:focus-visible) .ss-th__thumb:nth-child(4),
.ss-th:has(#ss-th-5:focus-visible) .ss-th__thumb:nth-child(5) {
  outline: 3px solid #1b4965;
  outline-offset: 5px;
}

:target product tour

  • :target
  • :has(:target)
  • scroll-margin-top

Capture anything

Type, paste a link or drop in a photo. Notes save as you write, even with no signal.

Sort it later

Drag notes into stacks, or let tags group them. Nothing gets filed until you decide.

Share a stack

Send a read-only link to a client, or invite the team to edit the same stack.

Pick up anywhere

Your phone, laptop and tablet stay in sync, and the last note you opened opens first.

Click a tab name. Watch the address bar and the Back button.

Plain links to #ss-tg-2 make the matching slide the :target, and :not(:has(:target)) shows the first slide when the URL has no fragment. The honest costs: every click adds a history entry, the page scrolls to the slide (scroll-margin-top softens it), and only one :target can exist per page, so two slideshows like this fight over the URL.

View code
.ss-tg {
  display: grid;
  gap: 12px;
  width: min(100%, 560px);
  color: #1d2a24;
}
.ss-tg__view {
  display: grid;
}
.ss-tg__slide {
  grid-area: 1 / 1;
  display: grid;
  align-content: start;
  gap: 10px;
  min-height: 250px;
  padding: clamp(20px, 5vw, 32px);
  box-sizing: border-box;
  border-radius: 22px;
  background: #f1e6d3;
  opacity: 0;
  visibility: hidden;
  scroll-margin-top: 140px;
  transition: opacity .4s ease, visibility 0s .4s;
}
.ss-tg__slide:nth-child(2) {
  background: #dce8de;
}
.ss-tg__slide:nth-child(3) {
  background: #dbe5f1;
}
.ss-tg__slide:nth-child(4) {
  background: #eadfee;
}
.ss-tg__slide:target,
.ss-tg__view:not(:has(:target)) .ss-tg__slide:first-child {
  opacity: 1;
  visibility: visible;
  transition: opacity .4s ease;
}
.ss-tg__title {
  margin: 0;
  font: 600 clamp(1.4rem, 4vw, 1.8rem)/1.15 "Fraunces", serif;
}
.ss-tg__slide p {
  max-width: 34ch;
  margin: 0;
  font-size: .98rem;
  line-height: 1.55;
}
.ss-tg__mock {
  display: grid;
  gap: 8px;
  margin-top: 10px;
  padding: 14px;
  border-radius: 14px;
  background: #fff;
}
.ss-tg__mock span {
  height: 10px;
  border-radius: 6px;
  background: #1d2a24;
  opacity: .16;
}
.ss-tg__mock span:nth-child(1) {
  width: 70%;
  opacity: .5;
}
.ss-tg__mock span:nth-child(3) {
  width: 45%;
}
.ss-tg__nav {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 4px;
  padding: 5px;
  border: 1px solid #c9d3cc;
  border-radius: 16px;
  background: #fff;
}
.ss-tg__nav a {
  display: grid;
  place-items: center;
  min-height: 44px;
  border-radius: 12px;
  color: #1d2a24;
  font-size: .92rem;
  font-weight: 600;
  text-decoration: none;
}
.ss-tg__nav a:hover {
  background: #eef2ef;
}
.ss-tg__nav a:focus-visible {
  outline: 3px solid #c2410c;
  outline-offset: 2px;
}
.ss-tg:has(#ss-tg-1:target) a[href="#ss-tg-1"],
.ss-tg:has(#ss-tg-2:target) a[href="#ss-tg-2"],
.ss-tg:has(#ss-tg-3:target) a[href="#ss-tg-3"],
.ss-tg:has(#ss-tg-4:target) a[href="#ss-tg-4"],
.ss-tg:not(:has(:target)) a[href="#ss-tg-1"] {
  background: #1d2a24;
  color: #fff;
}

Clip-path wipe between slides

  • clip-path: inset()
  • transition-delay
  • :has(:checked)
An orange jellyfish with long trailing tentacles in deep blue water
Pacific sea nettle, 30 m down
A downtown skyline of tall towers at sunrise beside a wide lake
Lakefront towers at 6:40 am
Red sandstone outcrops glowing orange at sunset above a rocky slope
Sandstone rim, last light
Two yellow tents pitched on snow beside a stone wall, with snowy peaks behind
High camp, minus 12 degrees

Pick a place in the switcher.

The new slide sits on top and its clip-path opens from inset(0 100% 0 0) to inset(0), a left-to-right wipe. The trick is the leaving slide: it drops below at once but keeps its full clip for the length of the wipe through a transition delay, so the old photo stays visible underneath until it is covered. With reduced motion the slides simply swap.

View code
.ss-cw {
  display: grid;
  gap: 12px;
  width: min(100%, 600px);
  color: #eef1f6;
}
.ss-cw__tabs {
  position: relative;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 4px;
  padding: 4px;
  border-radius: 14px;
  background: #1c1f26;
}
.ss-cw__radio {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  opacity: 0;
}
.ss-cw__tabs label {
  display: grid;
  place-items: center;
  min-height: 44px;
  padding: 0 4px;
  border-radius: 10px;
  color: #c9cfdb;
  font-size: .9rem;
  font-weight: 600;
  text-align: center;
  cursor: pointer;
}
.ss-cw__tabs label:hover {
  color: #fff;
  background: #2a2f39;
}
.ss-cw__radio:checked + label {
  background: #f2c14e;
  color: #14161b;
}
.ss-cw__radio:focus-visible + label {
  outline: 3px solid #8ab4ff;
  outline-offset: 2px;
}
.ss-cw__view {
  display: grid;
  overflow: hidden;
  border-radius: 16px;
  background: #0b0c10;
}
.ss-cw__slide {
  position: relative;
  grid-area: 1 / 1;
  margin: 0;
  visibility: hidden;
}
.ss-cw__slide img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 3 / 2;
  object-fit: cover;
}
.ss-cw__slide figcaption {
  position: absolute;
  left: 12px;
  bottom: 12px;
  max-width: calc(100% - 24px);
  box-sizing: border-box;
  padding: 7px 12px;
  border-radius: 8px;
  background: #0b0c10;
  color: #eef1f6;
  font: .82rem/1.3 ui-monospace, Consolas, monospace;
}
.ss-cw:has(#ss-cw-1:checked) .ss-cw__slide:nth-child(1),
.ss-cw:has(#ss-cw-2:checked) .ss-cw__slide:nth-child(2),
.ss-cw:has(#ss-cw-3:checked) .ss-cw__slide:nth-child(3),
.ss-cw:has(#ss-cw-4:checked) .ss-cw__slide:nth-child(4) {
  z-index: 1;
  visibility: visible;
}
@media (prefers-reduced-motion: no-preference) {
  .ss-cw__slide {
    visibility: visible;
    clip-path: inset(0 100% 0 0);
    transition: clip-path 0s .75s;
  }
  .ss-cw:has(#ss-cw-1:checked) .ss-cw__slide:nth-child(1),
  .ss-cw:has(#ss-cw-2:checked) .ss-cw__slide:nth-child(2),
  .ss-cw:has(#ss-cw-3:checked) .ss-cw__slide:nth-child(3),
  .ss-cw:has(#ss-cw-4:checked) .ss-cw__slide:nth-child(4) {
    clip-path: inset(0);
    transition: clip-path .75s cubic-bezier(.77, 0, .18, 1);
  }
}

Vertical slideshow with a dot rail

  • translateY
  • grid-template-rows
  • :checked
A hiker with a large orange backpack standing on a rock above a hazy mountain valley
Stage 1 of 4Ridge start6.4 km, 520 m up
A tall waterfall dropping into a mossy green gorge surrounded by pine forest
Stage 2 of 4Gorge descent8.1 km, 690 m down
Granite cliffs above a pine forest and a calm river with a sandy bank
Stage 3 of 4River camp11.3 km, mostly flat
A single-lane road winding through a green valley below steep grassy cliffs
Stage 4 of 4Pass road out9.0 km, 310 m up

Use the dots on the right edge.

The slides stack in a column track that is exactly one screen tall, and each checked radio moves the track up by a multiple of 100%. Percentages in translateY refer to the track itself, so the math holds at any phone height. The dot rail is a column of labels, each with a 44px target around a small visible dot.

View code
.ss-vr {
  position: relative;
  height: 100%;
  overflow: hidden;
  background: #0f1a14;
  color: #fff;
}
.ss-vr__radio,
.ss-vr__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.ss-vr__view {
  height: 100%;
  overflow: hidden;
}
.ss-vr__track {
  display: grid;
  grid-template-rows: repeat(4, 100%);
  height: 100%;
}
.ss-vr__slide {
  position: relative;
  margin: 0;
  overflow: hidden;
}
.ss-vr__slide img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.ss-vr__slide figcaption {
  position: absolute;
  left: 16px;
  right: 72px;
  bottom: 20px;
  padding: 14px 16px;
  border-radius: 16px;
  background: #0f1a14;
  font-size: .9rem;
  line-height: 1.4;
  color: #d5e3d9;
}
.ss-vr__slide figcaption span {
  display: block;
  color: #a7e3b8;
  font-size: .8rem;
  font-weight: 600;
}
.ss-vr__slide strong {
  display: block;
  margin: 2px 0 4px;
  color: #fff;
  font: 600 1.35rem/1.2 "Fraunces", serif;
}
#ss-vr-2:checked ~ .ss-vr__view .ss-vr__track {
  transform: translateY(-100%);
}
#ss-vr-3:checked ~ .ss-vr__view .ss-vr__track {
  transform: translateY(-200%);
}
#ss-vr-4:checked ~ .ss-vr__view .ss-vr__track {
  transform: translateY(-300%);
}
.ss-vr__rail {
  position: absolute;
  top: 50%;
  right: 10px;
  display: grid;
  border-radius: 999px;
  background: #0f1a14;
  transform: translateY(-50%);
}
.ss-vr__rail label {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  cursor: pointer;
}
.ss-vr__rail label::before {
  content: "";
  width: 10px;
  height: 10px;
  box-sizing: border-box;
  border: 2px solid #d5e3d9;
  border-radius: 999px;
}
#ss-vr-1:checked ~ .ss-vr__rail label:nth-child(1)::before,
#ss-vr-2:checked ~ .ss-vr__rail label:nth-child(2)::before,
#ss-vr-3:checked ~ .ss-vr__rail label:nth-child(3)::before,
#ss-vr-4:checked ~ .ss-vr__rail label:nth-child(4)::before {
  height: 24px;
  border-color: #a7e3b8;
  background: #a7e3b8;
}
#ss-vr-1:focus-visible ~ .ss-vr__rail label:nth-child(1),
#ss-vr-2:focus-visible ~ .ss-vr__rail label:nth-child(2),
#ss-vr-3:focus-visible ~ .ss-vr__rail label:nth-child(3),
#ss-vr-4:focus-visible ~ .ss-vr__rail label:nth-child(4) {
  outline: 3px solid #fbbf24;
  outline-offset: -4px;
}
@media (prefers-reduced-motion: no-preference) {
  .ss-vr__track {
    transition: transform .65s cubic-bezier(.65, 0, .35, 1);
  }
  .ss-vr__rail label::before {
    transition: height .3s ease;
  }
}

Autoplay slideshows with a pause control

Slides that change on their own, each with a visible way to stop them, which WCAG 2.2.2 requires once the motion runs longer than five seconds. With reduced motion turned on, every one of them stays on its first slide until the visitor moves it.

Keyframe autoplay crossfade with a pause switch

  • @keyframes
  • animation-delay
  • animation-play-state
A downtown skyline of tall towers at sunrise beside a wide lake
Lakeside city Direct flights from $129
A deep blue fjord between grey granite cliffs, seen from a rocky ledge
Fjord country Ferry and cabin from $310
A tall waterfall dropping into a mossy green gorge surrounded by pine forest
Rainforest falls Guided hikes from $85
Red sandstone outcrops glowing orange at sunset above a rocky slope
Red rock desert Camping from $40

Wait five seconds, then press Pause slideshow.

Four slides share one 20 second fade animation, each delayed by 5 seconds. The first slide gets its own keyframes that start at full opacity, which removes the empty first second that the plain version shows on load. The labelled checkbox pauses every slide through animation-play-state, and with reduced motion the animation never starts.

View code
.ss-kf {
  position: relative;
  display: grid;
  justify-items: start;
  gap: 14px;
  width: min(100%, 580px);
  color: #2b2118;
}
.ss-kf__check {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  opacity: 0;
}
.ss-kf__view {
  display: grid;
  width: 100%;
  box-sizing: border-box;
  overflow: hidden;
  border: 10px solid #fff;
  border-radius: 6px;
  background: #fff;
  box-shadow: 0 18px 40px rgba(43, 33, 24, .18);
}
.ss-kf__slide {
  position: relative;
  grid-area: 1 / 1;
  margin: 0;
  opacity: 0;
}
.ss-kf__slide:first-child {
  opacity: 1;
}
.ss-kf__slide img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 3 / 2;
  object-fit: cover;
}
.ss-kf__slide figcaption {
  position: absolute;
  top: 12px;
  left: 12px;
  max-width: calc(100% - 24px);
  box-sizing: border-box;
  padding: 8px 12px;
  border-radius: 8px;
  background: #fffaf3;
  font-size: .86rem;
  line-height: 1.35;
}
.ss-kf__slide strong {
  display: block;
  font: 600 1.05rem/1.25 "Fraunces", serif;
}
.ss-kf__toggle {
  display: none;
  align-items: center;
  gap: 10px;
  min-height: 44px;
  padding: 0 18px 0 16px;
  box-sizing: border-box;
  border: 2px solid #2b2118;
  border-radius: 999px;
  background: #fff;
  font-size: .92rem;
  font-weight: 600;
  cursor: pointer;
}
.ss-kf__icon {
  width: 12px;
  height: 14px;
  box-sizing: border-box;
  border-left: 4px solid currentColor;
  border-right: 4px solid currentColor;
}
.ss-kf__check:checked ~ .ss-kf__toggle {
  background: #2b2118;
  color: #fff;
}
.ss-kf__check:checked ~ .ss-kf__toggle .ss-kf__icon {
  width: 0;
  height: 0;
  border-left: 12px solid currentColor;
  border-right: 0;
  border-top: 7px solid transparent;
  border-bottom: 7px solid transparent;
}
.ss-kf__check:focus-visible ~ .ss-kf__toggle {
  outline: 3px solid #c2410c;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ss-kf__toggle {
    display: inline-flex;
  }
  .ss-kf__slide {
    animation: ss-kf-fade 20s ease-in-out infinite backwards;
  }
  .ss-kf__slide:first-child {
    animation-name: ss-kf-first;
  }
  .ss-kf__slide:nth-child(2) {
    animation-delay: 5s;
  }
  .ss-kf__slide:nth-child(3) {
    animation-delay: 10s;
  }
  .ss-kf__slide:nth-child(4) {
    animation-delay: 15s;
  }
  .ss-kf__check:checked ~ .ss-kf__view .ss-kf__slide,
  .ss-kf__view:hover .ss-kf__slide {
    animation-play-state: paused;
  }
}
@keyframes ss-kf-fade {
  0% {
    opacity: 0;
  }
  5%,
  25% {
    opacity: 1;
  }
  30%,
  100% {
    opacity: 0;
  }
}
@keyframes ss-kf-first {
  0%,
  25% {
    opacity: 1;
  }
  30%,
  100% {
    opacity: 0;
  }
}

Ken Burns zoom slideshow

  • scale keyframes
  • transform-origin
  • aria-pressed
Two yellow tents pitched on snow beside a stone wall, with snowy peaks behind
A deep blue fjord between grey granite cliffs, seen from a rocky ledge
Red sandstone outcrops glowing orange at sunset above a rocky slope

Far NorthA film by Odile Brandt, in cinemas March 6

Press Pause motion to freeze the zoom and the fades.

Each photo fades in and slowly pushes in toward a different point, set per slide with transform-origin. The zoom runs only inside prefers-reduced-motion: no-preference, because slow constant scaling is exactly the kind of motion that bothers people with vestibular disorders. A real toggle button with aria-pressed pauses both the fade and the zoom.

View code
.ss-kb {
  width: min(100%, 640px);
  container-type: inline-size;
}
.ss-kb__view {
  position: relative;
  display: grid;
  overflow: hidden;
  border-radius: 14px;
  background: #0c0e12;
  aspect-ratio: 4 / 3;
}
.ss-kb__slide {
  grid-area: 1 / 1;
  margin: 0;
  overflow: hidden;
  opacity: 0;
}
.ss-kb__slide:first-child {
  opacity: 1;
}
.ss-kb__slide img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.ss-kb__slide:nth-child(1) img {
  transform-origin: 70% 60%;
}
.ss-kb__slide:nth-child(2) img {
  transform-origin: 30% 40%;
}
.ss-kb__slide:nth-child(3) img {
  transform-origin: 50% 80%;
}
.ss-kb__title {
  position: absolute;
  left: 14px;
  bottom: 14px;
  z-index: 2;
  max-width: calc(100% - 28px);
  box-sizing: border-box;
  margin: 0;
  padding: 12px 16px;
  border-radius: 10px;
  background: #0c0e12;
  color: #c7ccd6;
  font-size: .85rem;
  line-height: 1.35;
}
.ss-kb__title strong {
  display: block;
  margin-bottom: 4px;
  color: #fff;
  font: 600 clamp(1.3rem, 6cqi, 2rem)/1.1 "Fraunces", serif;
}
.ss-kb__btn {
  position: absolute;
  top: 12px;
  right: 12px;
  z-index: 2;
  display: none;
  min-height: 44px;
  padding: 0 16px;
  border: 2px solid #fff;
  border-radius: 999px;
  background: #0c0e12;
  color: #fff;
  font: inherit;
  font-size: .88rem;
  font-weight: 600;
  cursor: pointer;
}
.ss-kb__btn[aria-pressed="true"] {
  background: #fff;
  color: #0c0e12;
}
.ss-kb__btn:focus-visible {
  outline: 3px solid #ffd166;
  outline-offset: 3px;
}
@container (min-width: 520px) {
  .ss-kb__view {
    aspect-ratio: 16 / 9;
  }
}
@media (prefers-reduced-motion: no-preference) {
  .ss-kb__btn {
    display: block;
  }
  .ss-kb__slide {
    animation: ss-kb-fade 24s linear infinite backwards;
  }
  .ss-kb__slide:first-child {
    animation-name: ss-kb-first;
  }
  .ss-kb__slide img {
    animation: ss-kb-zoom 24s linear infinite backwards;
  }
  .ss-kb__slide:nth-child(2),
  .ss-kb__slide:nth-child(2) img {
    animation-delay: 8s;
  }
  .ss-kb__slide:nth-child(3),
  .ss-kb__slide:nth-child(3) img {
    animation-delay: 16s;
  }
  .ss-kb:has(.ss-kb__btn[aria-pressed="true"]) .ss-kb__slide,
  .ss-kb:has(.ss-kb__btn[aria-pressed="true"]) .ss-kb__slide img {
    animation-play-state: paused;
  }
}
@keyframes ss-kb-fade {
  0% {
    opacity: 0;
  }
  6%,
  33.3% {
    opacity: 1;
  }
  39%,
  100% {
    opacity: 0;
  }
}
@keyframes ss-kb-first {
  0%,
  33.3% {
    opacity: 1;
  }
  39%,
  100% {
    opacity: 0;
  }
}
@keyframes ss-kb-zoom {
  0% {
    transform: scale(1);
  }
  39%,
  100% {
    transform: scale(1.14);
  }
}

Editorial slideshow with a progress timer

  • animationend
  • aria-live off / polite
  • container queries
A deep blue fjord between grey granite cliffs, seen from a rocky ledge

The last ferry keepers of the west coast

Three crews still run the winter crossings by hand. We spent a week on the night shift.

By Ines Varga, 9 min read

Hover to hold the timer. Use the buttons to stop it or step through.

The timer is the CSS animation itself: the current progress segment fills over 6 seconds, and its animationend event moves to the next story. Following the WAI-ARIA carousel pattern, the rotation button comes first, rotation stops for good once focus enters, and aria-live switches from off to polite when it stops. With reduced motion the fill never animates, so the stories wait for the buttons.

View code
.ss-pe {
  display: grid;
  gap: 14px;
  width: min(100%, 680px);
  padding: 16px;
  box-sizing: border-box;
  border-radius: 18px;
  background: #fbfaf7;
  box-shadow: 0 0 0 1px #e4e0d6, 0 16px 40px rgba(30, 27, 22, .08);
  color: #1e1b16;
  container-type: inline-size;
}
.ss-pe__head {
  display: flex;
  align-items: center;
  gap: 6px;
}
.ss-pe__bars {
  display: flex;
  flex: 1;
  gap: 5px;
  margin: 0 6px;
}
.ss-pe__bar {
  flex: 1;
  height: 4px;
  overflow: hidden;
  border-radius: 4px;
  background: #d8d3c7;
}
.ss-pe__bar i {
  display: block;
  height: 100%;
  background: #1e1b16;
  transform: scaleX(0);
  transform-origin: left;
}
.ss-pe__bar.is-done i,
.ss-pe__bar.is-current i {
  transform: scaleX(1);
}
.ss-pe__btn {
  display: grid;
  flex: none;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 1px solid #cfc9bb;
  border-radius: 50%;
  background: #fff;
  color: #1e1b16;
  cursor: pointer;
}
.ss-pe__btn:hover {
  background: #f1eee6;
}
.ss-pe__btn:focus-visible {
  outline: 3px solid #b45309;
  outline-offset: 2px;
}
.ss-pe__btn svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.ss-pe__icon {
  width: 12px;
  height: 14px;
  box-sizing: border-box;
  border-left: 4px solid currentColor;
  border-right: 4px solid currentColor;
}
.ss-pe.is-stopped .ss-pe__icon {
  width: 0;
  height: 0;
  margin-left: 3px;
  border-left: 12px solid currentColor;
  border-right: 0;
  border-top: 7px solid transparent;
  border-bottom: 7px solid transparent;
}
.ss-pe__slide {
  display: grid;
  gap: 14px;
}
.ss-pe__slide[hidden] {
  display: none;
}
.ss-pe__slide img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 3 / 2;
  object-fit: cover;
  border-radius: 12px;
}
.ss-pe__text h4 {
  margin: 0 0 8px;
  font: 600 clamp(1.3rem, 5cqi, 1.6rem)/1.2 "Fraunces", serif;
}
.ss-pe__text p {
  margin: 0;
  color: #4a4438;
  font-size: .95rem;
  line-height: 1.55;
}
.ss-pe__text .ss-pe__meta {
  margin-top: 12px;
  color: #6b6455;
  font-size: .85rem;
}
@container (min-width: 560px) {
  .ss-pe__slide {
    grid-template-columns: 1.2fr 1fr;
    align-items: center;
    gap: 22px;
  }
}
@media (prefers-reduced-motion: no-preference) {
  .ss-pe__bar.is-current i {
    animation: ss-pe-fill 6s linear forwards;
  }
  .ss-pe.is-stopped .ss-pe__bar.is-current i,
  .ss-pe:hover .ss-pe__bar.is-current i {
    animation-play-state: paused;
  }
  .ss-pe__slide:not([hidden]) {
    animation: ss-pe-in .5s ease;
  }
}
@media (prefers-reduced-motion: reduce) {
  .ss-pe__play {
    display: none;
  }
}
@keyframes ss-pe-fill {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}
@keyframes ss-pe-in {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
}

Full-width hero slideshow

  • visibility transition
  • aria-current
  • APG rotation control

Slow roads along the southern coast

Eight days, four towns, one rental car and no fixed plan.

A canal town between two trains

Spend the layover on the water. Boats leave every 20 minutes.

Walk above the morning cloud

Guided ridge hikes for small groups, May to September.

The slides rotate every 6 seconds until you hover, focus or stop them.

A homepage hero that rotates only when motion is allowed. Inactive slides get visibility: hidden after their fade, so the buttons in them leave the tab order and screen readers skip them. Hover pauses rotation, keyboard focus stops it, and the stop button and dots sit in the text panel where people actually look.

View code
.ss-hr {
  position: relative;
  overflow: hidden;
  background: #10202b;
  color: #fff;
  container-type: inline-size;
}
.ss-hr__slides {
  display: grid;
}
.ss-hr__slide {
  position: relative;
  grid-area: 1 / 1;
  display: grid;
  align-items: end;
  min-height: clamp(440px, 52vw, 560px);
  box-sizing: border-box;
  padding: clamp(16px, 4cqi, 48px);
  padding-bottom: 96px;
  opacity: 0;
  visibility: hidden;
  transition: opacity .8s ease, visibility 0s .8s;
}
.ss-hr__slide.is-active {
  z-index: 1;
  opacity: 1;
  visibility: visible;
  transition: opacity .8s ease;
}
.ss-hr__slide img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.ss-hr__panel {
  position: relative;
  max-width: 460px;
  padding: clamp(18px, 4cqi, 28px);
  border-radius: 16px;
  background: #10202b;
}
.ss-hr__panel h4 {
  margin: 0 0 10px;
  font: 600 clamp(1.5rem, 5cqi, 2.4rem)/1.1 "Fraunces", serif;
}
.ss-hr__panel p {
  margin: 0 0 18px;
  color: #cfdbe3;
  font-size: 1rem;
  line-height: 1.5;
}
.ss-hr__cta {
  min-height: 44px;
  padding: 0 20px;
  border: 0;
  border-radius: 999px;
  background: #ffcf6b;
  color: #10202b;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.ss-hr__cta:hover {
  background: #ffe1a3;
}
.ss-hr__controls {
  position: absolute;
  left: clamp(16px, 4cqi, 48px);
  bottom: 24px;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 2px;
  padding: 4px 8px 4px 4px;
  border-radius: 999px;
  background: #10202b;
}
.ss-hr__rotate,
.ss-hr__dot {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  color: #fff;
  cursor: pointer;
}
.ss-hr__rotate span {
  width: 12px;
  height: 14px;
  box-sizing: border-box;
  border-left: 4px solid currentColor;
  border-right: 4px solid currentColor;
}
.ss-hr.is-stopped .ss-hr__rotate span {
  width: 0;
  height: 0;
  margin-left: 3px;
  border-left: 12px solid currentColor;
  border-right: 0;
  border-top: 7px solid transparent;
  border-bottom: 7px solid transparent;
}
.ss-hr__dot {
  width: 32px;
}
.ss-hr__dot::before {
  content: "";
  width: 10px;
  height: 10px;
  border-radius: 999px;
  background: #8fa3b1;
}
.ss-hr__dot[aria-current="true"]::before {
  width: 22px;
  background: #ffcf6b;
}
.ss-hr__rotate:focus-visible,
.ss-hr__dot:focus-visible,
.ss-hr__cta:focus-visible {
  outline: 3px solid #ffcf6b;
  outline-offset: 2px;
}
.ss-hr__cta:focus-visible {
  outline-color: #fff;
}
@media (prefers-reduced-motion: no-preference) {
  .ss-hr__panel {
    transition: transform .7s .15s cubic-bezier(.2, .8, .2, 1);
  }
  .ss-hr__slide:not(.is-active) .ss-hr__panel {
    transform: translateY(24px);
  }
}

Slideshows with a small script

A few lines of JavaScript add what CSS cannot do alone: announcing "Slide 2 of 5" to screen readers, reacting to arrow keys, and running the View Transitions API. CSS still does all of the animation.

Direction-aware slide with live announcements

  • data-dir
  • aria-live="polite"
  • @keyframes
A narrow brick alley with ivy and fire escapes, tall towers at its far end
Ivy Lane, Old Market
Old gabled houses along a canal with small white boats moored at the water
Boatmen Quay
A downtown skyline of tall towers at sunrise beside a wide lake
Observation deck, floor 94
Granite cliffs above a pine forest and a calm river with a sandy bank
Sandbar picnic spot
A tall waterfall dropping into a mossy green gorge surrounded by pine forest
Upper falls viewpoint

Slide 1 of 5

Press Next a few times, then Previous.

Next slides the new photo in from the right and Previous from the left, chosen by a data-dir attribute the script sets before swapping classes. A visible status line with aria-live="polite" announces "Slide 2 of 5" after each change. The script only moves classes and text: the motion is two keyframes per direction, all inside a no-preference query.

View code
.ss-sb {
  display: grid;
  gap: 10px;
  width: min(100%, 600px);
  color: #1f2430;
}
.ss-sb__view {
  position: relative;
  display: grid;
  overflow: hidden;
  border-radius: 20px;
  background: #1f2430;
}
.ss-sb__slide {
  position: relative;
  grid-area: 1 / 1;
  margin: 0;
  visibility: hidden;
}
.ss-sb__slide.is-active,
.ss-sb__slide.is-leaving {
  visibility: visible;
}
.ss-sb__slide.is-active {
  z-index: 1;
}
.ss-sb__slide img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 10;
  object-fit: cover;
}
.ss-sb__slide figcaption {
  position: absolute;
  top: 14px;
  left: 14px;
  max-width: calc(100% - 28px);
  box-sizing: border-box;
  padding: 6px 12px;
  border-radius: 999px;
  background: #fff;
  font-size: .86rem;
  font-weight: 600;
}
.ss-sb__btn {
  position: absolute;
  top: 50%;
  z-index: 2;
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #fff;
  color: #1f2430;
  box-shadow: 0 6px 18px rgba(0, 0, 0, .25);
  cursor: pointer;
  transform: translateY(-50%);
}
.ss-sb__btn--prev {
  left: 12px;
}
.ss-sb__btn--next {
  right: 12px;
}
.ss-sb__btn:hover {
  background: #eef0f5;
}
.ss-sb__btn:focus-visible {
  outline: 3px solid #f59e0b;
  outline-offset: 3px;
}
.ss-sb__btn svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.ss-sb__status {
  margin: 0;
  color: #4b5263;
  font: .9rem/1.4 ui-monospace, Consolas, monospace;
  text-align: center;
}
@media (prefers-reduced-motion: no-preference) {
  .ss-sb[data-dir="next"] .ss-sb__slide.is-active {
    animation: ss-sb-from-right .55s cubic-bezier(.2, .7, .2, 1);
  }
  .ss-sb[data-dir="next"] .ss-sb__slide.is-leaving {
    animation: ss-sb-to-left .55s cubic-bezier(.2, .7, .2, 1) forwards;
  }
  .ss-sb[data-dir="prev"] .ss-sb__slide.is-active {
    animation: ss-sb-from-left .55s cubic-bezier(.2, .7, .2, 1);
  }
  .ss-sb[data-dir="prev"] .ss-sb__slide.is-leaving {
    animation: ss-sb-to-right .55s cubic-bezier(.2, .7, .2, 1) forwards;
  }
}
@keyframes ss-sb-from-right {
  from {
    transform: translateX(100%);
  }
}
@keyframes ss-sb-from-left {
  from {
    transform: translateX(-100%);
  }
}
@keyframes ss-sb-to-left {
  to {
    transform: translateX(-30%);
    opacity: .3;
  }
}
@keyframes ss-sb-to-right {
  to {
    transform: translateX(30%);
    opacity: .3;
  }
}

View Transitions API slideshow

  • startViewTransition()
  • view-transition-name
  • feature detection
An orange jellyfish with long trailing tentacles in deep blue water

Drift 1 / 4

Press Next and watch the photo and the caption move separately.

The script swaps the hidden attribute between figures, and when document.startViewTransition exists and motion is allowed it wraps that swap in a view transition. The photo and the caption carry their own view-transition-name, so the browser animates them as two layers with the keyframes defined here. Browsers without the API, or visitors with reduced motion, get the same swap with no animation.

View code
.ss-vt {
  display: grid;
  gap: 14px;
  width: min(100%, 560px);
  color: #102a43;
}
.ss-vt__frame {
  overflow: hidden;
  border-radius: 4px;
  background: #102a43;
  box-shadow: 0 0 0 8px #fff, 0 0 0 9px #c7d2de, 0 22px 50px rgba(16, 42, 67, .2);
  view-transition-name: ss-vt-frame;
}
.ss-vt__slide {
  margin: 0;
}
.ss-vt__slide[hidden] {
  display: none;
}
.ss-vt__slide img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
.ss-vt__bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-top: 6px;
}
.ss-vt__cap {
  flex: 1 1 140px;
  margin: 0;
  font-size: .9rem;
  view-transition-name: ss-vt-cap;
}
.ss-vt__cap strong {
  display: block;
  font: italic 600 1.3rem/1.2 "Fraunces", serif;
}
.ss-vt__cap span {
  color: #486581;
  font-family: ui-monospace, Consolas, monospace;
}
.ss-vt__nav {
  display: flex;
  gap: 8px;
}
.ss-vt__btn {
  min-height: 44px;
  padding: 0 18px;
  border: 2px solid #102a43;
  border-radius: 10px;
  background: #fff;
  color: #102a43;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.ss-vt__btn--main {
  background: #102a43;
  color: #fff;
}
.ss-vt__btn:focus-visible {
  outline: 3px solid #e3693a;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  ::view-transition-old(ss-vt-frame) {
    animation: ss-vt-out .3s ease-in both;
  }
  ::view-transition-new(ss-vt-frame) {
    animation: ss-vt-in .5s cubic-bezier(.2, .8, .2, 1) .1s both;
  }
  ::view-transition-old(ss-vt-cap) {
    animation: ss-vt-cap-out .25s ease-in both;
  }
  ::view-transition-new(ss-vt-cap) {
    animation: ss-vt-cap-in .4s ease-out .2s both;
  }
}
@keyframes ss-vt-out {
  to {
    opacity: 0;
    transform: scale(.94);
  }
}
@keyframes ss-vt-in {
  from {
    opacity: 0;
    transform: scale(1.06);
  }
}
@keyframes ss-vt-cap-out {
  to {
    opacity: 0;
    transform: translateY(-10px);
  }
}
@keyframes ss-vt-cap-in {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
}

Testimonial slideshow with @starting-style

  • @starting-style
  • transition-behavior
  • hidden attribute

We replaced a slider plugin with a few lines of CSS. The homepage got lighter and nobody missed the old one.

Mara OduyaFront-end lead, Fieldline Studio

1 of 4

Press the arrows to change the quote.

The script only toggles the hidden attribute. @starting-style gives the newly shown quote a starting opacity and offset, and transition-behavior: allow-discrete keeps the leaving quote on screen while it fades, even though hidden switches it to display: none. Both quotes share one grid cell, so the card does not jump while they overlap.

View code
.ss-ts {
  display: grid;
  gap: 18px;
  width: min(100%, 560px);
  padding: clamp(22px, 5vw, 36px);
  box-sizing: border-box;
  border-radius: 24px;
  background: #fdf4e7;
  color: #3b2410;
}
.ss-ts__quotes {
  display: grid;
}
.ss-ts__quote {
  grid-area: 1 / 1;
  margin: 0;
}
.ss-ts__quote[hidden] {
  display: none;
}
.ss-ts__quote blockquote {
  margin: 0;
}
.ss-ts__quote blockquote p {
  margin: 0;
  font: 500 clamp(1.2rem, 4vw, 1.5rem)/1.4 "Fraunces", serif;
}
.ss-ts__quote blockquote p::before {
  content: "\201C";
}
.ss-ts__quote blockquote p::after {
  content: "\201D";
}
.ss-ts__quote figcaption {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 18px;
  color: #6e4a2a;
  font-size: .88rem;
  line-height: 1.35;
}
.ss-ts__quote figcaption strong {
  display: block;
  color: #3b2410;
  font-size: .98rem;
}
.ss-ts__avatar {
  display: grid;
  flex: none;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: #c2571a;
  color: #fff;
  font-size: .85rem;
  font-weight: 700;
}
.ss-ts__quote:nth-child(2) .ss-ts__avatar {
  background: #2f6f5e;
}
.ss-ts__quote:nth-child(3) .ss-ts__avatar {
  background: #5b4bb7;
}
.ss-ts__quote:nth-child(4) .ss-ts__avatar {
  background: #3b2410;
}
.ss-ts__nav {
  display: flex;
  align-items: center;
  gap: 12px;
  padding-top: 16px;
  border-top: 1px solid #ecd6b8;
}
.ss-ts__count {
  margin: 0;
  color: #6e4a2a;
  font-size: .88rem;
  font-weight: 600;
}
.ss-ts__btn {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 2px solid #3b2410;
  border-radius: 50%;
  background: none;
  color: #3b2410;
  cursor: pointer;
}
.ss-ts__btn:hover {
  background: #3b2410;
  color: #fdf4e7;
}
.ss-ts__btn:focus-visible {
  outline: 3px solid #c2571a;
  outline-offset: 3px;
}
.ss-ts__btn svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
@media (prefers-reduced-motion: no-preference) {
  .ss-ts__quote {
    transition: opacity .45s ease, translate .45s ease, display .45s allow-discrete;
  }
  .ss-ts__quote[hidden] {
    opacity: 0;
    translate: 0 -12px;
  }
  @starting-style {
    .ss-ts__quote {
      opacity: 0;
      translate: 0 14px;
    }
  }
}

Other slideshow formats

Three layouts that still show one image at a time: a before and after comparison, a rotating 3D cube, and a stack of photo prints.

Before and after comparison slider

  • input type="range"
  • clip-path
  • custom property
Old brick and white houses along a canal, with two small boats moored by a wooden jetty

Drag across the photo, or tab to it and use the arrow keys.

A transparent range input covers the photo, so dragging, clicking and the arrow keys all come from a native control with a real label. A two-line script copies its value into --ss-ba-pos, and clip-path: inset() trims the raw version on top to that width. aria-valuetext reads the split as words instead of a bare number.

View code
.ss-ba {
  display: grid;
  gap: 12px;
  width: min(100%, 600px);
  color: #1c2530;
}
.ss-ba__frame {
  --ss-ba-pos: 50%;
  position: relative;
  overflow: hidden;
  border-radius: 16px;
  background: #1c2530;
}
.ss-ba__img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
.ss-ba__img--raw {
  position: absolute;
  inset: 0;
  height: 100%;
  filter: saturate(.25) contrast(.82) brightness(1.06);
  clip-path: inset(0 calc(100% - var(--ss-ba-pos)) 0 0);
}
.ss-ba__tag {
  position: absolute;
  top: 12px;
  left: 12px;
  padding: 4px 10px;
  border-radius: 6px;
  background: #1c2530;
  color: #fff;
  font: .78rem/1.4 ui-monospace, Consolas, monospace;
  pointer-events: none;
}
.ss-ba__tag--right {
  left: auto;
  right: 12px;
  background: #fff;
  color: #1c2530;
}
.ss-ba__handle {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--ss-ba-pos);
  width: 3px;
  margin-left: -1.5px;
  background: #fff;
  pointer-events: none;
}
.ss-ba__handle::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 40px;
  height: 40px;
  box-sizing: border-box;
  border: 3px solid #fff;
  border-radius: 50%;
  background: #1c2530;
  transform: translate(-50%, -50%);
}
.ss-ba__range {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: ew-resize;
}
.ss-ba__frame:has(.ss-ba__range:focus-visible) .ss-ba__handle::after {
  outline: 3px solid #f59e0b;
  outline-offset: 3px;
}
.ss-ba__label {
  color: #3d4a5a;
  font-size: .9rem;
  text-align: center;
}

Rotating 3D cube slideshow

  • preserve-3d
  • translateZ(50cqi)
  • rotateY
A tall waterfall dropping into a mossy green gorge surrounded by pine forest
Spring

Spring, 1 of 4

Press the arrows to turn the cube.

Four faces sit on the sides of a cube, each pushed out by half the box width with translateZ(50cqi), so the cube stays square at any size. The script only adds or subtracts 90 degrees from a custom property, which lets the cube keep turning the same way instead of rewinding after the last face. With reduced motion it switches faces instantly.

View code
.ss-cb {
  display: grid;
  gap: 18px;
  width: min(100%, 460px);
  color: #1f2a1f;
}
.ss-cb__box {
  position: relative;
  width: 82%;
  margin: 16px auto 0;
  aspect-ratio: 4 / 3;
  container-type: inline-size;
  perspective: 1100px;
}
.ss-cb__cube {
  --ss-cb-turn: 0deg;
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform: translateZ(-50cqi) rotateY(var(--ss-cb-turn));
}
.ss-cb__face {
  position: absolute;
  inset: 0;
  margin: 0;
  overflow: hidden;
  border-radius: 6px;
  backface-visibility: hidden;
  background: #1f2a1f;
}
.ss-cb__face:nth-child(1) {
  transform: rotateY(0deg) translateZ(50cqi);
}
.ss-cb__face:nth-child(2) {
  transform: rotateY(90deg) translateZ(50cqi);
}
.ss-cb__face:nth-child(3) {
  transform: rotateY(180deg) translateZ(50cqi);
}
.ss-cb__face:nth-child(4) {
  transform: rotateY(270deg) translateZ(50cqi);
}
.ss-cb__face img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.ss-cb__face figcaption {
  position: absolute;
  left: 10px;
  bottom: 10px;
  padding: 4px 12px;
  border-radius: 999px;
  background: #fff;
  font: italic 600 1rem/1.4 "Fraunces", serif;
}
.ss-cb__nav {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14px;
}
.ss-cb__status {
  min-width: 9em;
  margin: 0;
  font-size: .92rem;
  font-weight: 600;
  text-align: center;
}
.ss-cb__btn {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 12px;
  background: #1f2a1f;
  color: #fff;
  cursor: pointer;
}
.ss-cb__btn:hover {
  background: #3a4d3a;
}
.ss-cb__btn:focus-visible {
  outline: 3px solid #d97706;
  outline-offset: 3px;
}
.ss-cb__btn svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}
@media (prefers-reduced-motion: no-preference) {
  .ss-cb__cube {
    transition: transform .9s cubic-bezier(.65, 0, .35, 1);
  }
}

Photo print stack slideshow

  • z-index order
  • transition
  • aria-live
A single-lane road winding through a green valley below steep grassy cliffs
The pass, day one

Print 1 of 4

Press Next print to send the top print to the back.

Each print reads its place in the pile from a data-pos attribute, which sets its z-index and a slightly different tilt. Next print lifts the top card and fades it, then moves it to the bottom of the pile, so the slideshow loops with no clone slides. Only the top print is exposed to screen readers.

View code
.ss-ps {
  display: grid;
  justify-items: center;
  gap: 22px;
  width: min(100%, 420px);
  padding: 18px 0 4px;
  color: #2d2a26;
}
.ss-ps__pile {
  display: grid;
  width: min(78%, 280px);
}
.ss-ps__print {
  grid-area: 1 / 1;
  margin: 0;
  padding: 10px 10px 0;
  border-radius: 3px;
  background: #fffdf8;
  box-shadow: 0 1px 2px rgba(45, 42, 38, .2), 0 12px 28px rgba(45, 42, 38, .16);
}
.ss-ps__print img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 1;
  object-fit: cover;
}
.ss-ps__print figcaption {
  padding: 12px 4px 14px;
  font: italic 500 1.05rem/1.3 "Fraunces", serif;
  text-align: center;
}
.ss-ps__print[data-pos="0"] {
  z-index: 4;
  transform: rotate(-2deg);
}
.ss-ps__print[data-pos="1"] {
  z-index: 3;
  transform: rotate(4deg) translate(6px, 2px);
}
.ss-ps__print[data-pos="2"] {
  z-index: 2;
  transform: rotate(-6deg) translate(-6px, 4px);
}
.ss-ps__print[data-pos="3"] {
  z-index: 1;
  transform: rotate(7deg) translate(4px, 6px);
}
.ss-ps__print.is-lifting {
  opacity: 0;
  transform: translate(0, -14%) rotate(8deg) scale(.96);
}
.ss-ps__nav {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 8px 16px;
}
.ss-ps__btn {
  min-height: 44px;
  padding: 0 22px;
  border: 0;
  border-radius: 999px;
  background: #2d2a26;
  color: #fffdf8;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.ss-ps__btn:hover {
  background: #4a453e;
}
.ss-ps__btn:focus-visible {
  outline: 3px solid #d9480f;
  outline-offset: 3px;
}
.ss-ps__status {
  margin: 0;
  color: #5c564d;
  font-size: .9rem;
}
@media (prefers-reduced-motion: no-preference) {
  .ss-ps__print {
    transition: transform .38s cubic-bezier(.3, .7, .3, 1), opacity .38s ease;
  }
}

What Is a CSS Slideshow?

The names vary. Developers say image slider, or CSS carousel, and they all point at the same pattern. Keyframes, scroll snapping, or state selectors do the switching between slides, and no JavaScript is involved in the core behavior.

Underneath, it’s a box with a fixed width, an aspect-ratio, and overflow hidden, so only one slide shows at a time. The slides are images or content blocks. For fades they stack in one CSS grid cell, and for sliding they sit in a row with flexbox.

Something still has to decide which slide is visible. That can be a keyframes animation, a checked radio input, a :target anchor, or just the visitor scrolling.

CSS alone can produce autoplay, click navigation, touch swipe, and position indicators, but no single technique gets all four without giving something up.

A script that adds an “active” class on a timer is a JavaScript slideshow, even when CSS performs the fade.

A JavaScript carousel adds slide state, events, and play and pause controls, at the cost of a script to load and maintain. A CSS slideshow gives up those features and ships no script at all.

Which CSS Slideshow Method Fits Your Project?

Keyframes suit autoplay banners, and radio buttons suit small click-through galleries. Scroll snap is the one for swiping on touch screens, and the native scroll-marker carousel just adds buttons and dots on top of it.

MethodGives youDoes not give youBest for
KeyframesAutoplay, looping, pause on hoverClick navigation, swipeHero banners, background rotators
Radio buttonsClick navigation, dot indicatorsAutoplay, swipeSmall click-through galleries
Scroll snapTouch swipe, trackpad scroll, snappingAutoplay, indicatorsMobile galleries
Native carouselScroll buttons, dot markers, snappingAutoplayEnhancing a scroll snap base

The biggest gap between the last two rows is cross-browser compatibility. Scroll snap has been around for years. ::scroll-button() and ::scroll-marker only first shipped in Chrome 135 and Edge 135 in April 2025, according to Chrome for Developers and the web-platform-dx tracker.

Tutorials don’t agree about keyframes. Code Boxx steers readers away from keyframe autoplay and calls it more complicated than JavaScript. CSS Play’s April 2026 slideshow goes the other way, building autoplay from keyframes and exposing image count and timings as CSS variables.

Both positions hold up, depending on the job. A fixed set of slides stays simple. Once the slide count changes often, the effort grows.

For a static homepage banner, keyframes is the lowest-maintenance pick, and it’s the one I’d reach for.

How to Build an Autoplay CSS Slideshow With Keyframes

The loop runs because animation-iteration-count is set to infinite. Every slide sits in the same grid cell, every slide gets the same fade keyframes, and each slide’s animation-delay is offset by its share of the cycle.

A refresher on keyframe rules covers the percentage syntax used below.

  1. Wrap the slides in a container with a fixed aspect-ratio and overflow: hidden.
  2. Place every slide in the same grid cell (grid-area: 1 / 1) and set opacity: 0 on each.
  3. Set the cycle length to the slide count times the seconds per slide (5 slides at 5 seconds gives 25 seconds).
  4. Write one @keyframes rule that fades a slide in, holds it, and fades it out.
  5. Apply that animation to every slide with animation-duration set to the full cycle, animation-iteration-count set to infinite, and animation-fill-mode set to backwards so delayed slides sit at their 0% keyframe before they start.
  6. Give each slide an animation-delay of its index times 5 seconds (0s, 5s, 10s, 15s, 20s).

Calculating keyframe percentages and delays

Take 5 slides at 5 seconds each. The cycle is 25 seconds, and each slide owns 20% of it.

  • At 0% the opacity is 0 and the slide starts hidden
  • At 4% it reaches opacity 1, a 1-second fade-in (1 of 25 seconds)
  • At 20% the opacity is still 1, so the slide holds until second 5
  • At 24% it drops back to opacity 0, a 1-second fade-out that overlaps the next slide’s fade-in
  • At 100% it stays at 0 and waits for the next cycle

Each slide stays fully visible for 4 seconds (4% to 20%), and the overlap produces a 1-second crossfade.

Real demos run slower. CSS Play’s April 2026 slideshow uses 7 images at 1.5 seconds of movement and 4.5 seconds of stillness each, a 42-second cycle.

One flaw shows up on page load. Slide one starts at opacity 0, so the container is empty for the first second. A separate keyframe rule for slide one that starts at opacity 1 removes the gap.

Fade versus horizontal slide

Fades animate opacity on slides stacked in one grid cell, which means no slide width math. The keyframes move opacity from 0 to 1 and back, and the percentages need recalculating whenever the slide count changes.

Sliding works differently. The slides sit in a flex row inside a track, and translateX moves the track. Hold keyframes repeat the same value for 16% of the cycle, then the track moves for 4% (4 seconds still, 1 second moving, in a 25-second cycle). Repeating the first slide at the end of the row keeps the loop moving forward instead of rewinding.

A zoom effect is just a fade with a slow transform from scale(1) to scale(1.08) across the hold. Put easing inside the keyframes, because animation-timing-function applies to the segment that starts at each keyframe.

Pausing on hover

animation-play-state is Baseline widely available and has worked across browsers since September 2015 (MDN Web Docs), and a paused animation resumes where it stopped instead of restarting.

Setting animation-play-state: paused on .slideshow:hover .slide and .slideshow:focus-within .slide freezes the slideshow for mouse and keyboard users.

It has limits. A pause mid-fade leaves two half-transparent slides on screen, and touch screens have no hover at all, so treat this as a convenience and not an accessibility feature.

How to Build a CSS Slideshow With Radio Buttons and Labels

The browser keeps track of which radio is checked, so no script is needed. One radio input per slide goes before the slides, labels act as the clickable dots, and :checked plus a sibling combinator shows the slide that follows the checked input.

Source order decides whether this works, because a sibling selector only reaches elements that come after the input.

The catch is that there’s no autoplay and no touch swipe, and every slide you add means another input and another label.

Radio inputs and the sibling selector

All the radio inputs come first, sharing one name attribute, with the first one checked. The labels or dots go next, and the slide track goes last.

The :checked pseudo-class is Baseline widely available and has worked across browsers since July 2015 (MDN Web Docs), so the pattern runs in any current browser. A rule like #s2:checked ~ .track { transform: translateX(-100%); } moves the track to the second slide.

Hide the inputs visually instead of using display: none. A display: none input leaves the tab order, and keyboard users lose the controls.

Dot and arrow controls

For dots, a label with for=”s3″ checks the third radio when clicked, and a rule such as #s3:checked ~ .dots label[for=”s3″] styles the active dot.

Arrows take more work. Each slide needs two labels, one pointing to the previous radio and one to the next, shown only on the active slide. The markup grows with every slide you add.

:target and :has() variants

:target swaps the radio inputs for anchor links. Each slide has an id and the dots are links such as href=”#slide2″. Each click adds a history entry and scrolls the browser to that slide, so the Back button steps through slides and the page can jump.

:has() lets the container react to the checked input. A rule like .slideshow:has(#s2:checked) styles the parent from the checked state, so inputs can sit anywhere inside the container. :has() has worked in all major browsers since December 2023 and is now Baseline widely available (MDN Web Docs).

Anchor :has() to the slideshow container, not body or :root. MDN warns that broad anchors force the browser to re-check the condition after every DOM change.

How to Build a Swipeable Slideshow With CSS Scroll Snap

Set scroll-snap-type: x mandatory on a horizontally scrolling container and scroll-snap-align: center on each slide. Swipe, trackpad scrolling, and snapping to the nearest slide are then handled by the browser.

Scroll snap is defined in the CSS Scroll Snap Module Level 1, and scroll-snap-type is Baseline widely available and has worked across browsers since April 2022 (MDN Web Docs).

The container needs display: flex, overflow-x: auto, and scroll-snap-type: x mandatory. Each slide gets flex: 0 0 100% and scroll-snap-align: center. Adding scroll-snap-stop: always keeps a hard flick from jumping past a slide.

Use mandatory, not proximity. With proximity the browser decides whether to snap (MDN lists it as the default strictness), so a slide can come to rest between two positions.

Dots and arrows can be plain anchor links such as href=”#slide3″, which scroll the container to the target slide. Adding scroll-behavior: smooth animates the jump.

Scroll snap reacts only to scrolling, so autoplay is outside its scope. Built-in indicators and arrow buttons come from the native carousel pseudo-elements covered in the next section.

Does the Native CSS Carousel Replace the Older Methods?

Not yet. The native carousel gives a scroll snap row its buttons and dot markers without JavaScript, but it adds no autoplay and is still limited to Chromium browsers in the web-platform-dx tracker as of October 2026.

Both features come from the CSS Overflow Module Level 5. Scroll snap stays the base layer, and the markers and buttons sit on top of it.

Scroll buttons and scroll markers

::scroll-button(left) and ::scroll-button(right) attach to the scroll container and generate browser-managed buttons. Each press scrolls 85% of the scroll area (Chrome for Developers, 2025).

The content property takes a visible glyph and, after a slash, a text alternative for screen readers.

Markers work from the other direction. Setting scroll-marker-group: after on the container, plus ::scroll-marker on each slide, generates one dot per slide, and the :target-current state styles the dot for the slide in view.

A newer property, scroll-target-group, turns a list of anchor links into scroll markers, so the dots can live in your own HTML.

Wrap all of it in @supports (scroll-marker-group: after). Browsers without support then keep a plain scroll snap carousel.

Linking slide effects to scroll position

Scroll-driven animations tie an animation’s progress to scroll position instead of time.

animation-timeline: scroll() follows a container’s scroll offset. animation-timeline: view() follows an element’s position inside its scroller, and animation-range picks which part of that travel drives the keyframes.

On a scroll snap row, a view() timeline can fade or scale each slide as it enters and leaves the frame. The slides animate with the swipe instead of a clock.

Chrome 115, Edge 115, and Safari 26 ship the feature, and Firefox 159 (October 27, 2026) turns it on by default, according to MDN’s browser-compat-data. Guard the rules with @supports (animation-timeline: scroll()) so slides stay fully visible everywhere else.

Adoption of the markers is still tiny, about 0.13% of page loads, against about 20% for scroll snap (Chrome Platform Status, via web-platform-dx, October 2026). Firefox and Safari list neither ::scroll-marker (web-platform-dx tracker) nor ::scroll-button() (Chrome for Developers support table), as of October 2026.

Some 2026 tutorials report Safari support for the markers. The trackers do not, so check Can I use for the current state before launch.

How to Make a CSS Slideshow Responsive

Give the container a fixed aspect-ratio and a fluid width, then let the images fill it with object-fit: cover. The container never resizes when an image arrives.

A slideshow follows the same responsive design rules as the rest of the page: fluid widths, ratios instead of pixel heights, and breakpoints only where the content needs them.

Sizing slides and images

On the container, use width: 100%, a max-width, and one aspect ratio for every slide (16 / 9 for banners, 3 / 2 for photos).

CSS Play’s April 2026 slideshow caps the container at 750px, sets max-width to 95%, and reads its ratio from a custom property (3 / 2).

Images get width: 100%, height: 100%, and object-fit: cover. object-position shifts the crop toward the subject when a portrait photo sits in a landscape frame.

Sizing the track in cqw units, as the same demo does, scales slides with the container instead of the screen. Viewport units size the slideshow to the screen for a full-screen version, but a mobile browser toolbar can make 100vh taller than the visible area, so test on a real phone.

Missing image dimensions are among the layout shift causes web.dev lists, and a good Cumulative Layout Shift score is 0.1 or less (web.dev, Optimize Cumulative Layout Shift). Its fix is width and height attributes on every image, or a reserved space through aspect-ratio.

Lazy loading slide images

Lazy loading does nothing for a stacked fade slideshow, though a scroll snap row does benefit from it.

Browsers load a lazy image once it comes within a threshold distance of the viewport. Stacked slides all sit inside the viewport, so every one of them loads at once, opacity 0 or not.

In a scroll snap row, slides beyond that threshold wait, which cuts the weight of the first page load.

  • Leave loading=”lazy” off the first slide. In a hero banner it is usually the Largest Contentful Paint element, and Lighthouse flags a lazily loaded one
  • Add loading=”lazy” to later slides only in a scroll snap row
  • Keep width and height on every img, even inside an aspect-ratio container

How to Make a CSS Slideshow Accessible

Start with prefers-reduced-motion. Autoplay also needs a pause control, and hidden slides shouldn’t stay in the tab order. WCAG 2.2 success criterion 2.2.2 (Level A) requires a pause, stop, or hide mechanism for moving content that starts automatically, lasts more than five seconds, and sits beside other content (W3C).

Inside @media (prefers-reduced-motion: reduce), set animation: none on the slides and show the first one, or swap the layout for a scroll snap row the visitor controls.

For the pause control, place a checkbox before the slideshow and label it “Pause slideshow”, then add #pause:checked ~ .slideshow .slide { animation-play-state: paused; }. A native checkbox reports its state to screen readers without any script.

Slides at opacity 0 stay focusable, so links inside hidden slides still take focus. Animating visibility alongside opacity removes them. It’s an easy thing to miss until you tab through the page.

The W3C carousel pattern also stops rotation when keyboard focus enters the carousel, and it asks for a visible rotation control.

The roles and labels come from ARIA. That pattern sets aria-roledescription=”carousel” on the container, aria-roledescription=”slide” on each slide with a label such as “2 of 6”, and aria-live off while rotation runs.

Plain HTML carries those attributes. CSS cannot switch aria-live to polite when the visitor stops the rotation, which the pattern requires, so a CSS slideshow with autoplay stays short of the full pattern.

Native markers cover part of the gap. Chrome for Developers says keyboard navigation on markers behaves like a focusgroup and screen readers report them like a tablist, which matches the tabbed carousel variant in the W3C pattern. Radio-button dots expose a form control instead.

Caption text over photos needs enough color contrast, and a solid or semi-transparent panel behind the text is more reliable than a shadow. Every image still needs alt text that describes the slide.

Check the result in the Chrome DevTools accessibility tree, run a Lighthouse accessibility audit, and tab through the page once with the reduced motion setting on.

When a CSS Slideshow Is the Wrong Choice

Skip it when slides come from a CMS, or when you need slide-change events, analytics, or the full W3C pattern with live-region updates. Those jobs call for JavaScript.

A static banner of 3 slides needs no library. A product carousel that editors change weekly turns CSS-only into a maintenance chore.

On the plus side, there’s no script to load, update, or debug, and it still works with JavaScript disabled. Editors can read the plain HTML and CSS, which counts for more than it sounds.

The downsides are real, though. There are no slide-change events, so analytics cannot see which slide a visitor viewed, and no live-region updates when rotation stops. Scroll snap rows stop at the last slide instead of looping, and desktop users get no click-and-drag with a mouse.

Swiper’s own comparison page puts Splide at about 12 KB gzipped and Swiper at about 25 to 45 KB depending on modules. The same page describes Splide as built with ARIA support from the start and Swiper’s accessibility features as an opt-in module.

A site already on Bootstrap can use its Bootstrap carousel, which the docs describe as built with CSS 3D transforms and a bit of JavaScript. Those docs recommend avoiding autoplaying carousels for accessibility reasons and tie the animation to prefers-reduced-motion.

Which library to pick depends on the downside that hurts most. Events and analytics point to Swiper, and ARIA by default points to Splide. If the stack is already Bootstrap, its own carousel is the obvious choice.

CSS Slideshow FAQ

Can a CSS slideshow include captions and thumbnails?

Captions are easy. A figcaption inside each slide fades or moves with it. Thumbnails are small images placed inside the labels of the radio button method, or inside anchor links in a scroll snap row.

Thumbnails need one control per slide, so the markup grows with the slide count.

How can a CSS slideshow run vertically?

Swap the horizontal axis for the vertical one. Keyframes animate translateY on a column track, and scroll snap uses scroll-snap-type: y mandatory on a container with a fixed height and overflow-y: auto.

Each slide then takes the container’s height instead of its width.

How many slides can a keyframes slideshow hold?

There’s no fixed limit, but the cycle grows with every slide. At 5 seconds per slide, 12 slides make a 60-second cycle and give each slide 8.33% of the keyframes, so the percentages stop being round numbers.

Past a dozen slides, a scroll snap row is easier to maintain.

Are images in a CSS slideshow indexed by Google?

When they are img elements, yes. Google Search Central says it finds images in the src of img elements and does not index CSS images, so slides set as background-image stay out of Image Search.

Keep content images in img tags with descriptive alt text.

Is a 3D effect possible in a CSS slideshow?

It is. Set perspective on the container, give the slide track transform-style: preserve-3d, and turn it with rotateY or rotateX. Keyframes or checked radio inputs trigger the rotation.

Bootstrap’s carousel is also built with CSS 3D transforms, according to its documentation.

What to Check Before Publishing a CSS Slideshow

Check reduced motion first, then keyboard use, then a slow connection, and browser support last. That order is deliberate.

The two accessibility checks lead because a failure there can force markup changes. Connection speed and browser gaps need only adjusted values and fallbacks.

Browser support is the check most likely to expire.

If Firefox and Safari ship ::scroll-button() and ::scroll-marker(), the native carousel can replace radio buttons for click navigation, and the browser check shrinks to older versions.

Chrome for Developers still lists both features as unsupported in Firefox and Safari (checked October 2026).