Carousels have historically been JavaScript-heavy components, but modern CSS offers a surprisingly clean path to building one without a single line of script. By combining CSS Grid, scroll snapping, and a bit of thoughtful markup, we can create a functional, image-based carousel that works as a progressive enhancement on top of plain HTML.

The core structure consists of two parts: a thumbnail navigation strip on the left and a larger gallery view on the right. Each thumbnail links to an anchor ID that corresponds to one of the full-size images in the gallery. This gives us click-to-jump navigation for free — no JavaScript required.

<div class="wrapper">
  <nav class="lil-nav"></nav>
  <div class="gallery"></div>
</div>

Thumbnails go inside the nav element, each pointing at the ID of its matching gallery image:

<nav class="lil-nav">
  <a href="#image-1">
    <img class="lil-nav__img" src="..." alt="Yosemite" />
  </a>
  <a href="#image-2">
    <img class="lil-nav__img" src="..." alt="Basketball hoop" />
  </a>
  <!-- more images go here --> 
</nav>

The gallery itself is just a scrollable container holding the same images, each given a unique ID so the anchor links can target them:

<div class="gallery">
  <img class="gallery__img" id="image-1" src="..." alt="Yosemite" />
  <img class="gallery__img" id="image-2" src="..." alt="Basketball hoop" />
  <!-- more images go here --> 
</div>

Layout with CSS Grid

We set the parent .wrapper as a grid container, establishing two columns and giving ourselves sensible defaults for the images inside:

img {
  display: block;
  max-width: 100%;
}

.wrapper {
  display: grid;
  grid-template-columns: 1fr 5fr;
  grid-gap: 20px;
}

Both the thumbnail nav and the gallery need to handle overflow so their content can scroll independently:

.wrapper {
  display: grid;
  grid-template-columns: 1fr 5fr;
  grid-gap: 10px;
  overflow: hidden;
  height: 100vh; 
}

.gallery {
  overflow: scroll;
}

.lil-nav {
  overflow-y: scroll;
  overflow-x: hidden;
}

To make the gallery stop neatly on each image rather than free-scrolling, we apply scroll-snap-type on the container and scroll-snap-align on the images:

.gallery {
  overflow: scroll;
  scroll-snap-type: x mandatory;
}

.gallery__img {
  scroll-snap-align: start;
  margin-bottom: 10px;
}

That single pair of properties turns a simple scroll area into something that behaves like a traditional carousel, snapping cleanly from slide to slide.

Sprucing Up the Images

Right now the gallery images only take up as much space as their natural dimensions. Using object-fit along with a min-height based on viewport height (vh) ensures each image fills the screen regardless of its aspect ratio:

.gallery__img {
  scroll-snap-align: start;
  margin-bottom: 10px;
  min-height: 100vh;
  object-fit: cover;
}

The thumbnail strip benefits from similar treatment. A grayscale filter on the inactive thumbnails, removed on hover, gives visual feedback without any scripting:

.lil-nav {
  overflow-y: scroll;
  overflow-x: hidden;
}

.lil-nav a {
  height: 200px;
  display: flex;
  margin-bottom: 10px;
}

.lil-nav__img {
  object-fit: cover;
}
.lil-nav__img {
  object-fit: cover;
  filter: saturate(0);
  transition: 0.3s ease all;
}

.lil-nav__img:hover {
  transform: scale(1.05);
  filter: saturate(1);
}

Smooth Scrolling Between Slides

Clicking a thumbnail currently jumps abruptly to the matching gallery image. Adding the scroll-behavior property to the scrollable container animates that transition smoothly:

.gallery {
  overflow: scroll;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
}

Thumbnails, for what it's worth, keep their default scroll behavior — turning them into another snapping carousel felt unnatural in testing.

Making It Responsive

The layout shifts from a side-by-side arrangement on desktop to a stacked one on mobile. Wrapping the desktop styles in a media query that targets larger screens, and using Sass to keep things organized, handles the breakpoint:

$large: 1200px;

.wrapper {
  overflow: hidden;
  height: 100vh;
  display: grid;
  grid-template-rows: 2fr 1fr;
  grid-gap: 10px;

  @media screen and (min-width: $large) {
    grid-template-columns: 1fr 5fr;
    grid-template-rows: auto;
  }
}

The nav moves to the second row on smaller screens so it sits below the gallery:

.lil-nav {
  overflow-x: scroll;
  overflow-y: hidden;
  display: flex;
  grid-row-start: 2;

  @media screen and (min-width: $large) {
    overflow-y: scroll;
    overflow-x: hidden;
    display: block;
    grid-row-start: auto;
  }
}

The gallery's scroll-snap type and overflow direction also flip depending on screen size:

.gallery {
  overflow-x: scroll;
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  display: flex;

  @media screen and (min-width: $large) {
    display: block;
    overflow-y: scroll;
    overflow-x: hidden;
    scroll-snap-type: y mandatory;
  }
}

Final Notes on Production Readiness

This approach gives us a genuinely usable carousel with only HTML and CSS. For a production deployment, two areas deserve attention: accessibility — screen readers likely shouldn't announce every image twice (once in the nav, once in the gallery) — and performance, where lazy-loading gallery images would prevent unnecessary downloads. Those refinements aside, the technique stands on its own as a lightweight alternative to the usual JavaScript-heavy approaches.