jelonyx
No-app fixes · Fix 10

Shopify before/after slider
without an app.

Before/after image comparisons are one of the strongest conversion signals for products with visible results like skincare, hair colour, cleaning products, and renovation materials. Apps for this typically charge $10–20 per month. This snippet renders a drag-handle comparison slider using two of your product images. It works without a library or app, and is mouse, touch, and keyboard accessible.

DifficultyBeginner
Time~20 minutes
CostFree
Theme accessRequired
Compatible with:Dawn 12+HorizonSenseMost OS 2.0 themes
Preview workbench

Preview the image comparison.

Drag the preview slider or use the control to test the reveal state. The snippet uses two product images, no external library, and keyboard-friendly slider semantics.

Live storefront preview
After
Before

The code

One file. Set which product images to use at the top of the snippet and everything else is automatic.

snippets/jlx-before-after.liquid
Liquid · CSS · JS
{% comment %}
  Jelonyx · Before/After Slider : no app required
  Compatible with Dawn 12+, Horizon, and most Online Store 2.0 themes
  Source: jelonyx.com/shopify/no-app/before-after-slider
{% endcomment %}

{%- comment -%}
  Configuration : edit these values:
    jlx_before_img   : The "before" image. Default: product.images[0] (first product image).
    jlx_after_img    : The "after" image.  Default: product.images[1] (second product image).
    jlx_before_label : Label shown over the before half.
    jlx_after_label  : Label shown over the after half.
    jlx_start_pct    : Initial divider position as a percentage (0–100). Default: 50.

  To use a specific image by index:
    {%- assign jlx_before_img = product.images[2] -%}

  To use a specific image by src URL (useful for page sections):
    {%- assign jlx_before_src = 'https://cdn.shopify.com/s/files/...' -%}
    (see the src attribute in the img tag below)
{%- endcomment -%}

{%- assign jlx_before_img   = product.images[0] -%}
{%- assign jlx_after_img    = product.images[1] -%}
{%- assign jlx_before_label = 'Before' -%}
{%- assign jlx_after_label  = 'After' -%}
{%- assign jlx_start_pct    = 50 -%}

{%- if jlx_before_img == nil or jlx_after_img == nil -%}
  {%- comment -%}One or both images are missing: do not render the slider.{%- endcomment -%}
{%- else -%}

{%- assign jlx_slider_id = 'jlx-slider-' | append: jlx_before_img.id -%}

<div
  class="jlx-slider"
  id="{{ jlx_slider_id }}"
  role="group"
  aria-label="Before and after image comparison"
>
  <!-- After image (base layer, sets the container height) -->
  <div class="jlx-slider__after">
    <img
      class="jlx-slider__img"
      src="{{ jlx_after_img | image_url: width: 1200 }}"
      alt="{{ jlx_after_img.alt | default: jlx_after_label | escape }}"
      width="{{ jlx_after_img.width }}"
      height="{{ jlx_after_img.height }}"
      loading="lazy"
    >
    <span class="jlx-slider__label jlx-slider__label--after" aria-hidden="true">{{ jlx_after_label }}</span>
  </div>

  <!-- Before image (clipped overlay, reveals as handle moves right) -->
  <div class="jlx-slider__before" aria-hidden="true">
    <img
      class="jlx-slider__img"
      src="{{ jlx_before_img | image_url: width: 1200 }}"
      alt="{{ jlx_before_img.alt | default: jlx_before_label | escape }}"
      width="{{ jlx_before_img.width }}"
      height="{{ jlx_before_img.height }}"
    >
    <span class="jlx-slider__label jlx-slider__label--before" aria-hidden="true">{{ jlx_before_label }}</span>
  </div>

  <!-- Drag handle -->
  <div
    class="jlx-slider__handle"
    role="slider"
    aria-label="Drag to compare before and after"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-valuenow="{{ jlx_start_pct }}"
    tabindex="0"
  >
    <div class="jlx-slider__line" aria-hidden="true"></div>
    <div class="jlx-slider__btn" aria-hidden="true">
      <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
        <path d="M6 4L2 9l4 5M12 4l4 5-4 5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
      </svg>
    </div>
  </div>
</div>

