Tailwind Slider: 16 Range Slider and Carousel Examples

Search "Tailwind slider" and you get two things: carousels that scroll through slides, and range inputs where you drag a thumb. Tailwind CSS ships neither, so the behavior comes from CSS scroll snap, a script or a component kit.

Below are 16 Tailwind slider examples of both types, followed by a guide to the classes, libraries and accessibility rules.

By Bogdan Sandu ยท Updated October 29, 2026

Every example is built with Tailwind CSS v4.3 utility classes. To try one quickly, add the Play CDN tags below to your page and paste the example's HTML, CSS and JS. The dark variant line makes dark: classes follow a .dark class instead of the system setting. For production, install Tailwind with Vite or the CLI.

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
  @custom-variant dark (&:where(.dark, .dark *));
</style>

Range sliders on the native input

Six range controls built on input type="range": a one-class accent-* recolor, a custom thumb with a filled track and value bubble, datalist tick marks, a stepped pricing slider, a two-handle price filter and vertical equalizer bands.

Native ranges recolored with accent-*

  • accent-*
  • <output>
  • tabular-nums
  • aria-valuetext

Drag a handle, or focus it and use the arrow keys, Home and End.

Each slider stays a fully native control and gets its color from one accent-* class (accent-indigo-600, accent-emerald-600, accent-rose-600), so the browser keeps drawing the thumb, the track and the focus ring. A short script writes the value into a linked output element, sets aria-valuetext with the unit and applies the setting to the preview paragraph.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
  <h3 class="text-lg font-semibold text-slate-900">Reading preferences</h3>
  <p class="mt-1 text-sm text-slate-500">Changes apply to the preview below.</p>
  <div class="mt-6 space-y-5">
    <div>
      <div class="flex items-center justify-between">
        <label for="tsd-ac-size" class="text-sm font-medium text-slate-800">Text size</label>
        <output id="tsd-ac-size-out" for="tsd-ac-size" class="text-sm text-slate-600 tabular-nums">17 px</output>
      </div>
      <input id="tsd-ac-size" type="range" min="14" max="22" step="1" value="17" data-out="tsd-ac-size-out" data-unit=" px" data-css="px" data-prop="fontSize" class="tsd-ac mt-2 block w-full cursor-pointer accent-indigo-600">
    </div>
    <div>
      <div class="flex items-center justify-between">
        <label for="tsd-ac-leading" class="text-sm font-medium text-slate-800">Line height</label>
        <output id="tsd-ac-leading-out" for="tsd-ac-leading" class="text-sm text-slate-600 tabular-nums">1.6</output>
      </div>
      <input id="tsd-ac-leading" type="range" min="1.2" max="2" step="0.1" value="1.6" data-out="tsd-ac-leading-out" data-unit="" data-css="" data-prop="lineHeight" class="tsd-ac mt-2 block w-full cursor-pointer accent-emerald-600">
    </div>
    <div>
      <div class="flex items-center justify-between">
        <label for="tsd-ac-measure" class="text-sm font-medium text-slate-800">Line length</label>
        <output id="tsd-ac-measure-out" for="tsd-ac-measure" class="text-sm text-slate-600 tabular-nums">60 ch</output>
      </div>
      <input id="tsd-ac-measure" type="range" min="30" max="75" step="5" value="60" data-out="tsd-ac-measure-out" data-unit=" ch" data-css="ch" data-prop="maxWidth" class="tsd-ac mt-2 block w-full cursor-pointer accent-rose-600">
    </div>
  </div>
  <p id="tsd-ac-preview" class="mt-6 rounded-xl bg-slate-50 p-4 text-slate-700">The ferry leaves the harbor at seven. By noon the fjord walls close in on both sides and the engine is the only sound for miles.</p>
</div>

Custom thumb with a filled track and value bubble

  • appearance-none
  • [&::-webkit-slider-thumb]:
  • [&::-moz-range-thumb]:
  • w-[calc()]

Drag a handle, or focus it and use the arrow keys, Home and End.

appearance-none clears the native look, and the [&::-webkit-slider-thumb]: and [&::-moz-range-thumb]: arbitrary variants style the thumb once per engine. The filled part is a plain span under a transparent input: the script writes the position (0 to 1) into --tsd-p, and w-[calc(10px_+_var(--tsd-p)_*_(100%_-_20px))] keeps the fill and the bubble centered on the 20px thumb in every browser, with no gradient and no ::-moz-range-progress.

View code
<div class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8">
  <label for="tsd-bd-range" class="text-base font-semibold text-slate-900">What is your project budget?</label>
  <p id="tsd-bd-help" class="mt-1 text-sm text-slate-500">Drag the handle or use the arrow keys. Steps of $100.</p>
  <div class="tsd-bd relative mt-14 h-5 [--tsd-p:0.3684]">
    <span class="tsd-bd-bubble pointer-events-none absolute bottom-full left-[calc(10px_+_var(--tsd-p)_*_(100%_-_20px))] mb-3 -translate-x-1/2 rounded-lg bg-slate-900 px-2.5 py-1 text-sm font-semibold whitespace-nowrap text-white tabular-nums after:absolute after:top-full after:left-1/2 after:-ml-1.5 after:border-x-6 after:border-t-6 after:border-x-transparent after:border-t-slate-900" aria-hidden="true">$4,000</span>
    <span class="absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-violet-100" aria-hidden="true"></span>
    <span class="absolute top-1/2 left-0 h-2 w-[calc(10px_+_var(--tsd-p)_*_(100%_-_20px))] -translate-y-1/2 rounded-full bg-violet-600" aria-hidden="true"></span>
    <input id="tsd-bd-range" type="range" min="500" max="10000" step="100" value="4000" aria-describedby="tsd-bd-help" class="relative block h-5 w-full cursor-pointer appearance-none rounded-full bg-transparent focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-violet-600 [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-violet-600 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:shadow-md [&::-moz-range-track]:bg-transparent [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-violet-600 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md">
  </div>
  <div class="mt-3 flex justify-between text-xs font-medium text-slate-500"><span>$500</span><span>$10,000+</span></div>
  <p class="mt-6 rounded-xl bg-violet-50 px-4 py-3 text-sm text-violet-950">Typical scope: <span id="tsd-bd-tier" class="font-semibold">Small business site, 5 to 10 pages</span></p>
</div>

Thermostat with datalist tick marks

  • accent-orange-600
  • <datalist>
  • stepUp()
  • tabular-nums

Drag a handle, or focus it and use the arrow keys, Home and End.

The list attribute points the range at a datalist, and Chromium, Firefox and Safari draw a tick for each option on the native track (tick marks disappear once you set appearance-none, so this one keeps the native look with accent-orange-600). The visible degree labels are a justify-between row hidden from screen readers. The minus and plus buttons call stepDown() and stepUp(), so they respect min, max and the 0.5 step.

