Pure TailwindMIT licensed

Tailwind Prev / Next Arrow Carousel with Native Scroll Buttons

Real previous/next buttons that page the gallery one slide at a time, generated by the browser via ::scroll-button() — focusable, auto-disabling at the ends, and needing no JavaScript. Anchor-link arrows sit underneath as the universal fallback.

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-17 w-full min-h-svh grid place-items-center bg-tcar17-bg font-tcar17-sans px-5 py-10" aria-label="Featured shots">
  <div class="relative w-[min(620px,100%)]">
    <ul tabindex="0" role="group" aria-label="Shots, use the arrow buttons or arrow keys" class="tcar17-scroller list-none flex overflow-x-auto m-0 p-0 rounded-2xl snap-x snap-mandatory scroll-smooth [scrollbar-width:none] shadow-[0_24px_50px_-34px_oklch(0.35_0.1_265/0.5)] focus-visible:outline-3 focus-visible:outline-tcar17-acc focus-visible:outline-offset-[3px]">
      <li id="tcar-17-s1" class="flex-none basis-full snap-center aspect-[16/10] overflow-hidden"><img src="https://picsum.photos/seed/arrow-1/900/560" width="900" height="560" fetchpriority="high" decoding="async" alt="City skyline" class="size-full object-cover block"></li>
      <li id="tcar-17-s2" class="flex-none basis-full snap-center aspect-[16/10] overflow-hidden"><img src="https://picsum.photos/seed/arrow-2/900/560" width="900" height="560" loading="lazy" decoding="async" alt="Desert highway" class="size-full object-cover block"></li>
      <li id="tcar-17-s3" class="flex-none basis-full snap-center aspect-[16/10] overflow-hidden"><img src="https://picsum.photos/seed/arrow-3/900/560" width="900" height="560" loading="lazy" decoding="async" alt="Rainforest canopy" class="size-full object-cover block"></li>
      <li id="tcar-17-s4" class="flex-none basis-full snap-center aspect-[16/10] overflow-hidden"><img src="https://picsum.photos/seed/arrow-4/900/560" width="900" height="560" loading="lazy" decoding="async" alt="Frozen lake" class="size-full object-cover block"></li>
    </ul>
    <nav aria-label="Slide navigation (fallback)" class="tcar17-fallback absolute inset-0 pointer-events-none">
      <a href="#tcar-17-s1" aria-label="Previous" class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-auto size-11 flex items-center justify-center rounded-full bg-white text-tcar17-acc text-[26px] font-bold no-underline shadow-[0_8px_20px_-8px_oklch(0.35_0.1_265/0.6)] transition-[scale] duration-200 hover:scale-[1.08] focus-visible:outline-3 focus-visible:outline-tcar17-acc focus-visible:outline-offset-2 motion-reduce:transition-none motion-reduce:hover:scale-100">&lsaquo;</a>
      <a href="#tcar-17-s4" aria-label="Next" class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-auto size-11 flex items-center justify-center rounded-full bg-white text-tcar17-acc text-[26px] font-bold no-underline shadow-[0_8px_20px_-8px_oklch(0.35_0.1_265/0.6)] transition-[scale] duration-200 hover:scale-[1.08] focus-visible:outline-3 focus-visible:outline-tcar17-acc focus-visible:outline-offset-2 motion-reduce:transition-none motion-reduce:hover:scale-100">&rsaquo;</a>
    </nav>
  </div>
</section>
@import "tailwindcss";

@theme {
  --color-tcar17-bg: #eef2fb;
  --color-tcar17-bg: oklch(0.95 0.015 265);
  --color-tcar17-acc: #2563eb;
  --color-tcar17-acc: oklch(0.52 0.2 262);
  --font-tcar17-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}

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

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

.tcar17-scroller::-webkit-scrollbar {
  display: none;
}
/* CSS Carousel scroll buttons: no utility form, so the whole control lives here. */

.tcar17-scroller::scroll-button(left),
.tcar17-scroller::scroll-button(right) {
  position: absolute;
  top: 50%;
  translate: 0 -50%;
  z-index: 3;
  width: 44px;
  height: 44px;
  border: 0;
  border-radius: 50%;
  cursor: pointer;
  background: #fff;
  color: var(--color-tcar17-acc);
  font-size: 22px;
  font-weight: 700;
  line-height: 1;
  box-shadow: 0 8px 20px -8px oklch(0.35 0.1 265 / 0.6);
  transition: scale .2s, opacity .2s;
}

.tcar17-scroller::scroll-button(left) {
  content: "\2039";
  left: 12px;
}

.tcar17-scroller::scroll-button(right) {
  content: "\203A";
  right: 12px;
}

.tcar17-scroller::scroll-button(left):hover,
.tcar17-scroller::scroll-button(right):hover {
  scale: 1.08;
}

.tcar17-scroller::scroll-button(left):disabled,
.tcar17-scroller::scroll-button(right):disabled {
  opacity: .35;
  cursor: default;
}