<style>
  #{{ jlx_slider_id }}.jlx-slider {
    position: relative;
    overflow: hidden;
    cursor: ew-resize;
    user-select: none;
    touch-action: none;
    border-radius: var(--media-radius, var(--inputs-radius, 4px));
    font-family: inherit;
    -webkit-tap-highlight-color: transparent;
  }
  #{{ jlx_slider_id }} .jlx-slider__after {
    display: block;
    position: relative;
  }
  #{{ jlx_slider_id }} .jlx-slider__img {
    display: block;
    width: 100%;
    height: auto;
  }
  #{{ jlx_slider_id }} .jlx-slider__before {
    position: absolute;
    inset: 0;
    clip-path: inset(0 50% 0 0);
    pointer-events: none;
  }
  #{{ jlx_slider_id }} .jlx-slider__before .jlx-slider__img {
    height: 100%;
    object-fit: cover;
  }
  #{{ jlx_slider_id }} .jlx-slider__label {
    position: absolute;
    bottom: 12px;
    padding: 4px 10px;
    background: rgba(0, 0, 0, 0.45);
    color: #fff;
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    border-radius: 3px;
    pointer-events: none;
  }
  #{{ jlx_slider_id }} .jlx-slider__label--before { left: 12px; }
  #{{ jlx_slider_id }} .jlx-slider__label--after  { right: 12px; }
  #{{ jlx_slider_id }} .jlx-slider__handle {
    position: absolute;
    inset-block: 0;
    left: 50%;
    transform: translateX(-50%);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    outline: none;
    pointer-events: none;
  }
  #{{ jlx_slider_id }} .jlx-slider__line {
    position: absolute;
    inset-block: 0;
    left: 50%;
    transform: translateX(-50%);
    width: 2px;
    background: #fff;
    box-shadow: 0 0 6px rgba(0, 0, 0, 0.25);
  }
  #{{ jlx_slider_id }} .jlx-slider__btn {
    position: relative;
    z-index: 2;
    width: 38px;
    height: 38px;
    background: #fff;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    box-shadow: 0 1px 8px rgba(0, 0, 0, 0.2);
    color: #1a1a1a;
    transition: transform 0.1s ease;
  }
  #{{ jlx_slider_id }}:active .jlx-slider__btn,
  #{{ jlx_slider_id }}:focus-within .jlx-slider__btn {
    transform: scale(1.08);
  }
  #{{ jlx_slider_id }} .jlx-slider__handle:focus-visible + .jlx-slider__btn,
  #{{ jlx_slider_id }}:focus-within .jlx-slider__line {
    box-shadow: 0 0 0 2px #007aff, 0 0 6px rgba(0, 0, 0, 0.25);
  }
</style>

<script>
  (function () {
    'use strict';
    var slider    = document.getElementById('{{ jlx_slider_id | escape }}');
    if (!slider) return;
    var beforeDiv = slider.querySelector('.jlx-slider__before');
    var handle    = slider.querySelector('.jlx-slider__handle');
    if (!beforeDiv || !handle) return;

    var pct      = {{ jlx_start_pct }};
    var dragging = false;

    function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }

    function setPosition(p) {
      pct = clamp(p, 0, 100);
      var right = (100 - pct).toFixed(2);
      beforeDiv.style.clipPath = 'inset(0 ' + right + '% 0 0)';
      handle.style.left = pct + '%';
      handle.setAttribute('aria-valuenow', Math.round(pct));
    }

    function pctFromClient(clientX) {
      var rect = slider.getBoundingClientRect();
      return ((clientX - rect.left) / rect.width) * 100;
    }

    // Mouse
    slider.addEventListener('mousedown', function (e) {
      if (e.button !== 0) return;
      dragging = true;
      setPosition(pctFromClient(e.clientX));
      e.preventDefault();
    });
    document.addEventListener('mousemove', function (e) {
      if (dragging) setPosition(pctFromClient(e.clientX));
    });
    document.addEventListener('mouseup', function () { dragging = false; });

    // Touch
    slider.addEventListener('touchstart', function (e) {
      dragging = true;
      setPosition(pctFromClient(e.touches[0].clientX));
    }, { passive: true });
    slider.addEventListener('touchmove', function (e) {
      if (!dragging) return;
      setPosition(pctFromClient(e.touches[0].clientX));
      e.preventDefault();
    }, { passive: false });
    slider.addEventListener('touchend', function () { dragging = false; }, { passive: true });

    // Keyboard (arrows move 2%; Shift+arrow moves 10%)
    handle.addEventListener('keydown', function (e) {
      var step = e.shiftKey ? 10 : 2;
      if (e.key === 'ArrowLeft')  { setPosition(pct - step); e.preventDefault(); }
      if (e.key === 'ArrowRight') { setPosition(pct + step); e.preventDefault(); }
      if (e.key === 'Home')       { setPosition(0);   e.preventDefault(); }
      if (e.key === 'End')        { setPosition(100); e.preventDefault(); }
    });

    // Init
    setPosition(pct);
  })();
</script>

{%- endif -%}