View code
<div class="w-full max-w-sm rounded-3xl bg-orange-50 p-6 ring-1 ring-orange-200">
  <div class="flex items-start justify-between gap-4">
    <div>
      <h3 class="font-semibold text-stone-900">Living room</h3>
      <p class="text-sm text-stone-600">Heating, schedule paused</p>
    </div>
    <svg class="size-7 text-orange-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3c1 3.5 5 5.5 5 10a5 5 0 0 1-10 0c0-2 1-3.5 2-4.5.5 1.5 1.5 2.5 2.5 2.5C11 8.5 11 6 12 3Z"/></svg>
  </div>
  <div class="mt-6 flex items-center justify-between gap-3">
    <button type="button" data-dir="down" aria-label="Lower temperature" class="tsd-th-step grid size-11 shrink-0 cursor-pointer place-items-center rounded-full bg-white text-stone-800 ring-1 ring-orange-200 hover:bg-orange-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-700"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" d="M5 12h14"/></svg></button>
    <p class="text-center"><output id="tsd-th-out" for="tsd-th" class="text-6xl font-light tracking-tight text-stone-900 tabular-nums">21.5</output><span class="align-top text-2xl text-stone-600">&deg;C</span><span id="tsd-th-mode" class="block text-sm font-semibold text-orange-800">Comfort</span></p>
    <button type="button" data-dir="up" aria-label="Raise temperature" class="tsd-th-step grid size-11 shrink-0 cursor-pointer place-items-center rounded-full bg-white text-stone-800 ring-1 ring-orange-200 hover:bg-orange-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-700"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" d="M12 5v14M5 12h14"/></svg></button>
  </div>
  <label for="tsd-th" class="sr-only">Target temperature</label>
  <input id="tsd-th" type="range" min="16" max="28" step="0.5" value="21.5" list="tsd-th-marks" class="mt-6 block w-full cursor-pointer accent-orange-600">
  <datalist id="tsd-th-marks"><option value="16"></option><option value="18"></option><option value="20"></option><option value="22"></option><option value="24"></option><option value="26"></option><option value="28"></option></datalist>
  <div class="mt-1 flex justify-between px-0.5 text-xs text-stone-600 tabular-nums" aria-hidden="true"><span>16&deg;</span><span>18&deg;</span><span>20&deg;</span><span>22&deg;</span><span>24&deg;</span><span>26&deg;</span><span>28&deg;</span></div>
</div>

Stepped pricing slider with labels

  • data-reached:
  • left-[calc()]
  • aria-valuetext
  • appearance-none

Drag a handle, or focus it and use the arrow keys, Home and End.

The input runs from 0 to 5 in whole steps, and the script maps each step to a seat count (1, 5, 10, 25, 50, 100), because a range can only move in equal steps. aria-valuetext tells screen readers "25 seats" instead of "3". Stop dots switch color through the data-reached: variant, and each label is positioned with left-[calc(12px_+_(100%_-_24px)*n)] so it sits under its stop.

View code
<div class="w-full max-w-lg rounded-2xl bg-stone-50 p-6 ring-1 ring-stone-200 sm:p-8">
  <div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
    <h3 class="text-lg font-semibold text-stone-900">Team plan</h3>
    <p id="tsd-sp-rate" class="text-sm text-stone-600">$9 per seat</p>
  </div>
  <label for="tsd-sp-range" class="mt-5 block text-sm font-medium text-stone-700">How many seats? <span id="tsd-sp-seats" class="font-semibold text-emerald-800">25 seats</span></label>
  <div class="tsd-sp relative mt-4 h-6 [--tsd-p:0.6]">
    <span class="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-stone-200" aria-hidden="true"></span>
    <span class="absolute top-1/2 left-0 h-1.5 w-[calc(12px_+_var(--tsd-p)_*_(100%_-_24px))] -translate-y-1/2 rounded-full bg-emerald-700" aria-hidden="true"></span>
    <span class="absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-between px-2" aria-hidden="true">
      <span class="tsd-sp-stop size-2 rounded-full bg-stone-400 data-reached:bg-white"></span><span class="tsd-sp-stop size-2 rounded-full bg-stone-400 data-reached:bg-white"></span><span class="tsd-sp-stop size-2 rounded-full bg-stone-400 data-reached:bg-white"></span><span class="tsd-sp-stop size-2 rounded-full bg-stone-400 data-reached:bg-white"></span><span class="tsd-sp-stop size-2 rounded-full bg-stone-400 data-reached:bg-white"></span><span class="tsd-sp-stop size-2 rounded-full bg-stone-400 data-reached:bg-white"></span>
    </span>
    <input id="tsd-sp-range" type="range" min="0" max="5" step="1" value="3" class="relative block h-6 w-full cursor-pointer appearance-none rounded-full bg-transparent focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-emerald-700 [&::-moz-range-thumb]:size-6 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-4 [&::-moz-range-thumb]:border-emerald-700 [&::-moz-range-thumb]:bg-white [&::-moz-range-track]:bg-transparent [&::-webkit-slider-thumb]:size-6 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-4 [&::-webkit-slider-thumb]:border-emerald-700 [&::-webkit-slider-thumb]:bg-white">
  </div>
  <div class="relative mt-2 h-6 text-xs font-medium text-stone-600 tabular-nums">
    <button type="button" tabindex="-1" data-index="0" aria-label="Set 1 seat" class="tsd-sp-label absolute left-[12px] -translate-x-1/2 cursor-pointer rounded px-1 hover:text-stone-900">1</button>
    <button type="button" tabindex="-1" data-index="1" aria-label="Set 5 seats" class="tsd-sp-label absolute left-[calc(12px_+_(100%_-_24px)*0.2)] -translate-x-1/2 cursor-pointer rounded px-1 hover:text-stone-900">5</button>
    <button type="button" tabindex="-1" data-index="2" aria-label="Set 10 seats" class="tsd-sp-label absolute left-[calc(12px_+_(100%_-_24px)*0.4)] -translate-x-1/2 cursor-pointer rounded px-1 hover:text-stone-900">10</button>
    <button type="button" tabindex="-1" data-index="3" aria-label="Set 25 seats" class="tsd-sp-label absolute left-[calc(12px_+_(100%_-_24px)*0.6)] -translate-x-1/2 cursor-pointer rounded px-1 hover:text-stone-900">25</button>
    <button type="button" tabindex="-1" data-index="4" aria-label="Set 50 seats" class="tsd-sp-label absolute left-[calc(12px_+_(100%_-_24px)*0.8)] -translate-x-1/2 cursor-pointer rounded px-1 hover:text-stone-900">50</button>
    <button type="button" tabindex="-1" data-index="5" aria-label="Set 100 seats" class="tsd-sp-label absolute left-[calc(100%_-_12px)] -translate-x-1/2 cursor-pointer rounded px-1 hover:text-stone-900">100</button>
  </div>
  <div class="mt-6 flex flex-wrap items-end justify-between gap-4 border-t border-stone-200 pt-5">
    <p class="text-stone-600"><span id="tsd-sp-total" class="text-4xl font-semibold tracking-tight text-stone-900 tabular-nums">$225</span> / month</p>
    <a href="#" class="inline-flex h-11 items-center rounded-lg bg-emerald-700 px-5 text-sm font-semibold text-white hover:bg-emerald-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700">Start 14-day trial</a>
  </div>
</div>

Dual-handle price range

  • pointer-events-none
  • [&::-webkit-slider-thumb]:pointer-events-auto
  • focus-within:
  • left-[calc()]

Drag a handle, or focus it and use the arrow keys, Home and End.

A native range has one thumb, so this stacks two inputs on the same track. pointer-events-none on the inputs and [&::-webkit-slider-thumb]:pointer-events-auto (plus the -moz twin) let each thumb catch the pointer while clicks pass through the rest. The script keeps the handles at least $100 apart, paints the span between them from --tsd-lo and --tsd-hi, and syncs the number fields both ways.