.tcar17-scroller::scroll-button(left):focus-visible,
.tcar17-scroller::scroll-button(right):focus-visible {
  outline: 3px solid var(--color-tcar17-acc);
  outline-offset: 2px;
}
/* One rule decides which control set the user sees. */

@supports selector(::scroll-button(right)) {
  .tcar17-fallback {
    display: none;
  }
}

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

  .tcar17-scroller::scroll-button(left),
    .tcar17-scroller::scroll-button(right) {
    transition: none;
  }
}

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-tcar17-bg: #eef2fb;
  --color-tcar17-bg: oklch(0.95 0.015 265);
  --color-tcar17-acc: #2563eb;
  --color-tcar17-acc: oklch(0.52 0.2 262);
  --font-tcar17-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}

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

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

.tcar17-scroller::-webkit-scrollbar {
  display: none;
}
/* CSS Carousel scroll buttons: no utility form, so the whole control lives here. */

.tcar17-scroller::scroll-button(left),
.tcar17-scroller::scroll-button(right) {
  position: absolute;
  top: 50%;
  translate: 0 -50%;
  z-index: 3;
  width: 44px;
  height: 44px;
  border: 0;
  border-radius: 50%;
  cursor: pointer;
  background: #fff;
  color: var(--color-tcar17-acc);
  font-size: 22px;
  font-weight: 700;
  line-height: 1;
  box-shadow: 0 8px 20px -8px oklch(0.35 0.1 265 / 0.6);
  transition: scale .2s, opacity .2s;
}

.tcar17-scroller::scroll-button(left) {
  content: "\2039";
  left: 12px;
}

.tcar17-scroller::scroll-button(right) {
  content: "\203A";
  right: 12px;
}

.tcar17-scroller::scroll-button(left):hover,
.tcar17-scroller::scroll-button(right):hover {
  scale: 1.08;
}

.tcar17-scroller::scroll-button(left):disabled,
.tcar17-scroller::scroll-button(right):disabled {
  opacity: .35;
  cursor: default;
}

.tcar17-scroller::scroll-button(left):focus-visible,
.tcar17-scroller::scroll-button(right):focus-visible {
  outline: 3px solid var(--color-tcar17-acc);
  outline-offset: 2px;
}
/* One rule decides which control set the user sees. */

@supports selector(::scroll-button(right)) {
  .tcar17-fallback {
    display: none;
  }
}

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

  .tcar17-scroller::scroll-button(left),
    .tcar17-scroller::scroll-button(right) {
    transition: none;
  }
}
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.

  • The clearest boundary in the collection: the fallback arrows are 100% utilities, the native buttons are 100% CSS, and @supports chooses. Nothing is duplicated except the visual language, deliberately.
  • Both control sets are 44×44px. The original set the native buttons to 44px and the fallback arrows to 44px too, but only by accident of the shadow — here size-11 says it on purpose.
  • UI change from the CSS original: the fallback arrows are now a real prev/next pair pointing at the first and last slides, and both carry motion-reduce: branches. The scale-on-hover also moved to the scale property so it composes with the -translate-y-1/2 centring instead of overwriting it — the original had to restate translateY(-50%) inside every hover rule.
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 Prev / Next Arrow Carousel with Native Scroll Buttons
Source: https://codefronts.com/snippets/tailwind-css-image-carousel/tailwind-prev-next-arrow-carousel-scroll-button/

