Build a Snap-Scrolling Carousel with Tailwind v4 (No JS)

Build a Tailwind scroll snap carousel with zero JavaScript: snap-x, snap-center, smooth scrolling, and the new v4.3 scrollbar-none utility. Copy-paste Blade.

Steven Richardson
Steven Richardson
· 7 min read

Every few months I open a component that pulls in Embla or Swiper to render a row of six cards. That is a JavaScript dependency, a bundle-size hit, and a pile of ARIA the library got half-right — all for horizontal scrolling the browser already does. A Tailwind scroll snap carousel does the same job with snap-x, snap-center, and a scroll container. No library, no useEffect, no hydration. If the whole set already fits on screen you probably want a static subgrid-aligned card grid instead — but when the row overflows and you want it to swipe and snap, this is the build.

Set up the carousel's scroll container#

A carousel is just a horizontal row you can scroll. Start with a flex row that scrolls on the x-axis and slides that refuse to shrink. No snapping yet — get the overflow behaviour right first, then layer snapping on top.

{{-- resources/views/components/carousel.blade.php --}}
<div class="flex gap-4 overflow-x-auto">
    @foreach ($slides as $slide)
        <div class="shrink-0 w-72">
            <img
                src="{{ $slide->image }}"
                alt="{{ $slide->title }}"
                class="h-44 w-72 rounded-xl object-cover"
            />
        </div>
    @endforeach
</div>

overflow-x-auto makes the row scroll horizontally once its children overflow. flex keeps the slides on one line, and shrink-0 stops flexbox from squashing them to fit — each stays a fixed w-72 (18rem). gap-4 spaces them out. Trackpad and touch dragging already work; the scroll just stops wherever your finger lifts.

Turn on horizontal snapping with snap-x#

Now add snapping to the container. snap-x enables horizontal scroll snapping, and snap-mandatory forces the scroll to always come to rest on a slide instead of stopping halfway between two. The strictness classes are composable — drop snap-mandatory and you get proximity snapping, which only snaps when you are already close to a slide.

<div class="flex gap-4 overflow-x-auto snap-x snap-mandatory">
    {{-- slides --}}
</div>

snap-x sets scroll-snap-type: x and snap-mandatory sets the strictness to mandatory. Nothing snaps yet, because the slides themselves have no snap alignment — that is the next step. One trade-off to bank now: mandatory snapping is assertive. If a slide is wider than the container, or you have free-flowing content between snap points, snap-mandatory can trap the user or make content unreachable. For a row of equal-width cards it is exactly what you want.

Align each slide with snap-center#

Snapping needs two halves: a snap type on the parent and a snap alignment on each child. snap-center rests each slide in the middle of the container; snap-start aligns it to the leading edge. Centre feels like a gallery, start feels like a feed — pick whichever matches the design.

<div class="flex gap-4 overflow-x-auto snap-x snap-mandatory">
    @foreach ($slides as $slide)
        <div class="shrink-0 w-72 snap-center">
            {{-- ... --}}
        </div>
    @endforeach
</div>

snap-center maps to scroll-snap-align: center; swap in snap-start for scroll-snap-align: start. That pair — snap-x on the container and a snap-* alignment on every child — is the whole trick. How many slides show at once is just the slide width. If this component gets reused in both a narrow sidebar and a full-width page, size the slides with container queries so it adapts to its container rather than the viewport.

Inset the slides with scroll padding#

Right now the first slide sits flush against the container's left edge, which looks cramped. Scroll padding fixes the resting offset. scroll-ps-6 pushes every snap position in from the start so slides breathe instead of kissing the edge.

<div class="flex gap-4 overflow-x-auto snap-x snap-mandatory scroll-ps-6">
    {{-- slides --}}
</div>

scroll-ps-6 sets scroll-padding-inline-start to 1.5rem — the logical-property version of scroll-pl-6 that automatically flips for right-to-left layouts. Use scroll-pl-* directly if you never render RTL. If the carousel is ever deep-linked — you scroll the page to a specific slide and a sticky top bar would cover it — add scroll-mt-* to the slides so the snap position clears the header:

<div class="shrink-0 w-72 snap-center scroll-mt-20">
    {{-- ... --}}
</div>

scroll-mt-20 sets scroll-margin-top to 5rem, so a snapped-to slide parks below a sticky header instead of hiding underneath it.

Hide the scrollbar and smooth the scroll#

A raw horizontal scrollbar under a row of cards looks unfinished. Tailwind v4.3 added a first-party scrollbar-none utility, so you hide it without a plugin or custom CSS. Pair it with scroll-smooth so programmatic scrolls — next/prev buttons or anchor jumps — ease into place instead of teleporting.

<div class="flex gap-4 overflow-x-auto snap-x snap-mandatory scroll-ps-6
            scroll-smooth scrollbar-none">
    {{-- slides --}}
</div>