How to install

  1. 01
    Upload your images to the product.

    In Shopify Admin, open the product you want the slider on. Make sure the before image and after image are both uploaded as product images. By default the snippet uses the first product image as “before” and the second as “after”. You can change the index in the config if your images are in a different order. Save the product.

  2. 02
    Open your theme code editor.

    In Shopify Admin, go to Online Store → Themes. On your active theme, click the three-dot menu and select Edit code.

  3. 03
    Create a new snippet.

    Under the Snippets folder, click Add a new snippet. Name it jlx-before-after and click Done.

  4. 04
    Paste the code and set your image positions.

    Delete any placeholder content and paste the entire code block above. At the top of the snippet, edit the config variables if needed:

    • jlx_before_img: change the index (e.g. product.images[0]) to point to your before image.
    • jlx_after_img: change the index to point to your after image.
    • jlx_before_label and jlx_after_label: edit the overlay labels if needed (e.g. “Day 1” / “Day 30”).
    • jlx_start_pct: the initial divider position. Defaults to 50 (centred). Change to 30 or 70 to bias toward revealing the before or after image on load.

    Save the file.

  5. 05
    Add the render tag via the theme customizer.

    In Shopify Admin, go to Online Store → Themes → Customize. Navigate to a product page. Click on the product information section, then Add block → Custom Liquid. Enter the following, then drag the block to where you want the slider to appear, typically below the product description or buy buttons. Save.

    {% render 'jlx-before-after' %}
  6. 06
    Test the slider.

    Open the product page. The slider should show with the divider centred. Drag left and right and confirm both images reveal correctly. Test on a phone by dragging with a finger. Tab to the slider handle and test keyboard navigation with / , then Shift+← / Shift+→ for larger steps, and Home / End to jump to either extreme.

Customisation

Use images that are not product images. Replace the jlx_before_img and jlx_after_img assignments with a specific image URL:

{%- assign jlx_before_src = 'https://cdn.shopify.com/s/files/...' -%}

Then replace jlx_before_img | image_url: width: 1200 in the <img> tag with jlx_before_src, and remove the width and height attributes (or hardcode them).

Change the handle size or colour. Edit the .jlx-slider__btn CSS rule. Change background: #fff to match your brand colour. Change color: #1a1a1a to set the icon colour.

Hide the Before/After labels. Remove the two <span class="jlx-slider__label"> elements from the Liquid. The JavaScript does not depend on them.

Set a fixed height. By default, the slider height matches the image aspect ratio. To fix the height (useful for landscape images in a portrait context), add height: 400px; to the .jlx-slider__after img rule and change its height: auto to object-fit: cover; height: 400px.

How it works

The after image is a normal full-width <img> inside the container. It sets the container's height via its natural aspect ratio. The before image sits in a div that is position: absolute; inset: 0. This covers the after image exactly and uses clip-path: inset(0 X% 0 0) to reveal only the left portion. As the handle moves right, the right-side clip shrinks and more of the before image appears.

The drag handle is a <div role="slider"> with tabindex="0" and aria-valuenow kept in sync with the divider position. This lets screen readers announce the slider state and lets keyboard users navigate it with arrow keys.

The JavaScript listens for mousedown on the slider container and mousemove on the document (so dragging past the slider edge continues tracking). Touch events follow the same pattern. touch-action: none on the container tells the browser to hand all touch input to the script. This is necessary so the browser does not intercept horizontal swipes as scroll gestures.

Scoping CSS with the generated #{jlx_slider_id} ID prefix means multiple sliders on the same page do not interfere. Each instance gets a unique ID based on the before image's Shopify asset ID.

Compatibility

Tested against Dawn 12+ and Horizon. The snippet reads --media-radius and --inputs-radius from your theme for the border radius, so the slider corners match your theme's image rounding without any CSS edits.

clip-path: inset() is supported in all browsers that reach Shopify storefronts. touch-action: none and inset-block are equally well supported. No polyfills are required.

Shopify's product image CDN automatically serves the correct resolution via the image_url: width: 1200 filter. On Retina screens, the browser requests the appropriate DPR variant automatically.

Limitations

  • Both images must be the same aspect ratio: the after image sets the container height, and the before image fills 100% of that container with object-fit: cover. If the images have different aspect ratios, the before image will be cropped. Upload both images at the same dimensions for best results.
  • One slider per snippet render: you can add multiple sliders on the same page by rendering the snippet multiple times. However, each render must use different images (the snippet ID is derived from the before image ID). Two renders using the same before image will share an ID, which will cause JavaScript conflicts.
  • Images are always product images: by default the snippet reads from product.images. If you are using the snippet outside a product page context (e.g. on a landing page or collection page), you will need to hardcode image URLs directly in the snippet config.
  • touch-action: none blocks vertical scroll within the slider: on mobile, the slider captures all touch input including vertical swipes. Users cannot scroll through the slider. They must touch outside it to scroll. For very tall sliders this can be frustrating; reduce the slider height if it covers most of the mobile viewport.
  • Headless storefronts: this is a Liquid snippet and does not apply to Hydrogen or other headless setups.
No-App Shopify Fix Sprint

Need this installed?

If you would rather not edit theme code, we can install and style the before/after slider for your store. We match your theme radius and colour, position the right images, and test it live on your product pages.