Real previous/next buttons that page the gallery one slide at a time, generated by the browser via ::scroll-button() — focusable, auto-disabling at the ends, and needing no JavaScript. Anchor-link arrows sit underneath as the universal fallback.
## HTML
```html
<section class="tcar-17 w-full min-h-svh grid place-items-center bg-tcar17-bg font-tcar17-sans px-5 py-10" aria-label="Featured shots">
  <div class="relative w-[min(620px,100%)]">
    <ul tabindex="0" role="group" aria-label="Shots, use the arrow buttons or arrow keys" class="tcar17-scroller list-none flex overflow-x-auto m-0 p-0 rounded-2xl snap-x snap-mandatory scroll-smooth [scrollbar-width:none] shadow-[0_24px_50px_-34px_oklch(0.35_0.1_265/0.5)] focus-visible:outline-3 focus-visible:outline-tcar17-acc focus-visible:outline-offset-[3px]">
      <li id="tcar-17-s1" class="flex-none basis-full snap-center aspect-[16/10] overflow-hidden"><img src="https://picsum.photos/seed/arrow-1/900/560" width="900" height="560" fetchpriority="high" decoding="async" alt="City skyline" class="size-full object-cover block"></li>
      <li id="tcar-17-s2" class="flex-none basis-full snap-center aspect-[16/10] overflow-hidden"><img src="https://picsum.photos/seed/arrow-2/900/560" width="900" height="560" loading="lazy" decoding="async" alt="Desert highway" class="size-full object-cover block"></li>
      <li id="tcar-17-s3" class="flex-none basis-full snap-center aspect-[16/10] overflow-hidden"><img src="https://picsum.photos/seed/arrow-3/900/560" width="900" height="560" loading="lazy" decoding="async" alt="Rainforest canopy" class="size-full object-cover block"></li>
      <li id="tcar-17-s4" class="flex-none basis-full snap-center aspect-[16/10] overflow-hidden"><img src="https://picsum.photos/seed/arrow-4/900/560" width="900" height="560" loading="lazy" decoding="async" alt="Frozen lake" class="size-full object-cover block"></li>
    </ul>
    <nav aria-label="Slide navigation (fallback)" class="tcar17-fallback absolute inset-0 pointer-events-none">
      <a href="#tcar-17-s1" aria-label="Previous" class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-auto size-11 flex items-center justify-center rounded-full bg-white text-tcar17-acc text-[26px] font-bold no-underline shadow-[0_8px_20px_-8px_oklch(0.35_0.1_265/0.6)] transition-[scale] duration-200 hover:scale-[1.08] focus-visible:outline-3 focus-visible:outline-tcar17-acc focus-visible:outline-offset-2 motion-reduce:transition-none motion-reduce:hover:scale-100">&lsaquo;</a>
      <a href="#tcar-17-s4" aria-label="Next" class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-auto size-11 flex items-center justify-center rounded-full bg-white text-tcar17-acc text-[26px] font-bold no-underline shadow-[0_8px_20px_-8px_oklch(0.35_0.1_265/0.6)] transition-[scale] duration-200 hover:scale-[1.08] focus-visible:outline-3 focus-visible:outline-tcar17-acc focus-visible:outline-offset-2 motion-reduce:transition-none motion-reduce:hover:scale-100">&rsaquo;</a>
    </nav>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-tcar17-bg: #eef2fb;
  --color-tcar17-bg: oklch(0.95 0.015 265);
  --color-tcar17-acc: #2563eb;
  --color-tcar17-acc: oklch(0.52 0.2 262);
  --font-tcar17-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}

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

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

.tcar17-scroller::-webkit-scrollbar {
  display: none;
}
/* CSS Carousel scroll buttons: no utility form, so the whole control lives here. */

.tcar17-scroller::scroll-button(left),
.tcar17-scroller::scroll-button(right) {
  position: absolute;
  top: 50%;
  translate: 0 -50%;
  z-index: 3;
  width: 44px;
  height: 44px;
  border: 0;
  border-radius: 50%;
  cursor: pointer;
  background: #fff;
  color: var(--color-tcar17-acc);
  font-size: 22px;
  font-weight: 700;
  line-height: 1;
  box-shadow: 0 8px 20px -8px oklch(0.35 0.1 265 / 0.6);
  transition: scale .2s, opacity .2s;
}

.tcar17-scroller::scroll-button(left) {
  content: "\2039";
  left: 12px;
}

.tcar17-scroller::scroll-button(right) {
  content: "\203A";
  right: 12px;
}

.tcar17-scroller::scroll-button(left):hover,
.tcar17-scroller::scroll-button(right):hover {
  scale: 1.08;
}

.tcar17-scroller::scroll-button(left):disabled,
.tcar17-scroller::scroll-button(right):disabled {
  opacity: .35;
  cursor: default;
}

.tcar17-scroller::scroll-button(left):focus-visible,
.tcar17-scroller::scroll-button(right):focus-visible {
  outline: 3px solid var(--color-tcar17-acc);
  outline-offset: 2px;
}
/* One rule decides which control set the user sees. */

@supports selector(::scroll-button(right)) {
  .tcar17-fallback {
    display: none;
  }
}

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

  .tcar17-scroller::scroll-button(left),
    .tcar17-scroller::scroll-button(right) {
    transition: none;
  }
}
```

How this works

The browser builds the buttons. ::scroll-button(left) and ::scroll-button(right) render genuine focusable buttons that page the snap scroller and disable themselves at each end — behaviour that has always required a script and an IntersectionObserver.

Two control sets, one visible. The markup also carries anchor arrows. @supports selector(::scroll-button(right)) hides them wherever the pseudo-element exists, so the user never sees four arrows and never sees none.

The fallback is the Tailwind part. Everything about the anchor arrows — size, shape, shadow, hover, focus ring — is utilities. Everything about the native buttons is CSS. The demo is a clean illustration of where the line sits.

Make it yours

  • Glyphs: the content in the two ::scroll-button() rules and the text of the two anchors.
  • Arrow position: left-3 / right-3 on the fallback, left:12px / right:12px in the pseudo rules.
  • Step size: slide width plus snap-align.
  • Colour: --color-tcar17-acc, read by both control sets.

Gotchas — read before shipping

  • ::scroll-button() is Chromium 135+. Ship the fallback or most of the world gets a gallery with no visible controls.
  • A ::scroll-button() with empty content has no accessible name. Give it a glyph, or an aria-label via content: "\203A" / "Next".
  • Keep both control sets styled identically, or the fallback looks like a different component to the users who get it.

Browser support

ChromeSafariFirefoxEdge
135+n/an/a135+

::scroll-button() is Chromium 135+. The anchor-link arrows provide a universal fallback at Tailwind v4's usual floor, and @supports selector() picks between them.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Search CodeFronts

Loading…