scrollbar-none maps to scrollbar-width: none and scroll-smooth to scroll-behavior: smooth. Here is the one gotcha worth knowing: scrollbar-width only landed in Safari 18.2 (December 2024). It has shipped in Chrome since 121 and in Firefox for years, so on current browsers scrollbar-none is enough. If you still support Safari older than 18.2, define a custom utility with the @utility directive that adds the WebKit pseudo-element too:

/* resources/css/app.css */
@utility no-scrollbar {
    scrollbar-width: none;            /* Firefox, Chromium, Safari 18.2+ */
    &::-webkit-scrollbar {
        display: none;                /* older WebKit / Safari */
    }
}

Then use no-scrollbar in place of scrollbar-none when you need the older-Safari fallback. One more thing: hiding the scrollbar also hides the "there is more to see" hint. Fade the trailing edge with mask utilities so users still know the row keeps going.

A scroll container is not keyboard-focusable by default in every browser, so keyboard users can be left unable to move through your slides. Add tabindex="0" so the scroller can take focus, plus a label and role so assistive tech announces it as a region rather than an anonymous box.

<div
    role="group"
    aria-roledescription="carousel"
    aria-label="Featured products"
    tabindex="0"
    class="flex gap-4 overflow-x-auto snap-x snap-mandatory scroll-ps-6
           scroll-smooth scrollbar-none
           focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:outline-none"
>
    @foreach ($slides as $slide)
        <div
            class="shrink-0 w-72 snap-center"
            role="group"
            aria-label="{{ $loop->iteration }} of {{ count($slides) }}"
        >
            {{-- ... --}}
        </div>
    @endforeach
</div>

tabindex="0" puts the scroller in the tab order so the arrow keys, Home, and End scroll it. Recent Chrome makes scroll containers keyboard-focusable on their own, but Safari and Firefox do not guarantee it, so tabindex="0" keeps the behaviour portable. focus-visible:ring-* gives keyboard users a visible focus outline, and the role and aria-label attributes describe the carousel and each slide's position. Do not hide the scrollbar and strip every other affordance — keep the focus ring, and consider real prev/next buttons for pointer users.

Ship it and handle the edge cases#

You now have a complete carousel — a flex row, snap-x plus snap-center, scroll padding, a hidden scrollbar, and keyboard access — with not one line of JavaScript. Before it ships, walk the edges that bite in production.

Reserve snap-mandatory for uniform cards. On tall or variable-height content it can strand users between sections; reach for snap-proximity there so the browser only snaps when it is unobtrusive. If users complain that fast flicks skip past several cards, add snap-always to the slides — combined with snap-mandatory it forces a stop on every item. And remember these are Tailwind v4 class names: on v3 the utilities differ and scrollbar-none does not exist, so upgrade from v3 to v4 before you copy any of this in.

The one thing CSS will not do for you is data. If the carousel holds hundreds of slides, do not render them all up front — page them in as the user reaches the end with wire:intersect infinite scroll. The scrolling stays pure CSS; Livewire just feeds it more cards.

FAQ#

How do I make a carousel with only Tailwind CSS?

Put your slides in a flex row that scrolls horizontally — flex gap-4 overflow-x-auto — with shrink-0 on each slide so they keep their width. Add snap-x snap-mandatory to the container and snap-center (or snap-start) to each slide, and the browser snaps to a slide as you scroll. Finish with scroll-smooth and scrollbar-none for polish. There is no JavaScript and no carousel library involved.

What's the difference between snap-x and snap-mandatory?

They control different things and are meant to be combined. snap-x sets the axis — it tells the container to snap horizontally (scroll-snap-type: x). snap-mandatory sets the strictness — it forces the scroll to always come to rest on a snap point rather than stopping in between. If you use snap-x on its own the strictness defaults to proximity, which only snaps when the scroll position is already close to a slide.

How do I center carousel items on snap?

Add snap-center to each slide. It maps to scroll-snap-align: center, so every slide comes to rest in the middle of the scroll container. The container still needs snap-x (or snap-y) for any alignment to take effect — snapping requires both a snap type on the parent and a snap alignment on the children. Use snap-start or snap-end instead if you want slides to align to an edge.

Can I build a Tailwind carousel without JavaScript?

Yes — scrolling, snapping, and smooth animation are all native CSS, so a swipeable, snapping carousel needs no JavaScript at all. What you give up is scripted behaviour: autoplay, dot indicators that track the active slide, and next/prev buttons all need a little JavaScript because they depend on state or timers. A common middle ground is the pure-CSS scroller for the heavy lifting, plus a few lines of Alpine for buttons and dots when the design calls for them.

How do I hide the scrollbar on a Tailwind carousel?

Since Tailwind v4.3 you add the first-party scrollbar-none utility to the scroll container, which sets scrollbar-width: none. That covers Chrome 121+, Firefox, and Safari 18.2 and newer. For older Safari, define a custom @utility that also sets &::-webkit-scrollbar { display: none; }. Keep in mind that a hidden scrollbar removes a visible scroll cue, so pair it with an edge fade or visible buttons so users still know the row scrolls.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.