View code
<fieldset class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
  <legend class="sr-only">Price range</legend>
  <div class="flex items-baseline justify-between gap-4">
    <h3 class="text-base font-semibold text-slate-900">Price</h3>
    <p class="text-sm text-slate-600"><span id="tsd-dual-count" class="font-semibold text-slate-900">102</span> bikes match</p>
  </div>
  <div id="tsd-dual-track" class="relative mt-6 h-6 [--tsd-hi:0.7] [--tsd-lo:0.2]">
    <span class="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-slate-200" aria-hidden="true"></span>
    <span class="absolute top-1/2 right-[calc(12px_+_(1_-_var(--tsd-hi))_*_(100%_-_24px))] left-[calc(12px_+_var(--tsd-lo)_*_(100%_-_24px))] h-1.5 -translate-y-1/2 rounded-full bg-rose-600" aria-hidden="true"></span>
    <input id="tsd-dual-min" type="range" min="0" max="5000" step="50" value="1000" aria-label="Minimum price" class="pointer-events-none absolute inset-0 h-6 w-full appearance-none bg-transparent focus-visible:outline-hidden [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-6 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-rose-600 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:shadow [&::-moz-range-track]:bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:size-6 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-rose-600 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow [&:focus-visible::-moz-range-thumb]:ring-4 [&:focus-visible::-moz-range-thumb]:ring-rose-300 [&:focus-visible::-webkit-slider-thumb]:ring-4 [&:focus-visible::-webkit-slider-thumb]:ring-rose-300">
    <input id="tsd-dual-max" type="range" min="0" max="5000" step="50" value="3500" aria-label="Maximum price" class="pointer-events-none absolute inset-0 h-6 w-full appearance-none bg-transparent focus-visible:outline-hidden [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-6 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-rose-600 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:shadow [&::-moz-range-track]:bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:size-6 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-rose-600 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow [&:focus-visible::-moz-range-thumb]:ring-4 [&:focus-visible::-moz-range-thumb]:ring-rose-300 [&:focus-visible::-webkit-slider-thumb]:ring-4 [&:focus-visible::-webkit-slider-thumb]:ring-rose-300">
  </div>
  <div class="mt-5 grid grid-cols-2 gap-3">
    <label class="block">
      <span class="text-xs font-medium text-slate-600">Min</span>
      <span class="mt-1 flex items-center rounded-lg ring-1 ring-slate-300 focus-within:ring-2 focus-within:ring-rose-600"><span class="pl-3 text-slate-500" aria-hidden="true">$</span><input id="tsd-dual-min-n" type="number" inputmode="numeric" min="0" max="5000" step="50" value="1000" class="h-11 w-full min-w-0 bg-transparent pr-3 pl-1 text-slate-900 tabular-nums outline-hidden"></span>
    </label>
    <label class="block">
      <span class="text-xs font-medium text-slate-600">Max</span>
      <span class="mt-1 flex items-center rounded-lg ring-1 ring-slate-300 focus-within:ring-2 focus-within:ring-rose-600"><span class="pl-3 text-slate-500" aria-hidden="true">$</span><input id="tsd-dual-max-n" type="number" inputmode="numeric" min="0" max="5000" step="50" value="3500" class="h-11 w-full min-w-0 bg-transparent pr-3 pl-1 text-slate-900 tabular-nums outline-hidden"></span>
    </label>
  </div>
</fieldset>

Vertical equalizer with writing-mode

  • [writing-mode:vertical-lr]
  • [direction:rtl]
  • accent-lime-400
  • aria-pressed:

Drag a handle, or focus it and use the arrow keys, Home and End.

Tailwind has no writing-mode utility, so the arbitrary properties [writing-mode:vertical-lr] and [direction:rtl] stand each native range upright with the maximum at the top. Vertical range controls via writing-mode work in Chrome 124+, Firefox 120+ and Safari 17.4+. The preset buttons are toggle buttons styled through aria-pressed:, and any manual change clears them.

View code
<div class="w-full max-w-md rounded-2xl bg-zinc-900 p-5 text-zinc-100 shadow-lg sm:p-6">
  <div class="flex flex-wrap items-center justify-between gap-3">
    <h3 class="font-semibold">Equalizer</h3>
    <div class="flex rounded-lg bg-zinc-800 p-1 text-sm" role="group" aria-label="Presets">
      <button type="button" data-preset="flat" aria-pressed="true" class="tsd-eq-preset h-9 cursor-pointer rounded-md px-3 text-zinc-300 hover:text-white aria-pressed:bg-lime-400 aria-pressed:font-semibold aria-pressed:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-lime-300">Flat</button>
      <button type="button" data-preset="bass" aria-pressed="false" class="tsd-eq-preset h-9 cursor-pointer rounded-md px-3 text-zinc-300 hover:text-white aria-pressed:bg-lime-400 aria-pressed:font-semibold aria-pressed:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-lime-300">Bass</button>
      <button type="button" data-preset="vocal" aria-pressed="false" class="tsd-eq-preset h-9 cursor-pointer rounded-md px-3 text-zinc-300 hover:text-white aria-pressed:bg-lime-400 aria-pressed:font-semibold aria-pressed:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-lime-300">Vocal</button>
    </div>
  </div>
  <div class="mt-6 grid grid-cols-5 gap-2 text-center">
    <div><output id="tsd-eq-o1" for="tsd-eq-1" class="block text-xs text-zinc-300 tabular-nums">0 dB</output><input id="tsd-eq-1" data-out="tsd-eq-o1" type="range" min="-12" max="12" step="1" value="0" class="tsd-eq-band mx-auto mt-2 block h-36 w-7 cursor-pointer accent-lime-400 [direction:rtl] [writing-mode:vertical-lr]"><label for="tsd-eq-1" class="mt-2 block text-xs font-medium text-zinc-400">60 Hz</label></div>
    <div><output id="tsd-eq-o2" for="tsd-eq-2" class="block text-xs text-zinc-300 tabular-nums">0 dB</output><input id="tsd-eq-2" data-out="tsd-eq-o2" type="range" min="-12" max="12" step="1" value="0" class="tsd-eq-band mx-auto mt-2 block h-36 w-7 cursor-pointer accent-lime-400 [direction:rtl] [writing-mode:vertical-lr]"><label for="tsd-eq-2" class="mt-2 block text-xs font-medium text-zinc-400">250 Hz</label></div>
    <div><output id="tsd-eq-o3" for="tsd-eq-3" class="block text-xs text-zinc-300 tabular-nums">0 dB</output><input id="tsd-eq-3" data-out="tsd-eq-o3" type="range" min="-12" max="12" step="1" value="0" class="tsd-eq-band mx-auto mt-2 block h-36 w-7 cursor-pointer accent-lime-400 [direction:rtl] [writing-mode:vertical-lr]"><label for="tsd-eq-3" class="mt-2 block text-xs font-medium text-zinc-400">1 kHz</label></div>
    <div><output id="tsd-eq-o4" for="tsd-eq-4" class="block text-xs text-zinc-300 tabular-nums">0 dB</output><input id="tsd-eq-4" data-out="tsd-eq-o4" type="range" min="-12" max="12" step="1" value="0" class="tsd-eq-band mx-auto mt-2 block h-36 w-7 cursor-pointer accent-lime-400 [direction:rtl] [writing-mode:vertical-lr]"><label for="tsd-eq-4" class="mt-2 block text-xs font-medium text-zinc-400">4 kHz</label></div>
    <div><output id="tsd-eq-o5" for="tsd-eq-5" class="block text-xs text-zinc-300 tabular-nums">0 dB</output><input id="tsd-eq-5" data-out="tsd-eq-o5" type="range" min="-12" max="12" step="1" value="0" class="tsd-eq-band mx-auto mt-2 block h-36 w-7 cursor-pointer accent-lime-400 [direction:rtl] [writing-mode:vertical-lr]"><label for="tsd-eq-5" class="mt-2 block text-xs font-medium text-zinc-400">16 kHz</label></div>
  </div>
