Pure TailwindMIT licensed

Tailwind E-commerce Product Gallery Carousel with Thumbnails

The product-page workhorse: one large image with a strip of clickable thumbnails underneath. The port's win is group-has-[#id:checked]: — twelve :nth-of-type sibling rules collapse into one utility per element, written on the element that actually changes.

Live Demo
Plain CSSTailwind v4
Try it

Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time

Untitled UI React · Affiliate link

Get Untitled UI Pro

The code

<section class="tcar-01 w-full min-h-svh grid place-items-center bg-tcar01-bg font-tcar01-sans px-5 py-10" aria-label="Product image gallery">
  <div class="group w-[min(440px,100%)]">
    <input type="radio" name="tcar-01" id="tcar-01-1" class="sr-only" checked>
    <input type="radio" name="tcar-01" id="tcar-01-2" class="sr-only">
    <input type="radio" name="tcar-01" id="tcar-01-3" class="sr-only">
    <input type="radio" name="tcar-01" id="tcar-01-4" class="sr-only">
    <div class="relative aspect-square rounded-2xl overflow-hidden bg-tcar01-ph shadow-[0_20px_45px_-30px_oklch(0.3_0.03_265/0.6)]">
      <img src="https://picsum.photos/seed/sneaker-a/800/800" width="800" height="800" fetchpriority="high" decoding="async" alt="Trail runner, three-quarter view" class="absolute inset-0 size-full object-cover opacity-0 transition-opacity duration-[450ms] ease-out group-has-[#tcar-01-1:checked]:opacity-100 motion-reduce:transition-none">
      <img src="https://picsum.photos/seed/sneaker-b/800/800" width="800" height="800" loading="lazy" decoding="async" alt="Trail runner, sole detail" class="absolute inset-0 size-full object-cover opacity-0 transition-opacity duration-[450ms] ease-out group-has-[#tcar-01-2:checked]:opacity-100 motion-reduce:transition-none">
      <img src="https://picsum.photos/seed/sneaker-c/800/800" width="800" height="800" loading="lazy" decoding="async" alt="Trail runner, heel and laces" class="absolute inset-0 size-full object-cover opacity-0 transition-opacity duration-[450ms] ease-out group-has-[#tcar-01-3:checked]:opacity-100 motion-reduce:transition-none">
      <img src="https://picsum.photos/seed/sneaker-d/800/800" width="800" height="800" loading="lazy" decoding="async" alt="Trail runner, top-down view" class="absolute inset-0 size-full object-cover opacity-0 transition-opacity duration-[450ms] ease-out group-has-[#tcar-01-4:checked]:opacity-100 motion-reduce:transition-none">
    </div>
    <div class="grid grid-cols-4 gap-2.5 mt-3.5">
      <label for="tcar-01-1" class="block aspect-square rounded-xl overflow-hidden cursor-pointer bg-tcar01-ph border-2 border-transparent transition-[border-color,translate] duration-200 hover:-translate-y-0.5 group-has-[#tcar-01-1:checked]:border-tcar01-acc group-has-[#tcar-01-1:focus-visible]:outline-3 group-has-[#tcar-01-1:focus-visible]:outline-tcar01-acc group-has-[#tcar-01-1:focus-visible]:outline-offset-2 motion-reduce:transition-colors motion-reduce:hover:translate-y-0"><img src="https://picsum.photos/seed/sneaker-a/160/160" width="160" height="160" loading="lazy" decoding="async" alt="" aria-hidden="true" class="size-full object-cover block"></label>
      <label for="tcar-01-2" class="block aspect-square rounded-xl overflow-hidden cursor-pointer bg-tcar01-ph border-2 border-transparent transition-[border-color,translate] duration-200 hover:-translate-y-0.5 group-has-[#tcar-01-2:checked]:border-tcar01-acc group-has-[#tcar-01-2:focus-visible]:outline-3 group-has-[#tcar-01-2:focus-visible]:outline-tcar01-acc group-has-[#tcar-01-2:focus-visible]:outline-offset-2 motion-reduce:transition-colors motion-reduce:hover:translate-y-0"><img src="https://picsum.photos/seed/sneaker-b/160/160" width="160" height="160" loading="lazy" decoding="async" alt="" aria-hidden="true" class="size-full object-cover block"></label>
      <label for="tcar-01-3" class="block aspect-square rounded-xl overflow-hidden cursor-pointer bg-tcar01-ph border-2 border-transparent transition-[border-color,translate] duration-200 hover:-translate-y-0.5 group-has-[#tcar-01-3:checked]:border-tcar01-acc group-has-[#tcar-01-3:focus-visible]:outline-3 group-has-[#tcar-01-3:focus-visible]:outline-tcar01-acc group-has-[#tcar-01-3:focus-visible]:outline-offset-2 motion-reduce:transition-colors motion-reduce:hover:translate-y-0"><img src="https://picsum.photos/seed/sneaker-c/160/160" width="160" height="160" loading="lazy" decoding="async" alt="" aria-hidden="true" class="size-full object-cover block"></label>
      <label for="tcar-01-4" class="block aspect-square rounded-xl overflow-hidden cursor-pointer bg-tcar01-ph border-2 border-transparent transition-[border-color,translate] duration-200 hover:-translate-y-0.5 group-has-[#tcar-01-4:checked]:border-tcar01-acc group-has-[#tcar-01-4:focus-visible]:outline-3 group-has-[#tcar-01-4:focus-visible]:outline-tcar01-acc group-has-[#tcar-01-4:focus-visible]:outline-offset-2 motion-reduce:transition-colors motion-reduce:hover:translate-y-0"><img src="https://picsum.photos/seed/sneaker-d/160/160" width="160" height="160" loading="lazy" decoding="async" alt="" aria-hidden="true" class="size-full object-cover block"></label>
    </div>
  </div>
