Tailwind + JSMIT licensed

Tailwind Before / After Image Comparison Slider

A draggable divider that wipes between 'before' and 'after' versions of one photograph. A native range input is the control — so keyboard, screen reader and touch support arrive for free — and three lines of JS write its value into one custom property that CSS reads.

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-18 w-full min-h-svh grid place-items-center bg-tcar18-bg font-tcar18-sans px-5 py-10" aria-label="Before and after comparison">
  <figure style="--pos:50" class="group tcar18-cmp relative w-[min(620px,100%)] aspect-[3/2] m-0 rounded-2xl overflow-hidden select-none shadow-[0_26px_50px_-32px_oklch(0_0_0/0.9)]">
    <img src="https://picsum.photos/seed/edit-shot/900/600" width="900" height="600" decoding="async" alt="After: colour-graded edit" class="absolute inset-0 size-full object-cover block">
    <img src="https://picsum.photos/seed/edit-shot/900/600?grayscale" width="900" height="600" decoding="async" alt="Before: original photo" class="absolute inset-0 size-full object-cover block [clip-path:inset(0_0_0_calc(var(--pos)*1%))]">
    <input type="range" min="0" max="100" value="50" aria-label="Reveal amount: drag to compare before and after" class="peer absolute inset-0 size-full m-0 opacity-0 cursor-ew-resize z-[3]">
    <span aria-hidden="true" class="absolute inset-y-0 left-[calc(var(--pos)*1%)] w-0.5 -translate-x-px bg-white pointer-events-none shadow-[0_0_0_1px_oklch(0_0_0/0.25)] peer-focus-visible:shadow-[0_0_0_3px_var(--color-tcar18-acc)]">
      <span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-11 rounded-full bg-white text-tcar18-acc flex items-center justify-center text-base font-bold shadow-[0_4px_12px_oklch(0_0_0/0.4)] group-has-[input:focus-visible]:shadow-[0_0_0_3px_var(--color-tcar18-acc),0_4px_12px_oklch(0_0_0/0.4)]">&#8660;</span>
    </span>
    <span aria-hidden="true" class="absolute bottom-3.5 left-3.5 px-3 py-1.5 rounded-full text-[11px] font-bold tracking-[0.08em] uppercase text-white bg-tcar18-badge backdrop-blur-[4px]">Before</span>
    <span aria-hidden="true" class="absolute bottom-3.5 right-3.5 px-3 py-1.5 rounded-full text-[11px] font-bold tracking-[0.08em] uppercase text-white bg-tcar18-badge backdrop-blur-[4px]">After</span>
  </figure>
</section>
@import "tailwindcss";

@theme {
  --color-tcar18-bg: #0d1412;
  --color-tcar18-bg: oklch(0.18 0.018 165);
  --color-tcar18-acc: #10b981;
  --color-tcar18-acc: oklch(0.7 0.15 165);
  --color-tcar18-badge: #0a0e0d;
  --color-tcar18-badge: oklch(0.16 0.01 165 / 0.66);
  --font-tcar18-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}

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

.tcar-18 ::selection {
  background: var(--color-tcar18-acc);
  color: #04201a;
}
// Three lines: the range's value becomes --pos, and CSS does the rest.
document.querySelectorAll('.tcar18-cmp').forEach((cmp) => {
  const range = cmp.querySelector('input[type="range"]');
  const sync = () => cmp.style.setProperty('--pos', range.value);
  range.addEventListener('input', sync);
  sync();
});

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-tcar18-bg: #0d1412;
  --color-tcar18-bg: oklch(0.18 0.018 165);
  --color-tcar18-acc: #10b981;
  --color-tcar18-acc: oklch(0.7 0.15 165);
  --color-tcar18-badge: #0a0e0d;
  --color-tcar18-badge: oklch(0.16 0.01 165 / 0.66);
  --font-tcar18-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}

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

.tcar-18 ::selection {
  background: var(--color-tcar18-acc);
  color: #04201a;
}
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 one demo in the collection where peer is correct — for one of the two elements. The divider is a sibling of the range and uses peer-focus-visible:; the knob is a grandchild and uses group-has-[input:focus-visible]:. Read it next to demo 01: same library, same state problem, and the DOM relationship alone decides the variant.
  • --pos stays a plain custom property rather than an @theme entry, because it is runtime state, not a design token. Putting it in @theme would mint utilities nobody wants.
  • UI change from the CSS original: the handle knob grew from 38px to size-11 (44px) for WCAG 2.5.5, and the badge background moved to a token so both pills stay identical. The divider's focus indicator now also applies to the knob, so keyboard focus is visible wherever the eye happens to be.
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 Before / After Image Comparison Slider
Source: https://codefronts.com/snippets/tailwind-css-image-carousel/tailwind-before-after-image-comparison-slider/

