Components

Slide to confirm: build the button in CSS and React

A slide-to-confirm button with a draggable handle, a filling track and a snap-back spring. Live demo, plain HTML/CSS/JS, a React version and the timing that makes it feel right.

· 5 min read

Slide to confirm is the pattern you know from unlocking old iPhones and from checkout flows that do not want a stray tap to send money. The user drags a handle across a track; when it reaches the end, the action fires. Let it go early and the handle springs back.

It looks simple, but three details decide whether it feels right: the track fills as you drag, the label fades out under your thumb, and the handle snaps back with a small overshoot. This guide builds all three, first in plain HTML, CSS and JavaScript, then in React.

Slide to confirm Confirmed
Drag the handle, or focus it and press Enter

How the animation is put together

There are four moving parts, and only one of them is driven by time. The rest follow the pointer.

PartWhat movesDriven by
Handletransform: translateX()Pointer while dragging, a 380 ms spring on release
Track fillwidth, always the handle position plus one handle widthThe same value as the handle
Labelopacity, gone by about 60% of the wayDrag progress
Confirmed stateFill to 100%, label swapsA 200 ms ease-out once the end is reached

The trick that keeps the code small is one CSS custom property, --x, holding the handle offset in pixels. The handle, the fill and the label all read from it, so JavaScript sets a single value per pointer move and the browser does the rest.

The HTML

<div class="stc">
  <div class="stc-fill"></div>
  <span class="stc-label">Slide to confirm</span>
  <span class="stc-done">Confirmed</span>
  <button class="stc-handle" type="button" role="slider"
          aria-label="Slide to confirm" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6"><path d="M9 6l6 6-6 6"/></svg>
  </button>
</div>

The handle is a real button with role="slider", so it can take keyboard focus and screen readers announce how far along it is.

The CSS

.stc { --w: 300px; --h: 60px; --pad: 5px;
  position: relative; width: var(--w); height: var(--h);
  border-radius: 999px; background: #1c1f26; overflow: hidden;
  user-select: none; touch-action: none; }

.stc-fill { position: absolute; inset: 0; border-radius: inherit;
  width: calc(var(--x, 0px) + var(--h));
  background: linear-gradient(90deg, #fb9f3d33, #fb9f3d88); }

.stc-label { opacity: calc(1 - var(--p, 0) * 1.6); }

.stc-handle { position: absolute; top: var(--pad); left: var(--pad);
  width: calc(var(--h) - 2 * var(--pad)); height: calc(var(--h) - 2 * var(--pad));
  border-radius: 50%; background: #fb9f3d;
  transform: translateX(var(--x, 0px)); cursor: grab; }

/* only on release: the spring back */
.stc.back .stc-handle,
.stc.back .stc-fill {
  transition: transform .38s cubic-bezier(.34, 1.56, .64, 1),
              width .38s cubic-bezier(.34, 1.56, .64, 1); }

Notice the transition is only switched on with the back class. While the user drags there must be no transition at all, or the handle lags behind the finger and feels heavy.

The JavaScript

const root = document.querySelector('.stc');
const handle = root.querySelector('.stc-handle');
let startX = 0, x = 0, max = 0, dragging = false;

function set(v) {
  x = v;
  root.style.setProperty('--x', v + 'px');
  root.style.setProperty('--p', max ? v / max : 0);
  handle.setAttribute('aria-valuenow', Math.round(max ? (v / max) * 100 : 0));
}

handle.addEventListener('pointerdown', e => {
  max = root.clientWidth - handle.offsetWidth - 10;
  dragging = true;
  startX = e.clientX - x;
  root.classList.remove('back');
  handle.setPointerCapture(e.pointerId);
});

handle.addEventListener('pointermove', e => {
  if (dragging) set(Math.min(max, Math.max(0, e.clientX - startX)));
});

handle.addEventListener('pointerup', () => {
  dragging = false;
  if (x >= max * 0.92) { set(max); onConfirm(); }
  else { root.classList.add('back'); set(0); }
});

setPointerCapture is what makes the drag keep working when the pointer leaves the handle, which it will on any fast swipe. The 92% threshold forgives a thumb that stops a few pixels short.

The React version

import { useRef, useState } from 'react';

export function SlideToConfirm({ onConfirm, label = 'Slide to confirm' }) {
  const track = useRef(null);
  const drag = useRef(null);
  const [x, setX] = useState(0);
  const [back, setBack] = useState(false);
  const max = () => track.current.clientWidth - 60;

  const down = e => {
    drag.current = e.clientX - x;
    setBack(false);
    e.currentTarget.setPointerCapture(e.pointerId);
  };
  const move = e => {
    if (drag.current != null) setX(Math.min(max(), Math.max(0, e.clientX - drag.current)));
  };
  const up = () => {
    drag.current = null;
    if (x >= max() * 0.92) { setX(max()); onConfirm?.(); }
    else { setBack(true); setX(0); }
  };

  return (
    <div ref={track} className={'stc' + (back ? ' back' : '')}
         style={{ '--x': x + 'px', '--p': x / (track.current ? max() : 1) }}>
      <div className="stc-fill" />
      <span className="stc-label">{label}</span>
      <button className="stc-handle" type="button" role="slider" aria-label={label}
              aria-valuenow={Math.round((x / (track.current ? max() : 1)) * 100)}
              onPointerDown={down} onPointerMove={move} onPointerUp={up}>→</button>
    </div>
  );
}

It uses the same CSS as above. If you already use Framer Motion, swap the button for a motion.button with drag="x" and dragConstraints={track}, and animate it back with transition={{ type: 'spring', stiffness: 500, damping: 30 }}.

Timing that feels right

Accessibility checklist

Copy one you already like

If you have seen a slide to confirm on a banking app's website or a product page that feels better than yours, you do not have to guess its numbers. Open the page, start the Capture Motion extension, pick the handle and move it. You get the real keyframes, durations and easing curve, ready to paste as CSS or React, or to open as a timeline in Figma. There is also a ready-made slide to confirm in the Capture Motion gallery that you can customize and export.

Questions

Why use slide to confirm instead of a button?

It asks for a deliberate gesture, so it suits actions that are hard to undo: payments, deleting data, ending a call. A tap can happen by accident; a full slide rarely does.

Is slide to confirm accessible?

Only if it has a keyboard and screen reader path. Give the handle role="slider" with aria-valuenow, let Enter or the right arrow complete it, and keep a normal confirm button for people who cannot drag.

What easing should the snap-back use?

An overshoot curve such as cubic-bezier(.34, 1.56, .64, 1) over 300 to 400 ms. It reads as a spring without pulling in a physics library.

Can I copy this animation from a site that already has it?

Yes. The Capture Motion Chrome extension records the real keyframes, durations and easing of the handle and track, and exports them as CSS, React or JSON.