</div>

Dark mode, comparison and CSS-only sliders

Sliders inside the interfaces where they usually live: media controls that follow dark mode, a photo editor with its own theme switch, a before and after comparison and a slideshow driven by radio inputs alone.

Volume and brightness controls in dark mode

  • dark:
  • has-[input:focus-visible]:
  • mix-blend-difference
  • group-aria-pressed:

Each pill is a transparent full-size range input over a fill span whose width comes from --tsd-p, so the whole bar is the hit area. The label and value use mix-blend-difference, which keeps them readable over both the filled and empty parts, and has-[input:focus-visible]: draws the focus ring on the pill. Every color has a dark: twin (bg-white dark:bg-zinc-900, fill slate-900 / white), so the same markup works on light pages.

View code
<div class="w-full max-w-sm rounded-3xl bg-white p-5 shadow-sm ring-1 ring-slate-200 dark:bg-zinc-900 dark:ring-white/10">
  <div class="flex items-center gap-3">
    <img src="jellyfish.webp" width="800" height="600" alt="Album cover: orange jellyfish in deep blue water" class="size-14 shrink-0 rounded-xl object-cover">
    <div class="min-w-0 flex-1">
      <p class="truncate font-semibold text-slate-900 dark:text-white">Deep Current</p>
      <p class="truncate text-sm text-slate-600 dark:text-zinc-400">Lumen Drift</p>
    </div>
    <button type="button" id="tsd-mc-mute" aria-pressed="false" aria-label="Mute" class="group grid size-11 shrink-0 cursor-pointer place-items-center rounded-full bg-slate-100 text-slate-900 hover:bg-slate-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 aria-pressed:bg-rose-600 aria-pressed:text-white dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:aria-pressed:bg-rose-500">
      <svg class="size-5 group-aria-pressed:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M11 5 6 9H3v6h3l5 4V5Zm4.5 3.5a5 5 0 0 1 0 7M18 6a8.5 8.5 0 0 1 0 12"/></svg>
      <svg class="hidden size-5 group-aria-pressed:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M11 5 6 9H3v6h3l5 4V5Zm5 5 5 5m0-5-5 5"/></svg>
    </button>
  </div>
  <div class="mt-5 space-y-3">
    <div class="tsd-pill relative h-14 overflow-hidden rounded-2xl bg-slate-100 [--tsd-p:0.6] has-[input:focus-visible]:outline-2 has-[input:focus-visible]:outline-offset-2 has-[input:focus-visible]:outline-sky-500 dark:bg-zinc-800">
      <span class="absolute inset-y-0 left-0 w-[calc(var(--tsd-p)*100%)] bg-slate-900 dark:bg-white" aria-hidden="true"></span>
      <span class="pointer-events-none absolute inset-y-0 left-4 flex items-center gap-2 text-sm font-semibold text-white mix-blend-difference" aria-hidden="true"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M11 5 6 9H3v6h3l5 4V5Zm4.5 3.5a5 5 0 0 1 0 7"/></svg>Volume</span>
      <span class="tsd-pill-out pointer-events-none absolute inset-y-0 right-4 flex items-center text-sm font-semibold text-white tabular-nums mix-blend-difference" aria-hidden="true">60%</span>
      <input id="tsd-mc-volume" type="range" min="0" max="100" value="60" aria-label="Volume" class="absolute inset-0 size-full cursor-pointer appearance-none opacity-0">
    </div>
    <div class="tsd-pill relative h-14 overflow-hidden rounded-2xl bg-slate-100 [--tsd-p:0.8] has-[input:focus-visible]:outline-2 has-[input:focus-visible]:outline-offset-2 has-[input:focus-visible]:outline-sky-500 dark:bg-zinc-800">
      <span class="absolute inset-y-0 left-0 w-[calc(var(--tsd-p)*100%)] bg-slate-900 dark:bg-white" aria-hidden="true"></span>
      <span class="pointer-events-none absolute inset-y-0 left-4 flex items-center gap-2 text-sm font-semibold text-white mix-blend-difference" aria-hidden="true"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="4"/><path stroke-linecap="round" d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>Brightness</span>
      <span class="tsd-pill-out pointer-events-none absolute inset-y-0 right-4 flex items-center text-sm font-semibold text-white tabular-nums mix-blend-difference" aria-hidden="true">80%</span>
      <input type="range" min="0" max="100" value="80" aria-label="Brightness" class="absolute inset-0 size-full cursor-pointer appearance-none opacity-0">
    </div>
  </div>
</div>

Photo adjustment sliders with a dark mode switch

  • dark:
  • brightness-(--tsd-b)
  • aria-checked:
  • group-aria-checked:

Drag the sliders, then flip the Dark switch.

The switch adds or removes the dark class on the wrapper, and every color carries a dark: twin, including the thumb through dark:[&::-webkit-slider-thumb]:border-sky-400. The photo uses brightness-(--tsd-b), contrast-(--tsd-c) and saturate-(--tsd-s): Tailwind v4 filter utilities that read CSS variables, so the script only writes three percentages. The switch is a button with role="switch" whose knob moves through group-aria-checked:translate-x-5.

