Pure TailwindMIT licensed

Tailwind Dot Pagination Carousel with Target and Has Selectors

A wide-format gallery with indicator dots that jump to their slide and light up when active. The dots are anchor links, the highlight is :target read through :has(), and the whole thing is navigation that works with JavaScript disabled.

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-16 w-full min-h-svh grid place-items-center bg-tcar16-bg font-tcar16-sans px-5 py-10" aria-label="Destination gallery">
  <div class="group tcar16-frame w-[min(680px,100%)] bg-white rounded-[18px] overflow-hidden shadow-[0_24px_50px_-34px_oklch(0.45_0.1_75/0.5)]">
    <ul tabindex="0" role="group" aria-label="Destinations, swipe or use arrow keys" class="tcar16-scroller list-none flex overflow-x-auto m-0 p-0 snap-x snap-mandatory scroll-smooth [scrollbar-width:none] focus-visible:outline-3 focus-visible:outline-tcar16-acc focus-visible:-outline-offset-[3px]">
      <li id="tcar-16-1" class="relative flex-none basis-full snap-center aspect-video overflow-hidden"><img src="https://picsum.photos/seed/dot-1/1000/560" width="1000" height="560" fetchpriority="high" decoding="async" alt="Amalfi coastline" class="size-full object-cover block"><span class="absolute inset-x-0 bottom-0 px-5 pt-[34px] pb-4 text-white text-lg font-bold bg-[linear-gradient(transparent,oklch(0_0_0/0.7))]">Amalfi Coast</span></li>
      <li id="tcar-16-2" class="relative flex-none basis-full snap-center aspect-video overflow-hidden"><img src="https://picsum.photos/seed/dot-2/1000/560" width="1000" height="560" loading="lazy" decoding="async" alt="Santorini rooftops" class="size-full object-cover block"><span class="absolute inset-x-0 bottom-0 px-5 pt-[34px] pb-4 text-white text-lg font-bold bg-[linear-gradient(transparent,oklch(0_0_0/0.7))]">Santorini</span></li>
      <li id="tcar-16-3" class="relative flex-none basis-full snap-center aspect-video overflow-hidden"><img src="https://picsum.photos/seed/dot-3/1000/560" width="1000" height="560" loading="lazy" decoding="async" alt="Kyoto temple garden" class="size-full object-cover block"><span class="absolute inset-x-0 bottom-0 px-5 pt-[34px] pb-4 text-white text-lg font-bold bg-[linear-gradient(transparent,oklch(0_0_0/0.7))]">Kyoto</span></li>
      <li id="tcar-16-4" class="relative flex-none basis-full snap-center aspect-video overflow-hidden"><img src="https://picsum.photos/seed/dot-4/1000/560" width="1000" height="560" loading="lazy" decoding="async" alt="Patagonia peaks" class="size-full object-cover block"><span class="absolute inset-x-0 bottom-0 px-5 pt-[34px] pb-4 text-white text-lg font-bold bg-[linear-gradient(transparent,oklch(0_0_0/0.7))]">Patagonia</span></li>
    </ul>
    <nav aria-label="Pagination" class="flex justify-center p-1 bg-white">
      <a href="#tcar-16-1" aria-label="Go to slide 1" class="tcar16-dot group/dot size-11 grid place-items-center no-underline"><span class="size-2.5 rounded-full bg-tcar16-dot transition-[scale,background-color] duration-200 group-hover/dot:bg-tcar16-dothover focus-visible:outline-3 focus-visible:outline-tcar16-acc focus-visible:outline-offset-[3px] group-has-[#tcar-16-1:target]:bg-tcar16-acc group-has-[#tcar-16-1:target]:scale-[1.4] motion-reduce:transition-none"></span></a>
      <a href="#tcar-16-2" aria-label="Go to slide 2" class="tcar16-dot group/dot size-11 grid place-items-center no-underline"><span class="size-2.5 rounded-full bg-tcar16-dot transition-[scale,background-color] duration-200 group-hover/dot:bg-tcar16-dothover focus-visible:outline-3 focus-visible:outline-tcar16-acc focus-visible:outline-offset-[3px] group-has-[#tcar-16-2:target]:bg-tcar16-acc group-has-[#tcar-16-2:target]:scale-[1.4] motion-reduce:transition-none"></span></a>
      <a href="#tcar-16-3" aria-label="Go to slide 3" class="tcar16-dot group/dot size-11 grid place-items-center no-underline"><span class="size-2.5 rounded-full bg-tcar16-dot transition-[scale,background-color] duration-200 group-hover/dot:bg-tcar16-dothover focus-visible:outline-3 focus-visible:outline-tcar16-acc focus-visible:outline-offset-[3px] group-has-[#tcar-16-3:target]:bg-tcar16-acc group-has-[#tcar-16-3:target]:scale-[1.4] motion-reduce:transition-none"></span></a>
      <a href="#tcar-16-4" aria-label="Go to slide 4" class="tcar16-dot group/dot size-11 grid place-items-center no-underline"><span class="size-2.5 rounded-full bg-tcar16-dot transition-[scale,background-color] duration-200 group-hover/dot:bg-tcar16-dothover focus-visible:outline-3 focus-visible:outline-tcar16-acc focus-visible:outline-offset-[3px] group-has-[#tcar-16-4:target]:bg-tcar16-acc group-has-[#tcar-16-4:target]:scale-[1.4] motion-reduce:transition-none"></span></a>
    </nav>
  </div>
