GODRICH

9 CSS Only Carousel Slider Recipes to Copy-Paste

A CSS only carousel slider is a row of photos or cards that steps through one at a time in the same frame, built nine ways here — scroll snap to radio hack,

Auto-plays · click a tile to jump to its section · all nine in one zip

The order isn't a popularity ranking — it follows how a carousel actually gets built. 01 is the plainest version, a strip you can just drag. 02 through 04 add ways to jump straight to one slide — arrows, dots, thumbnails — while staying script-free. 05 and 06 change the shape of the same idea, one turning the axis vertical, the other tilting every card in 3D. 07 through 09 close with three that don't need anyone to touch them at all: a card stack that swipes itself, a zooming hero, and a bar that reads the scroll position directly.

01Peek-next scroll-snap

Each card leaves enough side padding that the next slide peeks into view at the edge of the frame, and scroll-padding-left keeps the first card snapped flush against the left side instead of drifting under the container's own edge. It suits a shop's product row or a short row of recommended reads, where a visitor should sense there's more without an arrow having to say so.

scroll-paddingscroll-snap-alignJS 0줄
.pn__viewport {
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-padding-left: 16px;
}
.pn__track { display: flex; width: max-content; }
.pn__slide {
  flex: 0 0 200px;
  scroll-snap-align: start;
  margin-right: 12px;
}

02Radio + arrow slider

Three hidden radio inputs and a pair of arrow-shaped labels are the whole mechanism here — checking one radio doesn't just toggle a class on its own slide — it shifts the entire track sideways by a third through a sibling selector. It's the oldest no-scroll, no-JS way to build a carousel, and it still suits a banner or a notice card someone flips through by hand only.

:checked형제결합자label for
.ra__viewport { overflow: hidden; }
.ra__track {
  display: flex;
  width: 300%;
  transition: transform 300ms;
}
#ra-1:checked ~ .ra__viewport .ra__track { transform: translateX(0%); }
#ra-2:checked ~ .ra__viewport .ra__track { transform: translateX(-33.3333%); }
#ra-3:checked ~ .ra__viewport .ra__track { transform: translateX(-66.6667%); }

03Dot nav via :target

Clicking a dot is a real link click, nothing more — the address bar's # fragment changes and the browser scrolls that slide into view inside the snap track entirely on its own. :target only steps in afterward, to color whichever dot matches the slide now showing, which is why the back button correctly returns to the previous slide.