View code
<div id="tsd-ed" class="w-full max-w-3xl">
  <div class="grid gap-5 rounded-2xl bg-white p-4 shadow-sm ring-1 ring-slate-200 md:grid-cols-[1.4fr_1fr] md:p-5 dark:bg-slate-900 dark:ring-white/10">
    <img id="tsd-ed-img" src="canal-houses.webp" width="800" height="600" alt="Old houses along a canal with small boats moored at a wooden jetty" class="aspect-[4/3] w-full rounded-xl object-cover brightness-(--tsd-b) contrast-(--tsd-c) saturate-(--tsd-s) [--tsd-b:100%] [--tsd-c:100%] [--tsd-s:100%]">
    <div class="flex flex-col">
      <div class="flex items-center justify-between gap-3">
        <h3 class="font-semibold text-slate-900 dark:text-white">Adjust</h3>
        <button type="button" id="tsd-ed-theme" role="switch" aria-checked="false" class="group flex h-11 cursor-pointer items-center gap-2 rounded-full px-1 text-sm font-medium text-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 dark:text-slate-200">Dark<span class="relative h-6 w-11 rounded-full bg-slate-300 group-aria-checked:bg-sky-500 motion-safe:transition-colors"><span class="absolute top-0.5 left-0.5 size-5 rounded-full bg-white shadow group-aria-checked:translate-x-5 motion-safe:transition-transform"></span></span></button>
      </div>
      <div class="mt-4 space-y-4">
        <div>
          <div class="flex justify-between text-sm"><label for="tsd-ed-b" class="font-medium text-slate-800 dark:text-slate-200">Exposure</label><output id="tsd-ed-bo" for="tsd-ed-b" class="text-slate-600 tabular-nums dark:text-slate-400">100%</output></div>
          <div class="relative mt-2 h-5 [--tsd-p:0.5]">
            <span class="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-slate-200 dark:bg-slate-700" aria-hidden="true"></span>
            <span class="absolute top-1/2 left-0 h-1.5 w-[calc(10px_+_var(--tsd-p)_*_(100%_-_20px))] -translate-y-1/2 rounded-full bg-slate-900 dark:bg-sky-400" aria-hidden="true"></span>
            <input id="tsd-ed-b" type="range" min="50" max="150" value="100" data-var="--tsd-b" data-out="tsd-ed-bo" class="tsd-ed-range relative block h-5 w-full cursor-pointer appearance-none rounded-full bg-transparent focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-sky-500 [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-slate-900 [&::-moz-range-thumb]:bg-white [&::-moz-range-track]:bg-transparent [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-slate-900 [&::-webkit-slider-thumb]:bg-white dark:[&::-moz-range-thumb]:border-sky-400 dark:[&::-moz-range-thumb]:bg-slate-900 dark:[&::-webkit-slider-thumb]:border-sky-400 dark:[&::-webkit-slider-thumb]:bg-slate-900">
          </div>
        </div>
        <div>
          <div class="flex justify-between text-sm"><label for="tsd-ed-c" class="font-medium text-slate-800 dark:text-slate-200">Contrast</label><output id="tsd-ed-co" for="tsd-ed-c" class="text-slate-600 tabular-nums dark:text-slate-400">100%</output></div>
          <div class="relative mt-2 h-5 [--tsd-p:0.5]">
            <span class="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-slate-200 dark:bg-slate-700" aria-hidden="true"></span>
            <span class="absolute top-1/2 left-0 h-1.5 w-[calc(10px_+_var(--tsd-p)_*_(100%_-_20px))] -translate-y-1/2 rounded-full bg-slate-900 dark:bg-sky-400" aria-hidden="true"></span>
            <input id="tsd-ed-c" type="range" min="50" max="150" value="100" data-var="--tsd-c" data-out="tsd-ed-co" class="tsd-ed-range relative block h-5 w-full cursor-pointer appearance-none rounded-full bg-transparent focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-sky-500 [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-slate-900 [&::-moz-range-thumb]:bg-white [&::-moz-range-track]:bg-transparent [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-slate-900 [&::-webkit-slider-thumb]:bg-white dark:[&::-moz-range-thumb]:border-sky-400 dark:[&::-moz-range-thumb]:bg-slate-900 dark:[&::-webkit-slider-thumb]:border-sky-400 dark:[&::-webkit-slider-thumb]:bg-slate-900">
          </div>
        </div>
        <div>
          <div class="flex justify-between text-sm"><label for="tsd-ed-s" class="font-medium text-slate-800 dark:text-slate-200">Saturation</label><output id="tsd-ed-so" for="tsd-ed-s" class="text-slate-600 tabular-nums dark:text-slate-400">100%</output></div>
          <div class="relative mt-2 h-5 [--tsd-p:0.5]">
            <span class="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-slate-200 dark:bg-slate-700" aria-hidden="true"></span>
            <span class="absolute top-1/2 left-0 h-1.5 w-[calc(10px_+_var(--tsd-p)_*_(100%_-_20px))] -translate-y-1/2 rounded-full bg-slate-900 dark:bg-sky-400" aria-hidden="true"></span>
            <input id="tsd-ed-s" type="range" min="0" max="200" value="100" data-var="--tsd-s" data-out="tsd-ed-so" class="tsd-ed-range relative block h-5 w-full cursor-pointer appearance-none rounded-full bg-transparent focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-sky-500 [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-slate-900 [&::-moz-range-thumb]:bg-white [&::-moz-range-track]:bg-transparent [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-slate-900 [&::-webkit-slider-thumb]:bg-white dark:[&::-moz-range-thumb]:border-sky-400 dark:[&::-moz-range-thumb]:bg-slate-900 dark:[&::-webkit-slider-thumb]:border-sky-400 dark:[&::-webkit-slider-thumb]:bg-slate-900">
          </div>
        </div>
      </div>
      <button type="button" id="tsd-ed-reset" class="mt-5 h-11 w-full cursor-pointer rounded-lg text-sm font-semibold text-slate-800 ring-1 ring-slate-300 hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 md:mt-auto dark:text-slate-100 dark:ring-slate-600 dark:hover:bg-slate-800">Reset</button>
    </div>
  </div>
</div>

Before and after comparison slider

  • [clip-path:inset()]
  • left-(--tsd-pos)
  • grayscale
  • has-[input:focus-visible]:

Drag across the photo, or focus it and use the arrow keys.

Two copies of the photo sit on top of each other: the top one is grayscale and clipped with [clip-path:inset(0_calc(100%_-_var(--tsd-pos))_0_0)], and the divider and handle sit at left-(--tsd-pos). A transparent range input covers the whole image, so dragging anywhere moves the split, and arrow keys move it in 1% steps. has-[input:focus-visible]: puts the focus ring on the frame because the input itself is invisible.

View code
<figure class="w-full max-w-2xl">
  <div class="tsd-ba relative aspect-[4/3] overflow-hidden rounded-2xl bg-slate-200 [--tsd-pos:50%] has-[input:focus-visible]:outline-2 has-[input:focus-visible]:outline-offset-4 has-[input:focus-visible]:outline-indigo-600">
    <img src="coast-road.webp" width="800" height="600" alt="Pine-covered cliffs above a calm sea, with a narrow road winding along the coastline at sunset" class="absolute inset-0 size-full object-cover">
    <img src="coast-road.webp" width="800" height="600" alt="" class="absolute inset-0 size-full object-cover grayscale [clip-path:inset(0_calc(100%_-_var(--tsd-pos))_0_0)]">
    <span class="pointer-events-none absolute inset-y-0 left-(--tsd-pos) w-0.5 -translate-x-1/2 bg-white" aria-hidden="true"></span>
    <span class="pointer-events-none absolute top-1/2 left-(--tsd-pos) grid size-11 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-white text-slate-900 shadow-lg" aria-hidden="true"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 7l-5 5 5 5M15 7l5 5-5 5"/></svg></span>
    <span class="pointer-events-none absolute top-3 left-3 rounded-full bg-black/70 px-2.5 py-1 text-xs font-semibold text-white" aria-hidden="true">Before</span>
    <span class="pointer-events-none absolute top-3 right-3 rounded-full bg-black/70 px-2.5 py-1 text-xs font-semibold text-white" aria-hidden="true">After</span>
    <input type="range" min="0" max="100" value="50" aria-label="Before and after split" class="absolute inset-0 size-full cursor-ew-resize appearance-none bg-transparent opacity-0">
  </div>
  <figcaption class="mt-3 text-sm text-slate-600">Before: the black and white scan. After: the color restoration.</figcaption>
</figure>

