Create an Elastic Slider in React
Build a slider that stretches beyond its bounds on drag and snaps back with a spring animation.
Standard sliders stop at the end of their track. An elastic slider lets you pull past the limit and then snaps back. That small bit of physical feedback makes the whole control feel more real. This is the kind of detail that gets noticed even when users cannot explain why your UI feels good.
The final result
What we are building
A custom slider built with Framer Motion that tracks pointer position, allows overflow beyond the track bounds with rubber-band resistance, and snaps back with a spring animation on release. The track stretches to follow the overflow.
Setting up
npm install motionimport {
animate, motion, useMotionValue,
useMotionValueEvent, useTransform,
} from 'motion/react';Building the component
Motion values for the core state
Three MotionValue instances drive the animation without triggering React re-renders:
const clientX = useMotionValue(0);
const overflow = useMotionValue(0);
const scale = useMotionValue(1);clientX tracks raw cursor position. overflow tracks how far past the track boundary the cursor has gone. scale animates the track height on hover.
Computing overflow with decay
When the cursor goes past the track edge, the overflow is not the raw distance. It goes through a sigmoid decay function that makes the resistance feel natural:
function decay(value: number, max: number): number {
if (max === 0) return 0;
const entry = value / max;
const sigmoid = 2 * (1 / (1 + Math.exp(-entry)) - 0.5);
return sigmoid * max;
}This gives you maximum resistance at the boundaries: the further past the end you drag, the slower the thumb moves relative to your cursor.
The overflow is computed inside a useMotionValueEvent listener:
useMotionValueEvent(clientX, 'change', (latest: number) => {
if (sliderRef.current) {
const { left, right } = sliderRef.current.getBoundingClientRect();
let newValue: number;
if (latest < left) {
setRegion('left');
newValue = left - latest;
} else if (latest > right) {
setRegion('right');
newValue = latest - right;
} else {
setRegion('middle');
newValue = 0;
}
overflow.jump(decay(newValue, MAX_OVERFLOW));
}
});Track deformation
The track element stretches to follow the overflow using useTransform on both scaleX and scaleY. The transform origin flips depending on which side is overflowing:
<motion.div
style={{
scaleX: useTransform(() => {
if (sliderRef.current) {
const { width } = sliderRef.current.getBoundingClientRect();
return 1 + overflow.get() / width;
}
return 1;
}),
scaleY: useTransform(overflow, [0, MAX_OVERFLOW], [1, 0.8]),
transformOrigin: useTransform(() => {
if (sliderRef.current) {
const { left, width } = sliderRef.current.getBoundingClientRect();
return clientX.get() < left + width / 2 ? 'right' : 'left';
}
return 'center';
}),
height: useTransform(scale, [1, 1.2], [6, 12]),
}}
className='flex flex-grow'
>The scaleY compression from 1 to 0.8 as overflow increases makes the track look like it is being squished sideways, which reinforces the rubber-band feeling.
Spring snap-back on release
On pointer up, we animate the overflow back to 0 with a spring that has enough bounce to feel satisfying:
const handlePointerUp = () => {
animate(overflow, 0, { type: 'spring', bounce: 0.5 });
};Left and right icon bounce
The icons at each end pulse when you overflow past their side:
<motion.div
animate={{
scale: region === 'left' ? [1, 1.4, 1] : 1,
transition: { duration: 0.25 },
}}
style={{
x: useTransform(() =>
region === 'left' ? -overflow.get() / scale.get() : 0
),
}}
>
{leftIcon}
</motion.div>How to use it
<ElasticSlider
defaultValue={50}
startingValue={0}
maxValue={100}
isStepped={false}
leftIcon={<span>-</span>}
rightIcon={<span>+</span>}
/>| Prop | Default | Description |
|------|---------|-------------|
| defaultValue | 50 | Initial slider value |
| startingValue | 0 | Minimum value |
| maxValue | 100 | Maximum value |
| isStepped | false | Snap to step increments |
| stepSize | 1 | Step increment when isStepped is true |
Key takeaways
useMotionValueEventlets you respond to MotionValue changes without subscribing viauseEffect, and it automatically cleans up when the component unmounts.- The
decaysigmoid function is what separates a good rubber-band effect from a bad one. Linear resistance feels mechanical; sigmoid resistance feels physical. - Using
animate(overflow, 0, { type: 'spring', bounce: 0.5 })rather than setting the value directly means the snap-back inherits the spring physics rather than just jumping.