</section>
@import "tailwindcss";

@theme {
  --color-tcar16-bg: #faf6ee;
  --color-tcar16-bg: oklch(0.97 0.012 85);
  --color-tcar16-dot: #dcc9a4;
  --color-tcar16-dot: oklch(0.85 0.045 85);
  --color-tcar16-dothover: #c4a873;
  --color-tcar16-dothover: oklch(0.75 0.07 85);
  --color-tcar16-acc: #b26a00;
  --color-tcar16-acc: oklch(0.6 0.13 70);
  --font-tcar16-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}

.tcar-16 {
  width: 100%;
  min-height: 100svh;
}

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

.tcar16-scroller::-webkit-scrollbar {
  display: none;
}
/* First paint: nothing is :target yet, so slide 1 owns the highlight.
   :not(:has(:target)) has no readable variant form — this is the rule that stays CSS. */

.tcar16-frame:not(:has(:target)) .tcar16-dot:first-child > span {
  background: var(--color-tcar16-acc);
  scale: 1.4;
}

@media (prefers-reduced-motion: reduce) {
  .tcar16-scroller {
    scroll-behavior: auto;
  }
}

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-tcar16-bg: #faf6ee;
  --color-tcar16-bg: oklch(0.97 0.012 85);
  --color-tcar16-dot: #dcc9a4;
  --color-tcar16-dot: oklch(0.85 0.045 85);
  --color-tcar16-dothover: #c4a873;
  --color-tcar16-dothover: oklch(0.75 0.07 85);
  --color-tcar16-acc: #b26a00;
  --color-tcar16-acc: oklch(0.6 0.13 70);
  --font-tcar16-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}

.tcar-16 {
  width: 100%;
  min-height: 100svh;
}

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

.tcar16-scroller::-webkit-scrollbar {
  display: none;
}
/* First paint: nothing is :target yet, so slide 1 owns the highlight.
   :not(:has(:target)) has no readable variant form — this is the rule that stays CSS. */

.tcar16-frame:not(:has(:target)) .tcar16-dot:first-child > span {
  background: var(--color-tcar16-acc);
  scale: 1.4;
}

@media (prefers-reduced-motion: reduce) {
  .tcar16-scroller {
    scroll-behavior: auto;
  }
}
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&display=swap">