CSS-only slideshow on radio inputs

  • group-has-[#id:checked]:
  • peer-checked:
  • peer-focus-visible:
  • motion-safe:transition-transform

Click a dot, or tab to the dots and use the arrow keys.

No script at all: three visually hidden radio inputs drive the slideshow. group-has-[#tsd-rp-2:checked]:-translate-x-full on the strip moves it one slide over, and peer-checked: inside each label widens the matching dot. Because the radios share a name, arrow keys switch slides once a dot has focus. The trade-offs are real: the slide count is fixed in the markup and there is no swipe.

View code
<div class="group w-full max-w-xl">
  <div class="overflow-hidden rounded-2xl bg-slate-900">
    <div class="flex group-has-[#tsd-rp-2:checked]:-translate-x-full group-has-[#tsd-rp-3:checked]:-translate-x-[200%] motion-safe:transition-transform motion-safe:duration-500 motion-safe:ease-out">
      <div class="relative w-full shrink-0">
        <img src="jellyfish.webp" width="800" height="600" alt="Orange jellyfish with long trailing tentacles in deep blue water" class="aspect-video w-full object-cover">
        <p class="absolute bottom-3 left-3 rounded-lg bg-black/70 px-3 py-1.5 text-sm font-semibold text-white">Aquarium nights</p>
      </div>
      <div class="relative w-full shrink-0">
        <img src="brick-alley.webp" width="800" height="600" alt="Narrow brick alley with fire escapes between old buildings" class="aspect-video w-full object-cover">
        <p class="absolute bottom-3 left-3 rounded-lg bg-black/70 px-3 py-1.5 text-sm font-semibold text-white">Old town alleys</p>
      </div>
      <div class="relative w-full shrink-0">
        <img src="mountain-hiker.webp" width="800" height="600" alt="Hiker with a large backpack looking over a hazy mountain valley" class="aspect-video w-full object-cover">
        <p class="absolute bottom-3 left-3 rounded-lg bg-black/70 px-3 py-1.5 text-sm font-semibold text-white">Into the mountains</p>
      </div>
    </div>
  </div>
  <fieldset class="mt-3 flex justify-center gap-1">
    <legend class="sr-only">Choose a slide</legend>
    <label class="flex h-8 cursor-pointer items-center px-1.5"><input type="radio" name="tsd-rp" id="tsd-rp-1" class="peer sr-only" checked><span class="block h-2.5 w-2.5 rounded-full bg-slate-300 peer-checked:w-6 peer-checked:bg-slate-900 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-4 peer-focus-visible:outline-slate-900 motion-safe:transition-all"></span><span class="sr-only">Aquarium nights</span></label>
    <label class="flex h-8 cursor-pointer items-center px-1.5"><input type="radio" name="tsd-rp" id="tsd-rp-2" class="peer sr-only"><span class="block h-2.5 w-2.5 rounded-full bg-slate-300 peer-checked:w-6 peer-checked:bg-slate-900 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-4 peer-focus-visible:outline-slate-900 motion-safe:transition-all"></span><span class="sr-only">Old town alleys</span></label>
    <label class="flex h-8 cursor-pointer items-center px-1.5"><input type="radio" name="tsd-rp" id="tsd-rp-3" class="peer sr-only"><span class="block h-2.5 w-2.5 rounded-full bg-slate-300 peer-checked:w-6 peer-checked:bg-slate-900 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-4 peer-focus-visible:outline-slate-900 motion-safe:transition-all"></span><span class="sr-only">Into the mountains</span></label>
  </fieldset>
</div>

What is a Tailwind slider?

The name covers two components that have little in common. Carousels put slides in a row and let the visitor move between them, which is how hero banners, testimonials, product rows and image galleries work. Range sliders are input type=”range” elements, where a thumb moves along a track to pick a value, like a price filter or a volume control.

A carousel is mostly a layout and scrolling problem. The range slider is a form control, so the two rarely share any code.

Most visitors know the first type as a carousel interface, a row of slides you move through by swipe or with arrow buttons, and sometimes dots.

Tailwind is a utility-first framework, so it sets widths, gaps, snap alignment and colors but adds no interaction. Arrows and dots need code you write or import, and so does autoplay.

So decide on the type first, since the rest follows from that.

Which Tailwind classes control scroll snap?

The container gets snap-x plus snap-mandatory or snap-proximity, and each slide gets snap-start, snap-center or snap-end.

ClassGoes onCSS it sets
snap-xContainerscroll-snap-type: x var(–tw-scroll-snap-strictness)
snap-mandatory, snap-proximityContainer–tw-scroll-snap-strictness
snap-start, snap-center, snap-endEach slidescroll-snap-align
snap-alwaysEach slidescroll-snap-stop: always

According to Tailwind’s documentation, snapping doesn’t start until the children carry an alignment class. So a snap-x container whose slides have no snap-start does nothing.

With snap-mandatory the container always comes to rest on a snap point. snap-proximity only settles on one when a snap point is close, and the CSS Scroll Snap spec makes proximity the default when no strictness is given.

snap-start suits rows that show several slides at once. For one-slide sliders, where the active slide should sit in the middle, snap-center is the pick.

Flex lays the slides in a row and overflow-x-auto makes that row scrollable. Add shrink-0 so flexbox doesn’t squeeze the slides narrower, basis-* to set how wide each one is, and scroll-smooth to animate programmatic scrolling.

The snap-always class forces a stop at every slide, so a hard flick can’t skip past several. caniuse lists support for scroll-snap-stop: always from Chrome 75, Firefox 103 and Safari 15. MDN cautions that it can also keep visitors from scrolling quickly to the content they want.

For the core properties, MDN marks scroll-snap-type as Baseline widely available, with support across browsers since April 2022.

How to build a Tailwind slider without JavaScript

Classes alone are enough for a working slider.

  1. Create the track: a flex container with overflow-x-auto, snap-x and snap-mandatory (or snap-proximity).
  2. Add spacing: gap-4 between slides, plus px-4 and scroll-px-4 so the first slide does not sit flush against the edge.
  3. Size each slide: shrink-0 keeps it from compressing, snap-start sets the snap point, and a basis class sets its width.
  4. Hide the scrollbar if the design calls for it, with scrollbar-none in current Tailwind v4 or the arbitrary properties [scrollbar-width:none] and [&::-webkit-scrollbar]:hidden.
  5. Add scroll-smooth, then test touch swipe, mouse wheel and keyboard scrolling on a real phone.
<div class="flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-smooth px-4 scroll-px-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
  <div class="shrink-0 basis-[85%] snap-start">Slide 1</div>
  <div class="shrink-0 basis-[85%] snap-start">Slide 2</div>
  <div class="shrink-0 basis-[85%] snap-start">Slide 3</div>
</div>

The 85% basis leaves a sliver of the next slide on screen. With the scrollbar hidden, that sliver is the only hint that the row scrolls.

Touch, wheel and keyboard scrolling come from the browser, not from your code. Bootstrap’s 6.0 alpha documentation lists CSS scroll snap as the feature behind its own Bootstrap carousel, for exactly that reason.

Setting slides per view at each breakpoint

Slides per view change through prefixed basis classes. Tailwind’s mobile-first approach applies unprefixed classes at every width, so the default is the phone layout and each prefix overrides it upward.

ViewportSlides visibleSlide class
Default1, with a peek of the nextbasis-[85%]
md and up2md:basis-[calc((100%_-_1rem)/2)]
lg and up3lg:basis-[calc((100%_-_2rem)/3)]

The subtraction accounts for gap-4, which is 1rem on the default spacing scale. Plain basis-1/2 ignores the gap, so two slides plus the gap overflow the container and the second one gets clipped.

How to add arrows, dots and autoplay

Scroll snap includes none of these, so each one is a bit of script. Arrows are a scrollBy() call on the track. Autoplay is a timer that fires that same scroll function. Dots take more work, since the active slide has to be read from an IntersectionObserver.

Arrow buttons

MDN documents scrollBy() on the Element interface with a left offset and a behavior option, and behavior: “smooth” animates the move.

Pass the track’s clientWidth as left for the next button and the negative value for the previous one.

One catch. A full-width step skips slides when several are visible, so step by one slide’s offsetWidth plus the gap instead.

Dot indicators

Observe every slide with the track as the root and a threshold above 0.5, then mark the dot of the intersecting slide as active. Clicking a dot calls scrollIntoView() on the matching slide with inline set to start.

Set block to nearest in that call so the page does not jump vertically. A data-active attribute and a Tailwind data variant style the active dot without extra class juggling.

Autoplay

Autoplay is a timer, not a scroll snap feature.

A setInterval call triggers the same next-slide function the arrow uses. Resetting scrollLeft to 0 at the last slide gives a rewind, not a true loop.

It also needs a pause mechanism. According to the W3C Web Accessibility Initiative, WCAG success criterion 2.2.2 requires one for content that moves automatically, lasts more than five seconds and sits beside other content.

Stop the timer on pointerenter and focusin. Skip it entirely when matchMedia reports prefers-reduced-motion, and add motion-reduce:scroll-auto to turn off scroll-smooth for those visitors.

Native scroll buttons and markers

Chrome 135 added the ::scroll-button() and ::scroll-marker() pseudo-elements, which turn a scroll container into a carousel with arrows and dots written in CSS alone, per Chrome for Developers (March 2025). Each scroll button pages 85% of the scroll area.

Chrome 135 and Edge 135 support them. Firefox and Safari do not.

That matches the support box in the article as of March 2025, and MDN’s compatibility data in October 2026 still shows no support for either pseudo-element in Firefox or Safari. Chromium-only support makes it an enhancement on top of the JavaScript controls, not a replacement for them.

When does a scroll snap slider stop working?

Scroll snap breaks down in a few places.

Infinite loops are the first. A scroll container has two ends, so looping needs script that repositions or duplicates slides. Autoplay isn’t built in either, as covered above.

Tracking the active slide is the second. Outside Chromium’s ::scroll-marker, CSS exposes no current-slide state, so dots and counters need IntersectionObserver code.

Mandatory snapping is the third. MDN warns that content larger than you expect can become impossible to scroll into view, and advises mandatory only where you know how much content sits on screen. Keep mandatory for slides you size yourself, and switch to snap-proximity when slide width is unknown.

Fixed-width card rows and product strips are fine on scroll snap, and so are image galleries that only need swipe.

Reach for a library when the slider must loop, when thumbnails or a counter have to stay in sync, when autoplay needs fine control over timing and pausing, or when slide content changes at runtime.

A pure CSS carousel built from the classes above handles the first group with no script at all.

Once the design lands in the second group, a JavaScript carousel library earns its bundle size. For a plain row of cards, skip it.

Which library or component kit fits a Tailwind slider?

Embla Carousel sits best with a utility-class workflow. Swiper is the one for feature-heavy sliders, and teams that want accessibility handled for them tend to land on Splide. If you’d rather not write markup at all, daisyUI, Flowbite and Preline UI cover that.

What decides it is the gap you’re filling, whether that’s loops, synced controls, effects or fine autoplay timing.

JavaScript libraries compared

LibraryFramework supportStandout
SwiperReact and Vue components, Swiper Element (web component)Module system: navigation, pagination, autoplay, loop, virtual slides
Embla CarouselReact, Vue, Svelte, Solid packagesHeadless core extended by plugins such as Autoplay
SplideReact and Vue wrappersAccessibility layer, extensions for Grid, Video and Auto Scroll
Keen SliderVanilla JS, React, Vue, AngularNative-feeling touch, free-scroll mode
Glide.jsVanilla JS, modular ESM buildSmall core with opt-in Controls and Breakpoints modules

All five are MIT licensed, so weight and features decide, not licensing.

Sizes below are gzipped, as each project publishes them.

  • Keen Slider: about 5.5 KB (Keen Slider README)
  • Embla Carousel 8.6.0: 6.9 KB, exactly 6,914 bytes (Bundlephobia)
  • Glide.js: about 8 KB on its documentation site, about 7 KB in its README
  • Splide: 12 KB (Splide README)
  • Swiper: about 20 KB with only navigation and pagination (Swiper’s Embla comparison page)

The sources disagree a bit. Swiper’s Embla comparison page puts Embla’s core at 3 to 5 KB gzipped, while Bundlephobia measures the embla-carousel 8.6.0 package at 6.9 KB, and Glide.js quotes two different weights on its own site and README. Swiper’s Keen Slider comparison page also puts the same navigation-and-pagination setup near 28 KB, not 20 KB.

None of those figures says whether plugins or framework wrappers are counted. Measure your own build before picking a winner over a 2 KB gap.

Stylesheets matter more in a Tailwind project than a few kilobytes. Swiper’s examples import swiper/css and Glide’s setup links glide.core.min.css, so both bring class-based CSS that has to coexist with your utilities. Embla needs only a viewport, a container and the slides themselves, and flex and basis classes cover that layout.

Component kits with ready-made sliders

daisyUI has carousel and carousel-item classes, snap modifiers (carousel-start, carousel-center, carousel-end) and carousel-vertical, and no script is involved. Flowbite uses data-carousel=”slide” to cycle items automatically and “static” to hold them still, with Flowbite’s JavaScript file driving the controls and indicators. Preline UI is the one with the @preline/carousel plugin, which adds slide counters and thumbnails, and its isSnap option pairs with snap-x and snap-mandatory on the container, according to the current v5.0 docs.

A prebuilt Tailwind UI kit saves the markup work. The price is a slider tied to that kit’s class names or data attributes.

On the plus side you get markup and Tailwind classes ready to paste, and Flowbite and Preline UI come with controls and indicators already wired. daisyUI adds no JavaScript at all.

The downsides are that those class names or data attributes lock the slider to one kit, and Flowbite and Preline UI ship their own scripts. Preline UI’s package lists MIT plus a Preline UI Fair Use License, so read the terms before a commercial launch.

If the project already runs daisyUI, its carousel is the same scroll snap idea with names attached. Reach for Flowbite or Preline UI only when you want their controls without writing the script yourself.

How to set up a Tailwind slider in React, Vue and Alpine.js

React and Vue projects usually wrap a library. Alpine.js can drive a scroll snap track directly, and plain HTML just needs a script tag and the same utility classes.

FrameworkOptionEntry point
ReactEmbla Carouselembla-carousel-react, useEmblaCarousel hook
ReactSwiperswiper/react components or Swiper Element
VueEmbla Carouselembla-carousel-vue, useEmblaCarousel
Alpine.jsScroll snap trackx-data, x-ref, Intersect plugin
Plain HTMLAny libraryCDN or npm script plus markup

In React, install embla-carousel-react, attach the ref from useEmblaCarousel to the outer element, and put the layout in utilities. Embla’s React guide says to keep navigation buttons outside the viewport to avoid drag conflicts.

<div className="overflow-hidden" ref={emblaRef}>
<div className="flex">
<div className="min-w-0 flex-[0_0_100%]">Slide 1</div>
<div className="min-w-0 flex-[0_0_100%]">Slide 2</div>
</div>
</div>

New to the Tailwind side of that stack? Setting up Tailwind CSS inside a React project comes first, and the slider code does not change after that.

Mantine’s Carousel component runs on Embla and expects embla-carousel-react installed beside it (Mantine docs), so a Mantine project already has the engine.

Swiper in React and Vue is a little messier. Swiper’s v14 release notes (June 2026) still list swiper/react and swiper/vue next to swiper/element, with imports unchanged from v12. Ionic’s docs describe the React component as set for removal in a future Swiper release, and Swiper’s Svelte components were already dropped in v9.

For new code, Swiper Element is the safer Swiper entry point.

Alpine.js needs no package for the track itself. Alpine’s Intersect plugin wraps IntersectionObserver, and its .half modifier fires once half of the element is visible.

<div x-data="{ active: 0 }">
<div x-ref="track" class="flex snap-x snap-mandatory overflow-x-auto">
<div x-intersect.half="active = 0" class="w-full shrink-0 snap-start">Slide 1</div>
<div x-intersect.half="active = 1" class="w-full shrink-0 snap-start">Slide 2</div>
</div>
<button @click="$refs.track.scrollBy({ left: $refs.track.clientWidth, behavior: 'smooth' })">Next</button>
</div>

Tailwind generates only the classes it finds in scanned files. When a kit’s JavaScript injects Tailwind classes, register its path with @source, which is how Preline’s setup docs handle it.

How to make a Tailwind slider accessible

The ARIA Authoring Practices carousel pattern is the reference here. The carousel region gets a name, every slide gets a group role and a position label, controls are native buttons, and dot pickers shouldn’t flood the tab order.

<section aria-roledescription="carousel" aria-label="Featured products">
  <div tabindex="0" aria-live="polite" class="flex snap-x snap-mandatory overflow-x-auto">
    <div role="group" aria-roledescription="slide" aria-label="1 of 4" class="w-full shrink-0 snap-start">...</div>
    <div role="group" aria-roledescription="slide" aria-label="2 of 4" class="w-full shrink-0 snap-start">...</div>
  </div>
  <button type="button" aria-label="Previous slide" class="focus-visible:ring-2">...</button>
  <button type="button" aria-label="Next slide" class="focus-visible:ring-2">...</button>
</section>

The tabindex=”0″ on the track gives keyboard users a way to focus it and scroll with the arrow keys.

The carousel takes an aria-label, or aria-labelledby when a visible heading exists. Slides are named by position, such as “1 of 4”, without the word “slide”, because aria-roledescription already says it. The live region should be polite while the carousel is still and off while it rotates. And since aria-roledescription values are written in the page language, translate them.

Native buttons carry keyboard behavior for free, which is why the pattern prefers them to div elements dressed up with ARIA roles.

Dots built as separate buttons add one tab stop per slide. The pattern calls that grouped style the least friendly for keyboard users and offers a tabbed variant that uses the tabs pattern for a single tab stop.

Splide v4 follows the W3C carousel pattern and adds a live region that announces slide changes, per Splide’s accessibility guide. Its i18n option translates the default button text.

On the Swiper side, the A11y module adds roles, labels and a live region, set to polite normally and off when autoplay runs, with slides defaulting to role group. It is opt-in, so import A11y and pass it in modules.

Keyboard handling can misfire on busy pages. Accessible360’s Splide carousel boilerplate turns Splide’s keyboard option off, with a code comment that Splide listens for key events at the document level and moves every carousel on the page when arrow keys are used. Splide v4 already changed that option’s default from true to false, so check which version you ship.

How to style a Tailwind range slider

For a quick recolor, an accent-* class on the native input does it in one step. accent-blue-500, accent-rose-500 or any palette color sets the accent-color property, and Tailwind’s docs show the utility taking hover, focus and breakpoint variants, such as md:accent-pink-500. Which parts of the control take the color varies by browser.

When the design needs its own shape, use appearance-none with arbitrary variants on the thumb and track pseudo-elements.

<input type="range" min="0" max="100" value="40"
  class="w-full cursor-pointer appearance-none bg-transparent
  [&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-slate-200
  [&::-webkit-slider-thumb]:-mt-1.5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-indigo-600
  [&::-moz-range-track]:h-2 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-slate-200
  [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-indigo-600" />

Material Tailwind’s React Slider styles its thumb and track through the same arbitrary variants, including a negative top margin on the thumb.

Custom styling tends to break in a few predictable ways.

  • Set appearance-none on the input and the thumb first, or the pseudo-element styles do not apply
  • Write every rule twice, once per engine, because one unknown pseudo-element in a combined selector invalidates the whole rule
  • Offset the WebKit thumb with a negative margin (-mt-1.5 here) when the thumb is taller than the track
  • Firefox exposes ::-moz-range-progress for the filled part, while WebKit and Blink have no counterpart, so a filled track there needs a gradient or an extra element driven by a CSS variable

daisyUI skips all of that. Its range class takes color modifiers such as range-primary, sizes from range-xs to range-xl, and variables like –range-thumb and –range-fill for overrides.

A native input has exactly one thumb, so dual-handle sliders need two stacked inputs or a library.

noUiSlider is dependency-free and MIT licensed, with ARIA and keyboard support per its npm README, and start: [20, 80] with connect: true produces a two-handle range. Material Tailwind’s v3 docs include a range slider with two thumbs.

When the design stays simple, a plain CSS range slider on the native input is still the better build, because the browser supplies the keyboard and screen reader behavior.

Tailwind Slider FAQ

How do you lazy load images in a Tailwind slider?

Put loading=”lazy” on every img after the first slide, and leave the first visible one eager so it is not delayed.

For finer control, Alpine’s x-intersect.once modifier can swap in the real image source when a slide scrolls into view.

Which classes turn the same track into a testimonial or hero slider?

Give each slide basis-full and snap-center so only one shows at a time, then change what’s inside.

Testimonials are quote cards with padding and centered text, while a hero slide is a full-width image with object-cover and a heading laid over it.

Is Swiper free to use in a Tailwind project?

Yes. The swiper package is MIT licensed, which covers commercial projects.

Swiper Studio, the visual builder, and the premium templates are separate products sold apart from the library.

Do the slider classes differ between Tailwind v3 and v4?

No. The snap-x, snap-mandatory, snap-start and basis-* utilities read the same in both versions.

Setup is where they differ. Tailwind v4 moves theme configuration into CSS through the @theme directive, and a kit’s JavaScript needs an @source line, as Preline’s docs show.

How do you add a thumbnail gallery to a Tailwind slider?

Place a second row of thumbnail buttons under the main track. Each click calls scrollIntoView() on the matching slide.

The IntersectionObserver that drives the dots can highlight the active thumbnail too. Splide and Preline UI both ship thumbnail sliders ready-made.

Can a slider run on radio inputs and peer-checked alone?

Yes. Hidden radio inputs plus Tailwind’s peer-checked variant switch slides with no script, as Cruip’s CSS-only card slider demonstrates.

The slide count is hard-wired into the markup, and swipe gestures do not work the way they do on a scroll snap track.

Choosing between scroll snap and a library

A Tailwind slider should begin as a plain scroll snap track and move to a library only when the missing features outnumber what a few lines of script can cover.

Build the track with utilities first, patch single gaps with small scripts, and replace the track once looping or synced thumbnails show up.

Switching isn’t free. By the roughly 20 KB figure Swiper publishes against Embla, its navigation-and-pagination build weighs roughly 13 KB more than Embla Carousel 8.6.0, nearly three times as much, and Swiper’s own Keen Slider page cites a heavier setup near 28 KB.

Library markup also differs from a native track, so a late switch means rewriting slide wrappers and controls.

That threshold moves up if Firefox and Safari add the ::scroll-button and ::scroll-marker support that Chrome for Developers listed for Chrome and Edge in March 2025, which MDN’s compatibility data shows they have not yet done as of October 2026.

Slide content comes next, and Tailwind card layouts give each slide a structure worth scrolling to.