webdevpuneet.com

CSS Only Before/After Image Slider — Stepped Radio Positions, No JS

Before/After Image Slider — CSS Only Radio Steps (No JavaScript) · Media · Plain HTML & CSS · Live preview

Copy & paste

Source Code

<div class="demo">
  <div class="compare">
    <input type="radio" name="split" id="split0" class="split-input" />
    <input type="radio" name="split" id="split25" class="split-input" />
    <input type="radio" name="split" id="split50" class="split-input" checked />
    <input type="radio" name="split" id="split75" class="split-input" />
    <input type="radio" name="split" id="split100" class="split-input" />

    <div class="compare-frame">
      <div class="layer layer-after">
        <span class="layer-tag tag-after">After</span>
      </div>
      <div class="layer layer-before">
        <span class="layer-tag tag-before">Before</span>
      </div>
      <div class="divider"></div>
    </div>

    <div class="stops" role="group" aria-label="Comparison slider position">
      <div class="stops-track">
        <label for="split0" class="stop" aria-label="Show all before"></label>
        <label for="split25" class="stop" aria-label="25 percent"></label>
        <label for="split50" class="stop" aria-label="50 percent"></label>
        <label for="split75" class="stop" aria-label="75 percent"></label>
        <label for="split100" class="stop" aria-label="Show all after"></label>
        <div class="stops-fill"></div>
        <div class="stops-handle"></div>
      </div>
    </div>
  </div>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Radio-hack state machine with five mutually-exclusive stop positions sharing one name attribute
clip-path: inset() reveals the layered "before" image, animated smoothly between stops via CSS transition
Custom slider-track UI (.stops-fill / .stops-handle) built from styled labels, mirroring the frame's state independently
Same radio state drives two separate visual consumers simultaneously through independent sibling-selector rule blocks
Fully transparent, documented limitation: five fixed stops, not true continuous pointer-driven drag
Zero JavaScript — no clip-path value is ever computed from a live pointer or range value
Bounded aspect-ratio frame sized safely for consistent rendering inside a preview iframe
Keyboard-operable via native radio-group Tab/Arrow-key behavior, no custom key handling needed

About this UI Snippet

Before/After Comparison Slider — Pure CSS Stepped Positions via Radio Inputs and clip-path

Screenshot of the Before/After Image Slider — CSS Only Radio Steps (No JavaScript) snippet rendered live

A true drag-anywhere before/after slider reads the pointer's continuous X position and feeds it into a clip-path percentage every frame — that is fundamentally a JavaScript problem, because CSS has no mechanism to read an arbitrary pointer coordinate and convert it into a style value. This snippet is transparent about that limitation and solves a close, genuinely useful approximation instead: a five-position stepped slider, using the same radio-hack state machine as this batch's star rating and tab switcher.

The real limitation, stated plainly

There is no pure-CSS way to implement true continuous drag-to-reveal comparison. The common workaround people reach for — layering a native <input type="range"> on top and trying to read its value into clip-path — doesn't work either, because attr() only reads static HTML attributes, and a range input's live value while dragging is a DOM property, not an attribute CSS can observe. Any implementation claiming continuous CSS-only drag is either quietly using a few lines of JavaScript or not actually continuous. This snippet picks five fixed stop positions instead — 0%, 25%, 50%, 75%, 100% — and is upfront that this is a stepped approximation, not true pixel-level dragging.

How the split itself works: clip-path on the top layer

Two full-size image layers are stacked with position: absolute; inset: 0 — .layer-after underneath, .layer-before on top. .layer-before is clipped with clip-path: inset(0 <right> 0 0), which cuts away everything past a given distance from the left edge, revealing the .layer-after layer beneath through the clipped-away region. Each of the five radio :checked states sets a different inset() right-offset — checking #split75 sets clip-path: inset(0 25% 0 0), showing 75% of the "before" layer from the left. clip-path transitions smoothly between values, so even though the *positions* are discrete stops, the *motion* between them is a smooth animated slide, not an instant jump.

Why radios, not checkboxes

Exactly one stop can be "current" at a time — the same mutual-exclusivity requirement as the tab switcher and star rating elsewhere in this batch — so the five stop inputs share name="split", guaranteeing the browser enforces single selection with no extra logic.