A draggable divider that wipes between 'before' and 'after' versions of one photograph. A native range input is the control — so keyboard, screen reader and touch support arrive for free — and three lines of JS write its value into one custom property that CSS reads.
## HTML
```html
<section class="tcar-18 w-full min-h-svh grid place-items-center bg-tcar18-bg font-tcar18-sans px-5 py-10" aria-label="Before and after comparison">
  <figure style="--pos:50" class="group tcar18-cmp relative w-[min(620px,100%)] aspect-[3/2] m-0 rounded-2xl overflow-hidden select-none shadow-[0_26px_50px_-32px_oklch(0_0_0/0.9)]">
    <img src="https://picsum.photos/seed/edit-shot/900/600" width="900" height="600" decoding="async" alt="After: colour-graded edit" class="absolute inset-0 size-full object-cover block">
    <img src="https://picsum.photos/seed/edit-shot/900/600?grayscale" width="900" height="600" decoding="async" alt="Before: original photo" class="absolute inset-0 size-full object-cover block [clip-path:inset(0_0_0_calc(var(--pos)*1%))]">
    <input type="range" min="0" max="100" value="50" aria-label="Reveal amount: drag to compare before and after" class="peer absolute inset-0 size-full m-0 opacity-0 cursor-ew-resize z-[3]">
    <span aria-hidden="true" class="absolute inset-y-0 left-[calc(var(--pos)*1%)] w-0.5 -translate-x-px bg-white pointer-events-none shadow-[0_0_0_1px_oklch(0_0_0/0.25)] peer-focus-visible:shadow-[0_0_0_3px_var(--color-tcar18-acc)]">
      <span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-11 rounded-full bg-white text-tcar18-acc flex items-center justify-center text-base font-bold shadow-[0_4px_12px_oklch(0_0_0/0.4)] group-has-[input:focus-visible]:shadow-[0_0_0_3px_var(--color-tcar18-acc),0_4px_12px_oklch(0_0_0/0.4)]">&#8660;</span>
    </span>
    <span aria-hidden="true" class="absolute bottom-3.5 left-3.5 px-3 py-1.5 rounded-full text-[11px] font-bold tracking-[0.08em] uppercase text-white bg-tcar18-badge backdrop-blur-[4px]">Before</span>
    <span aria-hidden="true" class="absolute bottom-3.5 right-3.5 px-3 py-1.5 rounded-full text-[11px] font-bold tracking-[0.08em] uppercase text-white bg-tcar18-badge backdrop-blur-[4px]">After</span>
  </figure>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-tcar18-bg: #0d1412;
  --color-tcar18-bg: oklch(0.18 0.018 165);
  --color-tcar18-acc: #10b981;
  --color-tcar18-acc: oklch(0.7 0.15 165);
  --color-tcar18-badge: #0a0e0d;
  --color-tcar18-badge: oklch(0.16 0.01 165 / 0.66);
  --font-tcar18-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}

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

.tcar-18 ::selection {
  background: var(--color-tcar18-acc);
  color: #04201a;
}
```

## JavaScript
```js
// Three lines: the range's value becomes --pos, and CSS does the rest.
document.querySelectorAll('.tcar18-cmp').forEach((cmp) => {
  const range = cmp.querySelector('input[type="range"]');
  const sync = () => cmp.style.setProperty('--pos', range.value);
  range.addEventListener('input', sync);
  sync();
});
```

How this works

One variable, two consumers. The range's value becomes --pos; the top image clips to it and the divider positions to it. Nothing is measured per frame and nothing is laid out — clip-path and left on an absolutely-positioned 2px line are both cheap.

Do not rebuild dragging. The native range gives arrow-key nudging, Home/End jumps, a spoken value and touch dragging. A hand-rolled pointerdown implementation gives you none of that and a week of bug reports.

This is where peer belongs — and where it stops. The divider is a following sibling of the range, so peer-focus-visible: styles it directly. The knob inside the divider is not a sibling, so it falls back to group-has-[input:focus-visible]:. Both variants in one component, chosen by DOM relationship rather than preference — the clearest statement of the rule in the collection.

Make it yours

  • Starting split: the input's value and the inline --pos, kept in step.
  • Vertical wipe: clip on the Y axis and rotate the range.
  • Handle: it is an ordinary absolutely-positioned element, styled with utilities.
  • Badges: the two pill labels, repositionable without touching the wipe.

Gotchas — read before shipping

  • Keep the real range input. Every accessibility property of this component comes from it.
  • Both images need identical dimensions and one locked aspect-[3/2], or the wipe line does not correspond to the same point in both photographs.
  • The inline style="--pos:50" must match the input's value="50"; the JS syncs them on load, but a mismatch flashes on first paint.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

clip-path:inset() and range inputs are universal; the only JS is a three-line value-to-custom-property sync. The floor quoted is Tailwind v4's generated sheet.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Theme tokensset in @theme, used by a utility

Arbitraryneeds bracket syntax

Search CodeFronts

Loading…