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

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

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.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Ask an AI assistant to explain precisely why attr() cannot be used to read a range input's live value for driving clip-path — this is a common point of confusion, and understanding it clarifies why the stepped-radio approach here is a deliberate, honest choice rather than an oversight. It's also worth asking for a version with more stop positions for finer granularity, or for the minimal JavaScript addition (a single input-event listener updating a CSS custom property) that would upgrade this into a true continuous-drag slider while keeping the rest of the CSS unchanged.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a before/after image comparison slider using only HTML and CSS — no JavaScript, no onclick/oninput attributes, no <script> tags — using a stepped rather than continuous drag interaction, and be explicit in code comments about why continuous drag is not achievable without JavaScript.

Requirements:
- Two full-frame stacked image layers, where the top layer is clipped using clip-path: inset() to reveal varying amounts of the layer beneath it.
- Five mutually-exclusive radio inputs sharing one name attribute represent five fixed comparison positions (0%, 25%, 50%, 75%, 100%), each setting a different clip-path inset value on the top layer with a smooth CSS transition between positions.
- Build a separate slider-track UI element (styled labels acting as stop markers, a fill bar, and a handle indicator) that visually resembles a draggable slider and stays in sync with the same five radio states, driven purely by CSS sibling selectors from the same inputs.
- One radio should be checked by default, showing a 50/50 split on initial render.
- Ensure the control is operable via keyboard using only the radio group's native Tab and Arrow-key behavior.
- Do not attempt to fake continuous dragging with attr() or any other CSS-only trick reading a live range input value — use the fixed-stop approach and document the tradeoff clearly.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

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>

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.