Two independent visual consumers of one state

The five radios drive two separate sibling subtrees simultaneously: the actual image comparison (.divider position and .layer-before clip) and a separate slider-track UI (.stops-fill width and .stops-handle position) that visually resembles a draggable range control, built from styled <label> stop markers. Both respond to the same five :checked states through independent sibling-selector rule blocks — a single state, multiple styled consumers, which is a recurring pattern across every checkbox/radio-hack snippet in this batch.

Honest comparison to a JS-powered version

A JavaScript implementation reading pointermove events can update clip-path continuously to any arbitrary percentage as the user drags, producing a true 1:1 drag feel. This version snaps to the nearest of five preset positions and animates the transition between them — a meaningfully different, coarser interaction. It is the right choice when "compare roughly how much changed" is enough and zero JavaScript is a hard requirement; it is the wrong choice when users need to precisely land on an arbitrary position, which genuinely requires a few lines of script listening to pointer or range-input events.

Step by step

How to Use

  1. 1
    Replace the two gradient layers with real imagesSwap .layer-after and .layer-before's backgrounds for background-image or nested <img> elements sized to fill the frame identically.
  2. 2
    Keep radios before both consumer blocks in markupThe five .split-input radios must appear before both .compare-frame and .stops in the DOM for the ~ sibling selectors on both to match.
  3. 3
    Add or remove stop positionsEach stop needs a radio, a label in .stops-track, and a matching pair of #splitN:checked rules for the frame and the track visuals — five is a good default, more gives finer steps.
  4. 4
    Set a default positionThe checked attribute on one radio (50% by default here) determines the initial comparison split shown on page load.
  5. 5
    Decide if stepped motion is acceptableIf true continuous drag is required, this pattern is the wrong tool — use a JS pointermove-driven clip-path update instead.

Real-world uses

Common Use Cases

SHOP
Product Before/After Marketing
Skincare, renovation, or editing-tool before/after comparisons on a page rendered inside a script-stripped CMS block
Case Study and Portfolio Pages
Design or photo-editing case studies embedded in Markdown-rendered documentation or portfolio sites
Sandboxed Embed Widgets
A comparison widget rendered inside an iframe sandbox without allow-scripts, where a JS slider could not run at all
Comparison Graphics in HTML Email
A stepped before/after visual in a marketing email, where continuous JS-driven drag is never an option regardless
Learning the clip-path Layering Technique
A clear reference for how inset() clip-path reveals a lower layer, independent of the stepped-vs-continuous tradeoff
Related: Clipboard History Stack Widget
See the Clipboard History Stack Widget for a related misc pattern worth pairing with this one.
Related: Live Viewer Count Badge
See the Live Viewer Count Badge for a related misc pattern worth pairing with this one.
Related: Lorem Ipsum Generator
See the Lorem Ipsum Generator for a related misc pattern worth pairing with this one.
Related: Metronome & Tap Tempo Tool
See the Metronome & Tap Tempo Tool for a related misc pattern worth pairing with this one.
Related: Percentage Change Calculator
See the Percentage Change Calculator for a related misc pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

attr() can only read static HTML attributes present in the markup, not a form control's live value property, which is what changes as a user drags a range input. There is no CSS-only way to observe that live value, which is exactly why this snippet uses discrete radio stops instead of attempting (and failing at) true continuous drag.

A JS version listens for pointermove or an input event and sets clip-path to the exact live percentage, giving true 1:1 drag. This version snaps between five preset positions with an animated transition — visually similar at rest, but coarser and non-continuous during interaction.

Yes — add more radio/label pairs with evenly-spaced percentage values and matching #splitN:checked rules for both the image clip-path and the track fill/handle position. More stops narrow the gap toward (but never reach) continuous dragging.

Yes — because the stops are real radio inputs sharing one name, Tab moves focus into the group and Arrow keys move the checked stop using native browser radio-group behavior, with no custom key handling required.

clip-path and the divider's left offset are both CSS properties with a transition applied, so even though only five discrete values are ever set, the browser animates smoothly between whichever two values change when a different radio becomes checked.