Lift
import { useRef, useState } from 'react';
const W = 240;
const MID = 52;
const css = `
.slider-lift { position: relative; width: ${W}px; height: 76px; }
.slider-lift__rail { position: absolute; left: 0; right: 0; top: ${MID - 2}px; height: 4px; border-radius: 2px; background: light-dark(#ebedf0, #232529); }
.slider-lift__fill { position: absolute; left: 0; top: ${MID - 2}px; height: 4px; border-radius: 2px; width: var(--slider-lift-x); background: light-dark(#50535b, #c6c8cd); }
/* Zero height, so the shadow is all blur: an oval on the track under the thumb. */
.slider-lift__shadow {
position: absolute;
top: ${MID + 12}px;
left: calc(var(--slider-lift-x) - 8px);
width: 16px;
height: 0;
border-radius: 50%;
box-shadow: 0 0 4px 3px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5));
transition: box-shadow 0.4s cubic-bezier(0.3, 1.2, 0.5, 1), transform 0.4s cubic-bezier(0.3, 1.2, 0.5, 1);
}
.slider-lift__thumb {
position: absolute;
top: ${MID - 15}px;
left: calc(var(--slider-lift-x) - 10px);
width: 20px;
height: 30px;
transform-origin: 50% 30%;
transform: translateY(var(--slider-lift-up, 0px)) rotate(var(--slider-lift-tilt)) scale(var(--slider-lift-s, 1));
transition: transform 0.45s cubic-bezier(0.3, 1.5, 0.5, 1);
}
.slider-lift__body {
position: absolute;
inset: 0;
border-radius: 10px;
background: light-dark(#373a40, #f1f2f3);
box-shadow: inset 0 -2px 0 color-mix(in oklab, light-dark(#6d7179, #7d8087) 35%, transparent);
transform-origin: 50% 100%;
}
.slider-lift__body::after { content: ''; position: absolute; left: 6px; right: 6px; top: 14px; height: 2px; border-radius: 1px; background: light-dark(#cbced3, #393c41); }
.slider-lift__ripple {
position: absolute;
top: ${MID - 6}px;
left: calc(var(--slider-lift-x) - 18px);
width: 36px;
height: 12px;
border-radius: 50%;
border: 1.5px solid light-dark(#5f626a, #a7abb2);
opacity: 0;
animation: slider-lift-ripple 0.6s ease-out 0.12s;
}
@keyframes slider-lift-ripple {
from { opacity: 0.7; transform: scale(0.5); }
to { opacity: 0; transform: scale(2.1); }
}
.slider-lift__value {
position: absolute;
top: 0;
left: var(--slider-lift-x);
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: light-dark(#42454c, #e5e6e9);
opacity: 0;
transform: translateX(-50%) translateY(6px);
transition: opacity 0.2s, transform 0.35s cubic-bezier(0.3, 1.5, 0.5, 1);
}
.slider-lift--held {
--slider-lift-up: -14px;
--slider-lift-s: 1.08;
}
.slider-lift--held .slider-lift__shadow { box-shadow: 0 0 9px 5px light-dark(rgba(0, 0, 0, 0.07), rgba(0, 0, 0, 0.3)); transform: scaleX(1.5); }
.slider-lift--held .slider-lift__value { opacity: 1; transform: translateX(-50%); }
.slider-lift:has(.slider-lift__input:focus-visible) .slider-lift__body { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-lift__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: grab; -webkit-appearance: none; appearance: none; background: none; }
.slider-lift__input:active { cursor: grabbing; }
.slider-lift__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 40px; }
.slider-lift__input::-moz-range-thumb { width: 20px; height: 40px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-lift__thumb, .slider-lift__shadow { transition: none; }
.slider-lift__ripple { display: none; }
}
`;
export function LiftSlider() {
const [v, setV] = useState(60);
const [held, setHeld] = useState(false);
const [tilt, setTilt] = useState(0);
const [drops, setDrops] = useState(0);
const body = useRef<HTMLSpanElement>(null);
const settle = useRef<ReturnType<typeof setTimeout>>(undefined);
const still = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
const x = 10 + (v / 100) * (W - 20);
const pickUp = () => {
setHeld(true);
const drop = () => {
setHeld(false);
setTilt(0);
removeEventListener('pointerup', drop);
removeEventListener('pointercancel', drop);
if (still()) return;
setDrops((d) => d + 1);
// Lands with a squash, timed to meet the track as the lift springs back.
body.current?.animate(
[
{ transform: 'scale(1)' },
{ transform: 'scale(1.16, 0.82)', offset: 0.35 },
{ transform: 'scale(0.96, 1.05)', offset: 0.7 },
{ transform: 'scale(1)' },
],
{ duration: 420, delay: 110, easing: 'ease-out' },
);
};
addEventListener('pointerup', drop);
addEventListener('pointercancel', drop);
};
return (
<>
<style>
{css}
</style>
<div
className={`slider-lift${held ? ' slider-lift--held' : ''}`}
style={{ '--slider-lift-x': `${x}px`, '--slider-lift-tilt': `${tilt}deg` } as React.CSSProperties}
>
<span className="slider-lift__rail" aria-hidden="true" />
<span className="slider-lift__fill" aria-hidden="true" />
{drops > 0 && <span key={drops} className="slider-lift__ripple" aria-hidden="true" />}
<span className="slider-lift__shadow" aria-hidden="true" />
<span className="slider-lift__thumb" aria-hidden="true">
<span ref={body} className="slider-lift__body" />
</span>
<span className="slider-lift__value" aria-hidden="true">
{v}
</span>
<input
className="slider-lift__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => {
const next = +e.target.value;
// Held up, the thumb swings back against the drag, then hangs straight once you pause.
if (held && !still()) setTilt(Math.max(-18, Math.min(18, (next - v) * 2.4)));
setV(next);
clearTimeout(settle.current);
settle.current = setTimeout(() => setTilt(0), 100);
}}
onPointerDown={pickUp}
aria-label="Opacity"
aria-valuetext={`${v}%`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderLift(el) on the mounted element instead.
<div class="slider-lift" style="--slider-lift-x:142px;--slider-lift-tilt:0deg">
<span class="slider-lift__rail" aria-hidden="true"></span>
<span class="slider-lift__fill" aria-hidden="true"></span>
<span class="slider-lift__shadow" aria-hidden="true"></span>
<span class="slider-lift__thumb" aria-hidden="true">
<span class="slider-lift__body"></span>
</span>
<span class="slider-lift__value" aria-hidden="true">60</span>
<input class="slider-lift__input" type="range" min="0" max="100" aria-label="Opacity" aria-valuetext="60%" value="60"/>
</div>.slider-lift { position: relative; width: 240px; height: 76px; }
.slider-lift__rail { position: absolute; left: 0; right: 0; top: 50px; height: 4px; border-radius: 2px; background: light-dark(#ebedf0, #232529); }
.slider-lift__fill { position: absolute; left: 0; top: 50px; height: 4px; border-radius: 2px; width: var(--slider-lift-x); background: light-dark(#50535b, #c6c8cd); }
/* Zero height, so the shadow is all blur: an oval on the track under the thumb. */
.slider-lift__shadow {
position: absolute;
top: 64px;
left: calc(var(--slider-lift-x) - 8px);
width: 16px;
height: 0;
border-radius: 50%;
box-shadow: 0 0 4px 3px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5));
transition: box-shadow 0.4s cubic-bezier(0.3, 1.2, 0.5, 1), transform 0.4s cubic-bezier(0.3, 1.2, 0.5, 1);
}
.slider-lift__thumb {
position: absolute;
top: 37px;
left: calc(var(--slider-lift-x) - 10px);
width: 20px;
height: 30px;
transform-origin: 50% 30%;
transform: translateY(var(--slider-lift-up, 0px)) rotate(var(--slider-lift-tilt)) scale(var(--slider-lift-s, 1));
transition: transform 0.45s cubic-bezier(0.3, 1.5, 0.5, 1);
}
.slider-lift__body {
position: absolute;
inset: 0;
border-radius: 10px;
background: light-dark(#373a40, #f1f2f3);
box-shadow: inset 0 -2px 0 color-mix(in oklab, light-dark(#6d7179, #7d8087) 35%, transparent);
transform-origin: 50% 100%;
}
.slider-lift__body::after { content: ''; position: absolute; left: 6px; right: 6px; top: 14px; height: 2px; border-radius: 1px; background: light-dark(#cbced3, #393c41); }
.slider-lift__ripple {
position: absolute;
top: 46px;
left: calc(var(--slider-lift-x) - 18px);
width: 36px;
height: 12px;
border-radius: 50%;
border: 1.5px solid light-dark(#5f626a, #a7abb2);
opacity: 0;
animation: slider-lift-ripple 0.6s ease-out 0.12s;
}
@keyframes slider-lift-ripple {
from { opacity: 0.7; transform: scale(0.5); }
to { opacity: 0; transform: scale(2.1); }
}
.slider-lift__value {
position: absolute;
top: 0;
left: var(--slider-lift-x);
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: light-dark(#42454c, #e5e6e9);
opacity: 0;
transform: translateX(-50%) translateY(6px);
transition: opacity 0.2s, transform 0.35s cubic-bezier(0.3, 1.5, 0.5, 1);
}
.slider-lift--held {
--slider-lift-up: -14px;
--slider-lift-s: 1.08;
}
.slider-lift--held .slider-lift__shadow { box-shadow: 0 0 9px 5px light-dark(rgba(0, 0, 0, 0.07), rgba(0, 0, 0, 0.3)); transform: scaleX(1.5); }
.slider-lift--held .slider-lift__value { opacity: 1; transform: translateX(-50%); }
.slider-lift:has(.slider-lift__input:focus-visible) .slider-lift__body { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-lift__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: grab; -webkit-appearance: none; appearance: none; background: none; }
.slider-lift__input:active { cursor: grabbing; }
.slider-lift__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 40px; }
.slider-lift__input::-moz-range-thumb { width: 20px; height: 40px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-lift__thumb, .slider-lift__shadow { transition: none; }
.slider-lift__ripple { display: none; }
}function initSliderLift(root) {
var W = 240;
var input = root.querySelector('.slider-lift__input');
var body = root.querySelector('.slider-lift__body');
var value = root.querySelector('.slider-lift__value');
var shadow = root.querySelector('.slider-lift__shadow');
var v = +input.value, held = false, settle, ripple;
function still() { return matchMedia('(prefers-reduced-motion: reduce)').matches; }
function tilt(deg) { root.style.setProperty('--slider-lift-tilt', deg + 'deg'); }
function update() {
var next = +input.value;
// Held up, the thumb swings back against the drag, then hangs straight once you pause.
if (held && !still()) tilt(Math.max(-18, Math.min(18, (next - v) * 2.4)));
v = next;
root.style.setProperty('--slider-lift-x', 10 + (v / 100) * (W - 20) + 'px');
value.textContent = v;
input.setAttribute('aria-valuetext', v + '%');
clearTimeout(settle);
settle = setTimeout(function () { tilt(0); }, 100);
}
function drop() {
held = false;
root.classList.remove('slider-lift--held');
tilt(0);
removeEventListener('pointerup', drop);
removeEventListener('pointercancel', drop);
if (still()) return;
// A ripple on the track where it lands, and a squash timed to meet it as the lift springs back.
if (ripple) ripple.remove();
ripple = document.createElement('span');
ripple.className = 'slider-lift__ripple';
ripple.setAttribute('aria-hidden', 'true');
root.insertBefore(ripple, shadow);
body.animate(
[
{ transform: 'scale(1)' },
{ transform: 'scale(1.16, 0.82)', offset: 0.35 },
{ transform: 'scale(0.96, 1.05)', offset: 0.7 },
{ transform: 'scale(1)' },
],
{ duration: 420, delay: 110, easing: 'ease-out' }
);
}
function pickUp() {
held = true;
root.classList.add('slider-lift--held');
addEventListener('pointerup', drop);
addEventListener('pointercancel', drop);
}
input.addEventListener('input', update);
input.addEventListener('pointerdown', pickUp);
return function () {
clearTimeout(settle);
removeEventListener('pointerup', drop);
removeEventListener('pointercancel', drop);
input.removeEventListener('input', update);
input.removeEventListener('pointerdown', pickUp);
};
}
document.querySelectorAll('.slider-lift').forEach(initSliderLift);Goal: Create a "Lift" slider.
Visual behavior: An opacity slider whose thumb you pick up: it lifts off the track over a softening shadow and swings as you drag, then drops back with a squash and a ripple.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- Pointer down adds a held state that raises and scales the thumb, while its shadow stays on the track and spreads and softens. The shadow is a zero-height element whose box-shadow is all blur.
- While held, each input event tilts the thumb against the drag by how far the value jumped, cleared by a timer. On release a Web Animations squash plays as it lands, and a ripple element is added again so its keyframes replay.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the thumb. Under prefers-reduced-motion the thumb neither swings nor squashes, and there is no ripple.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Lift" slider.
Visual behavior: An opacity slider whose thumb you pick up: it lifts off the track over a softening shadow and swings as you drag, then drops back with a squash and a ripple.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- Pointer down adds a held state that raises and scales the thumb, while its shadow stays on the track and spreads and softens. The shadow is a zero-height element whose box-shadow is all blur.
- While held, each input event tilts the thumb against the drag by how far the value jumped, cleared by a timer. On release a Web Animations squash plays as it lands, and a ripple element is added again so its keyframes replay.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the thumb. Under prefers-reduced-motion the thumb neither swings nor squashes, and there is no ripple.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
An opacity slider whose thumb you pick up: it lifts off the track over a softening shadow and swings as you drag, then drops back with a squash and a ripple.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, spring, 3d
- Techniques
- css variables, css transition, keyframes
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the thumb. Under prefers-reduced-motion the thumb neither swings nor squashes, and there is no ripple.