</section>
@import "tailwindcss";

@theme {
  --color-tcar01-bg: #f4f5f7;
  --color-tcar01-bg: oklch(0.96 0.003 265);
  --color-tcar01-ph: #e6e8ec;
  --color-tcar01-ph: oklch(0.92 0.005 265);
  --color-tcar01-acc: #3f7ad1;
  --color-tcar01-acc: oklch(0.62 0.16 250);
  --font-tcar01-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
   utilities, so the stage is sized before the browser build has compiled. */

.tcar-01 {
  width: 100%;
  min-height: 100svh;
}
/* ::selection takes no variant form. */

.tcar-01 ::selection {
  background: var(--color-tcar01-acc);
  color: #fff;
}

Setup — do this once per project

Skip this if your project already runs Tailwind v4.

1Load Tailwind
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>

Compiles in the browser — perfect for a CodePen or a quick test, not for production.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@import "tailwindcss";

@theme {
  --color-tcar01-bg: #f4f5f7;
  --color-tcar01-bg: oklch(0.96 0.003 265);
  --color-tcar01-ph: #e6e8ec;
  --color-tcar01-ph: oklch(0.92 0.005 265);
  --color-tcar01-acc: #3f7ad1;
  --color-tcar01-acc: oklch(0.62 0.16 250);
  --font-tcar01-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
   utilities, so the stage is sized before the browser build has compiled. */

.tcar-01 {
  width: 100%;
  min-height: 100svh;
}
/* ::selection takes no variant form. */

.tcar-01 ::selection {
  background: var(--color-tcar01-acc);
  color: #fff;
}
3Fonts used by this demooptional — skip if you have your own type stack
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap">

What changed in the port

Which parts became utilities, which stayed CSS, and why.

  • The twelve :nth-of-type(N):checked ~ .stage .main:nth-child(N) selectors become one group-has-[#tcar-01-N:checked]:opacity-100 per image. The rule count drops from twelve to four, and each one sits on the element it governs.
  • peer-checked: looks like the obvious port and is a trap: it emits a sibling combinator, and the stage images are not siblings of the radios. group-has- is the only variant that crosses the nesting boundary.
  • UI change from the CSS original: images below the first now carry loading="lazy" decoding="async" and the first carries fetchpriority="high" — the LCP candidate is fetched first and the other three stop competing with it. The CSS version eagerly loaded all eight files.
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind E-commerce Product Gallery Carousel with Thumbnails
Source: https://codefronts.com/snippets/tailwind-css-image-carousel/tailwind-ecommerce-product-gallery-carousel-thumbnails/

The product-page workhorse: one large image with a strip of clickable thumbnails underneath. The port's win is group-has-[#id:checked]: — twelve :nth-of-type sibling rules collapse into one utility per element, written on the element that actually changes.
## HTML
```html
<section class="tcar-01 w-full min-h-svh grid place-items-center bg-tcar01-bg font-tcar01-sans px-5 py-10" aria-label="Product image gallery">
  <div class="group w-[min(440px,100%)]">
    <input type="radio" name="tcar-01" id="tcar-01-1" class="sr-only" checked>
    <input type="radio" name="tcar-01" id="tcar-01-2" class="sr-only">
    <input type="radio" name="tcar-01" id="tcar-01-3" class="sr-only">
    <input type="radio" name="tcar-01" id="tcar-01-4" class="sr-only">
    <div class="relative aspect-square rounded-2xl overflow-hidden bg-tcar01-ph shadow-[0_20px_45px_-30px_oklch(0.3_0.03_265/0.6)]">
      <img src="https://picsum.photos/seed/sneaker-a/800/800" width="800" height="800" fetchpriority="high" decoding="async" alt="Trail runner, three-quarter view" class="absolute inset-0 size-full object-cover opacity-0 transition-opacity duration-[450ms] ease-out group-has-[#tcar-01-1:checked]:opacity-100 motion-reduce:transition-none">
      <img src="https://picsum.photos/seed/sneaker-b/800/800" width="800" height="800" loading="lazy" decoding="async" alt="Trail runner, sole detail" class="absolute inset-0 size-full object-cover opacity-0 transition-opacity duration-[450ms] ease-out group-has-[#tcar-01-2:checked]:opacity-100 motion-reduce:transition-none">
      <img src="https://picsum.photos/seed/sneaker-c/800/800" width="800" height="800" loading="lazy" decoding="async" alt="Trail runner, heel and laces" class="absolute inset-0 size-full object-cover opacity-0 transition-opacity duration-[450ms] ease-out group-has-[#tcar-01-3:checked]:opacity-100 motion-reduce:transition-none">
      <img src="https://picsum.photos/seed/sneaker-d/800/800" width="800" height="800" loading="lazy" decoding="async" alt="Trail runner, top-down view" class="absolute inset-0 size-full object-cover opacity-0 transition-opacity duration-[450ms] ease-out group-has-[#tcar-01-4:checked]:opacity-100 motion-reduce:transition-none">
    </div>
    <div class="grid grid-cols-4 gap-2.5 mt-3.5">
      <label for="tcar-01-1" class="block aspect-square rounded-xl overflow-hidden cursor-pointer bg-tcar01-ph border-2 border-transparent transition-[border-color,translate] duration-200 hover:-translate-y-0.5 group-has-[#tcar-01-1:checked]:border-tcar01-acc group-has-[#tcar-01-1:focus-visible]:outline-3 group-has-[#tcar-01-1:focus-visible]:outline-tcar01-acc group-has-[#tcar-01-1:focus-visible]:outline-offset-2 motion-reduce:transition-colors motion-reduce:hover:translate-y-0"><img src="https://picsum.photos/seed/sneaker-a/160/160" width="160" height="160" loading="lazy" decoding="async" alt="" aria-hidden="true" class="size-full object-cover block"></label>
      <label for="tcar-01-2" class="block aspect-square rounded-xl overflow-hidden cursor-pointer bg-tcar01-ph border-2 border-transparent transition-[border-color,translate] duration-200 hover:-translate-y-0.5 group-has-[#tcar-01-2:checked]:border-tcar01-acc group-has-[#tcar-01-2:focus-visible]:outline-3 group-has-[#tcar-01-2:focus-visible]:outline-tcar01-acc group-has-[#tcar-01-2:focus-visible]:outline-offset-2 motion-reduce:transition-colors motion-reduce:hover:translate-y-0"><img src="https://picsum.photos/seed/sneaker-b/160/160" width="160" height="160" loading="lazy" decoding="async" alt="" aria-hidden="true" class="size-full object-cover block"></label>
      <label for="tcar-01-3" class="block aspect-square rounded-xl overflow-hidden cursor-pointer bg-tcar01-ph border-2 border-transparent transition-[border-color,translate] duration-200 hover:-translate-y-0.5 group-has-[#tcar-01-3:checked]:border-tcar01-acc group-has-[#tcar-01-3:focus-visible]:outline-3 group-has-[#tcar-01-3:focus-visible]:outline-tcar01-acc group-has-[#tcar-01-3:focus-visible]:outline-offset-2 motion-reduce:transition-colors motion-reduce:hover:translate-y-0"><img src="https://picsum.photos/seed/sneaker-c/160/160" width="160" height="160" loading="lazy" decoding="async" alt="" aria-hidden="true" class="size-full object-cover block"></label>
      <label for="tcar-01-4" class="block aspect-square rounded-xl overflow-hidden cursor-pointer bg-tcar01-ph border-2 border-transparent transition-[border-color,translate] duration-200 hover:-translate-y-0.5 group-has-[#tcar-01-4:checked]:border-tcar01-acc group-has-[#tcar-01-4:focus-visible]:outline-3 group-has-[#tcar-01-4:focus-visible]:outline-tcar01-acc group-has-[#tcar-01-4:focus-visible]:outline-offset-2 motion-reduce:transition-colors motion-reduce:hover:translate-y-0"><img src="https://picsum.photos/seed/sneaker-d/160/160" width="160" height="160" loading="lazy" decoding="async" alt="" aria-hidden="true" class="size-full object-cover block"></label>
    </div>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-tcar01-bg: #f4f5f7;
  --color-tcar01-bg: oklch(0.96 0.003 265);
  --color-tcar01-ph: #e6e8ec;
  --color-tcar01-ph: oklch(0.92 0.005 265);
  --color-tcar01-acc: #3f7ad1;
  --color-tcar01-acc: oklch(0.62 0.16 250);
  --font-tcar01-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
   utilities, so the stage is sized before the browser build has compiled. */

.tcar-01 {
  width: 100%;
  min-height: 100svh;
}
/* ::selection takes no variant form. */

.tcar-01 ::selection {
  background: var(--color-tcar01-acc);
  color: #fff;
}
```

How this works

The sibling map becomes a :has() condition. The CSS version needs one selector per (radio × target) pair, twelve in total. Tailwind puts group on the viewer and the condition on each element:

<img class="opacity-0 group-has-[#tcar-01-2:checked]:opacity-100">

Depth stops mattering, so the identical variant lights the thumbnail ring two levels away, and adding a fifth view is additive rather than a renumbering exercise.

The radios are still radios. sr-only clips them to a 1px box but leaves them focusable and in the accessibility tree, so Left/Right steps the gallery natively and the group announces "1 of 4".

Focus is mirrored, not removed. group-has-[#tcar-01-2:focus-visible]:outline-3 paints the ring on the thumbnail the keyboard is on, so mouse and keyboard states stay in lockstep without a single outline:none.

Make it yours

  • Add a view by adding one radio, one stage image and one thumb, each carrying its own group-has-[#tcar-01-5:checked]: utility — no existing class changes.
  • Retheme through --color-tcar01-acc: active ring and focus ring both read it.
  • Instant cut instead of a dissolve: drop transition-opacity duration-[450ms] from the stage images.
  • Thumbs per row: change grid-cols-4.

Gotchas — read before shipping

  • peer-checked: is the wrong tool here — it compiles to .peer:checked ~ &, a sibling combinator that cannot reach the stage image inside a sibling div. It fails silently. Use group-has-[...].
  • Never swap sr-only for hidden on the radios: display:none removes them from the tab order and the accessibility tree, and arrow-key navigation dies with it.
  • Keep aspect-square on the stage. Without a reserved box the image swap shifts the page and CLS spikes.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

:has() drives the whole state map (Chrome 105+, Safari 15.4+, Firefox 121+), which sits inside Tailwind v4's own floor (cascade layers, @property, color-mix()).

Techniques used in this demo

Search CodeFronts

Loading…