:targetscroll-margin앵커 링크
.dt__viewport {
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}
.dt__slide { scroll-snap-align: start; }
.dt:has(#dt-s1:target) .dt__dot--1,
.dt:has(#dt-s2:target) .dt__dot--2,
.dt:has(#dt-s3:target) .dt__dot--3 {
  background: var(--color);
  transform: scale(1.4);
}

04Thumbnail-synced snap

A real strip of square thumbnail images sits below the main photo, each one a plain anchor pointing at its matching slide, so the same :has() trick from the dots above now outlines a thumbnail instead of coloring a dot. Because these are actual image elements rather than generated markers, each one can carry its own alt text for a product-detail gallery.

:target형제결합자scroll-snap-align
.tm__thumb {
  border: 3px solid transparent;
  transition: border-color 300ms;
}
.tm:has(#tm-s1:target) .tm__thumb--1,
.tm:has(#tm-s2:target) .tm__thumb--2,
.tm:has(#tm-s3:target) .tm__thumb--3 {
  border-color: #17141a;
}

05Vertical snap

Swap the scroll axis from horizontal to vertical and scroll-snap keeps working exactly the same way, so each slide fills the box top to bottom instead of side to side — the same direction a phone's story viewer scrolls in. A vertical story banner or a mobile full-screen intro reads naturally in this direction where a sideways strip wouldn't.

scroll-snap-type: yscroll-snap-align100%
.vs__viewport {
  height: 100%;
  overflow-y: auto;
  scroll-snap-type: y mandatory;
}
.vs__track { display: flex; flex-direction: column; }
.vs__slide { flex: 0 0 150px; scroll-snap-align: start; }

06Cover-flow snap

No radio input picks which card faces forward — real horizontal dragging is the only control, and every card is already tilted with a fixed rotateY and scale set purely by its position in the row. Whichever card scrolls itself to the center happens to be the one sitting at rotateY(0deg), so it just looks centered without any state ever changing.

perspectivescroll-snap-align: centerrotateY
.cf__viewport {
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  perspective: 640px;
}
.cf__slide { scroll-snap-align: center; }
.cf__slide--1, .cf__slide--5 { transform: rotateY(38deg) scale(.72); }
.cf__slide--2, .cf__slide--4 { transform: rotateY(24deg) scale(.86); }
.cf__slide--3 { transform: rotateY(0deg) scale(1); }

07Stacked card swipe

Three cards sit stacked in the exact same grid cell with no scroll box anywhere in sight, and all three play the identical @keyframes loop — only animation-delay staggers when each one starts, which alone is enough to make the top card peel off sideways while the next one rises to take its spot. Hovering the stack is the only way to pause it, via animation-play-state: paused.

grid-areatransformz-index
.cs__card {
  grid-area: 1 / 1;
  animation: cs-cycle 6s ease-in-out infinite;
}
.cs__card--2 { animation-delay: -.667s; }
.cs__card--3 { animation-delay: -1.333s; }
@keyframes cs-cycle {
  0%, 4%   { transform: translate(0, 0); opacity: 1; z-index: 3; }
  29%      { transform: translate(42%, 6%) rotate(12deg); opacity: 0; }
  33%, 62% { transform: translate(-6%, 6%) rotate(-4deg); opacity: .85; }
  66%, 96% { transform: translate(6%, 4%) rotate(3deg); opacity: .92; }
  100%     { transform: translate(0, 0); opacity: 1; z-index: 3; }
}

08Autoplay hero with Ken Burns zoom

Three photos cross-fade on their own timer, and the one currently on screen also runs its own private zoom — scale climbs from 1 to 1.16 only for as long as that photo is visible, then resets before its next turn comes around. That extra layer is what keeps a still hero banner feeling alive instead of just cutting between flat frames.

transform: scale@keyframesopacity
.kb__card { grid-area: 1 / 1; animation: kb-fade 6s linear infinite; }
.kb__img { animation: kb-zoom 6s ease-out infinite; }
@keyframes kb-fade { 0%, 4% { opacity: 1; } 29%, 100% { opacity: 0; } }
@keyframes kb-zoom {
  0%   { transform: scale(1); }
  29%  { transform: scale(1.16); }
  29.1%, 100% { transform: scale(1); }
}

09Scroll-linked progress bar

No radio hack and no JavaScript reading a scroll offset — animation-timeline points a keyframe animation at a named scroll-timeline and lets the real horizontal scroll position fill the bar's width directly, frame for frame. Measuring the render showed 23 of the 24 captured frames moving, more than any other recipe in this set, because the bar tracks the scroll continuously instead of jumping between fixed states.

animation-timeline: scroll()scroll-timelineJS 0줄
.sp { timeline-scope: --sp-progress; }
.sp__viewport { scroll-timeline: --sp-progress inline; }
.sp__fill {
  animation-name: sp-fill;
  animation-timeline: --sp-progress;
  animation-fill-mode: both;
}
@keyframes sp-fill { from { width: 0%; } to { width: 100%; } }

Where does this break?

Item 09's progress bar shipped broken the first time it was wired up. The bar (.sp__bar) and the scroll box (.sp__viewport) are siblings, not parent and child, and a scroll-timeline-name declared on one element is only visible to that element's own descendants by default — a sibling can't see it at all. The bar sat frozen at 0% width no matter how far the slides scrolled, because animation-timeline: --sp-progress on the fill had nothing to attach to. Declaring timeline-scope: --sp-progress on their shared parent is what opens that name up to the sibling side of the tree, and only after adding it did the bar start filling in step with the real scroll. Rendering it in an installed Chrome build confirmed the fix directly, frame by frame, from empty to about half full to completely filled; a browser that hasn't shipped animation-timeline yet just leaves the bar empty while scrolling and snapping keep working normally. Every one of the nine folders got re-checked this way before the zip was sealed, and typing b2earrkf exactly where the download page shows it unlocks the corrected set.

Variants worth trying

Variant Changed value Feel
Slower radio flip Item 02's track transition stretched from 300ms to 500ms The panel change reads as a deliberate page turn instead of a quick swap
Flatter cover flow Item 06's side-card angles cut from 38deg/24deg to 20deg/10deg Neighboring covers stay mostly readable instead of nearly edge-on
Eased progress fill Item 09's animation-timeline keyframe swapped from linear to ease-out The bar rushes early in the scroll and settles into the last stretch, instead of moving at one constant rate

Accessibility

Under prefers-reduced-motion: reduce, every item's own demo-preview loop turns off with animation: none !important, leaving only what a real drag, click, radio change, or anchor jump still causes. Items 07 and 08 stop mid-cycle and lock onto their first card so nothing keeps drifting on its own; the scroll-snap items (01, 03, 04, 05, 06, 09) simply set scroll-behavior: auto, so Tab and the arrow keys still move focus one slide at a time, just without an eased glide. Item 02's radio group is a native form control, so arrow keys already switch slides with no extra markup at all. The dots in item 03 and the thumbnails in item 04 each carry an aria-label="Slide N" so a screen reader can tell them apart even though they render with no visible text. MDN's prefers-reduced-motion reference lists exactly which platform settings trigger it.

None of the nine needs a 3D-tilted radio state or a ::scroll-marker to work — for that route through the same idea, see 9 CSS-Only Carousels You Can Copy-Paste, a separate set built around the browser-generated scroll markers this one deliberately avoids. More scroll-driven CSS sits in the CSS category hub, and MDN's scroll-snap-type entry covers exactly how snapping chooses a stopping point.

FAQ

Do any of these nine need JavaScript to actually run?

No — every slide change comes from native scrolling, a radio input's :checked state, or a plain anchor link's :target. The only script in each demo file is a three-line listener that stops the auto-playing preview loop once a visitor drags or clicks; deleting it removes nothing but that preview.

Will the item 09 progress bar fill in every browser?

It was measured in an installed Chrome build, where animation-timeline and timeline-scope are supported and the fill was confirmed moving frame by frame. Where those properties aren't supported yet, the bar just stays empty — the slides underneath still scroll and snap normally, so nothing about navigating the carousel breaks.

How different is the React version underneath?

Items 01, 05, 06, 07, 08, and 09 carry the same scroll-snap rules, keyframes, and animation-timeline straight into a CSS module, so the mechanism is identical. Item 02 swaps its radio group for one useState index, and item 03 (and 04 the same way) replaces :target with a scrollIntoView call so multiple carousel instances on one page never collide over the same anchor id; duration, easing, and color all become props either way.

Enter the archive password

The password is inside this article. You will find it as you read.