What changed in the port

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

  • group-has-[#tcar-16-2:target]: is the same variant the radio demos use, pointed at a different pseudo-class. Once the :has() pattern is in hand, :checked, :target and :focus-visible are interchangeable inside it.
  • The scroller's id-bearing slides are the anchor targets AND the snap items. One element serving both roles is why this pattern needs no JavaScript at all.
  • UI change from the CSS original: slide 1's dot is now highlighted on first paint via :not(:has(:target)) — the original shipped with all four dots idle and listed the fix only as a gotcha. The accent also darkened from #f59e0b to oklch(0.6 0.13 70), which the original could not resolve against white.
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 Dot Pagination Carousel with Target and Has Selectors
Source: https://codefronts.com/snippets/tailwind-css-image-carousel/tailwind-dot-pagination-carousel-target-has/

A wide-format gallery with indicator dots that jump to their slide and light up when active. The dots are anchor links, the highlight is :target read through :has(), and the whole thing is navigation that works with JavaScript disabled.
## HTML
```html
<section class="tcar-16 w-full min-h-svh grid place-items-center bg-tcar16-bg font-tcar16-sans px-5 py-10" aria-label="Destination gallery">
  <div class="group tcar16-frame w-[min(680px,100%)] bg-white rounded-[18px] overflow-hidden shadow-[0_24px_50px_-34px_oklch(0.45_0.1_75/0.5)]">
    <ul tabindex="0" role="group" aria-label="Destinations, swipe or use arrow keys" class="tcar16-scroller list-none flex overflow-x-auto m-0 p-0 snap-x snap-mandatory scroll-smooth [scrollbar-width:none] focus-visible:outline-3 focus-visible:outline-tcar16-acc focus-visible:-outline-offset-[3px]">
      <li id="tcar-16-1" class="relative flex-none basis-full snap-center aspect-video overflow-hidden"><img src="https://picsum.photos/seed/dot-1/1000/560" width="1000" height="560" fetchpriority="high" decoding="async" alt="Amalfi coastline" class="size-full object-cover block"><span class="absolute inset-x-0 bottom-0 px-5 pt-[34px] pb-4 text-white text-lg font-bold bg-[linear-gradient(transparent,oklch(0_0_0/0.7))]">Amalfi Coast</span></li>
      <li id="tcar-16-2" class="relative flex-none basis-full snap-center aspect-video overflow-hidden"><img src="https://picsum.photos/seed/dot-2/1000/560" width="1000" height="560" loading="lazy" decoding="async" alt="Santorini rooftops" class="size-full object-cover block"><span class="absolute inset-x-0 bottom-0 px-5 pt-[34px] pb-4 text-white text-lg font-bold bg-[linear-gradient(transparent,oklch(0_0_0/0.7))]">Santorini</span></li>
      <li id="tcar-16-3" class="relative flex-none basis-full snap-center aspect-video overflow-hidden"><img src="https://picsum.photos/seed/dot-3/1000/560" width="1000" height="560" loading="lazy" decoding="async" alt="Kyoto temple garden" class="size-full object-cover block"><span class="absolute inset-x-0 bottom-0 px-5 pt-[34px] pb-4 text-white text-lg font-bold bg-[linear-gradient(transparent,oklch(0_0_0/0.7))]">Kyoto</span></li>
      <li id="tcar-16-4" class="relative flex-none basis-full snap-center aspect-video overflow-hidden"><img src="https://picsum.photos/seed/dot-4/1000/560" width="1000" height="560" loading="lazy" decoding="async" alt="Patagonia peaks" class="size-full object-cover block"><span class="absolute inset-x-0 bottom-0 px-5 pt-[34px] pb-4 text-white text-lg font-bold bg-[linear-gradient(transparent,oklch(0_0_0/0.7))]">Patagonia</span></li>
    </ul>
    <nav aria-label="Pagination" class="flex justify-center p-1 bg-white">
      <a href="#tcar-16-1" aria-label="Go to slide 1" class="tcar16-dot group/dot size-11 grid place-items-center no-underline"><span class="size-2.5 rounded-full bg-tcar16-dot transition-[scale,background-color] duration-200 group-hover/dot:bg-tcar16-dothover focus-visible:outline-3 focus-visible:outline-tcar16-acc focus-visible:outline-offset-[3px] group-has-[#tcar-16-1:target]:bg-tcar16-acc group-has-[#tcar-16-1:target]:scale-[1.4] motion-reduce:transition-none"></span></a>
      <a href="#tcar-16-2" aria-label="Go to slide 2" class="tcar16-dot group/dot size-11 grid place-items-center no-underline"><span class="size-2.5 rounded-full bg-tcar16-dot transition-[scale,background-color] duration-200 group-hover/dot:bg-tcar16-dothover focus-visible:outline-3 focus-visible:outline-tcar16-acc focus-visible:outline-offset-[3px] group-has-[#tcar-16-2:target]:bg-tcar16-acc group-has-[#tcar-16-2:target]:scale-[1.4] motion-reduce:transition-none"></span></a>
      <a href="#tcar-16-3" aria-label="Go to slide 3" class="tcar16-dot group/dot size-11 grid place-items-center no-underline"><span class="size-2.5 rounded-full bg-tcar16-dot transition-[scale,background-color] duration-200 group-hover/dot:bg-tcar16-dothover focus-visible:outline-3 focus-visible:outline-tcar16-acc focus-visible:outline-offset-[3px] group-has-[#tcar-16-3:target]:bg-tcar16-acc group-has-[#tcar-16-3:target]:scale-[1.4] motion-reduce:transition-none"></span></a>
      <a href="#tcar-16-4" aria-label="Go to slide 4" class="tcar16-dot group/dot size-11 grid place-items-center no-underline"><span class="size-2.5 rounded-full bg-tcar16-dot transition-[scale,background-color] duration-200 group-hover/dot:bg-tcar16-dothover focus-visible:outline-3 focus-visible:outline-tcar16-acc focus-visible:outline-offset-[3px] group-has-[#tcar-16-4:target]:bg-tcar16-acc group-has-[#tcar-16-4:target]:scale-[1.4] motion-reduce:transition-none"></span></a>
    </nav>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-tcar16-bg: #faf6ee;
  --color-tcar16-bg: oklch(0.97 0.012 85);
  --color-tcar16-dot: #dcc9a4;
  --color-tcar16-dot: oklch(0.85 0.045 85);
  --color-tcar16-dothover: #c4a873;
  --color-tcar16-dothover: oklch(0.75 0.07 85);
  --color-tcar16-acc: #b26a00;
  --color-tcar16-acc: oklch(0.6 0.13 70);
  --font-tcar16-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}

.tcar-16 {
  width: 100%;
  min-height: 100svh;
}

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

.tcar16-scroller::-webkit-scrollbar {
  display: none;
}
/* First paint: nothing is :target yet, so slide 1 owns the highlight.
   :not(:has(:target)) has no readable variant form — this is the rule that stays CSS. */

.tcar16-frame:not(:has(:target)) .tcar16-dot:first-child > span {
  background: var(--color-tcar16-acc);
  scale: 1.4;
}

@media (prefers-reduced-motion: reduce) {
  .tcar16-scroller {
    scroll-behavior: auto;
  }
}
```

How this works

The fragment is the state. Clicking a dot sets the URL hash, the matching slide becomes :target, and group-has-[#tcar-16-2:target]: finds the dot that should light. No JavaScript, no active-index variable, and the state survives a page reload or a shared link.

The first-paint default stays in CSS. Before any click, no slide is :target and every dot is idle. The fix is :not(:has(:target)) on the frame, which has no clean variant form — two lines of CSS, and the gallery never opens in a stateless-looking condition.

Swipe does not update the fragment. This is inherent to the pattern: touch scrolling changes no URL, so the highlight reflects the last clicked dot. Adding an IntersectionObserver would fix it and would also make this a JS demo.

Make it yours

  • Numbered markers: put the index in the anchor text and drop rounded-full.
  • Dot row over the image: absolutely position the <nav> and tint the dots.
  • Add a slide: a new <li id> plus a dot with the matching condition.
  • Colours: --color-tcar16-acc, --color-tcar16-dot, --color-tcar16-dothover.

Gotchas — read before shipping

  • Swipe scrolling never updates the fragment, so the dots track clicks only. Say so in your own docs rather than letting a QA ticket discover it.
  • scroll-smooth must be on the scroller, and the page's own scroll-behavior matters too — an anchor jump inside a scroller can also scroll the document.
  • Keep the <nav aria-label="Pagination"> wrapper. Without it, four unlabelled circles are four unlabelled circles.
  • Enlarge the dot's <b>own</b> box to 44px rather than inflating it with before:-inset-*: with a 10px dot and a 10px gap the pseudo hit areas overlap by 14px, and the topmost one in paint order takes clicks meant for its neighbours.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

:has() plus :target drive the highlight (Chrome 105+, Safari 15.4+, Firefox 121+). Anchor navigation itself is universal — in an older browser the dots still navigate, only the highlight is lost.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Search CodeFronts

Loading…