A Slider That Respects Physics
Creative coding is a reliable way to sharpen front-end skills, and native HTML elements are often the most surprising raw material. A range input (<input type="range"/>) is a familiar control, but it behaves nothing like a physical object. This experiment uses GreenSock (GSAP) plugins inside a React component to give the humble slider real momentum, inertia, and a bounce at each end of the track.
The goal is to produce a slider that feels like it is actually sliding. When you flick the thumb, it keeps moving with velocity; when it reaches the min or max, it rebounds instead of stopping dead. All of this lives in a reusable SlideySlider React component.
Starting With Draggable and Proxy Triggers
The first approach uses the Draggable plugin. Rather than making the input itself draggable, a proxy element is created and the trigger property opens a drag interaction through the input. The drag is constrained to the x-axis:
const SlideySlider = ({
min = 0,
max = 100,
step = 1,
value = 0
}) => {
const proxy = React.useRef(document.createElement('div'))
const inputRef = React.useRef(null)
React.useEffect(() => {
Draggable.create(proxy.current, {
trigger: inputRef.current,
type: 'x',
})
}, []);
return (
<input
ref={inputRef}
type="range"
min={min}
max={max}
step={step}
defaultValue={value}
/>
);
};
The Inertia plugin tracks the velocity of that drag and tweens the input's value, so a flick carries the thumb forward. Any running tween must be killed on onPress to keep the control responsive:
onPress: () => {
gsap.killTweensOf(inputRef.current)
},
This version is slidey, but it lacks a bounce. The naive fix — multiplying velocity by a negative when the value hits a boundary — only works once before the slider stalls. The problem is timing: the inertia tracker holds historical velocity data, and when the direction is reversed mid-flight, the new velocity value takes time to be registered. As Jack from GreenSock explained, the tracker averages data points per tick, so a sudden reversal is not reflected quickly enough.
That issue can be solved with the wrapYoyo utility, which bounces a value between two limits. But that approach detects no collision event, and track clicks far from the thumb produce distance-based velocity you may not want.
Switching to the Observer Plugin
A cleaner solution uses the GSAP Observer plugin, which taps into pointer and touch events. Instead of dragging a proxy, the input's actual value is tracked with InertiaPlugin.track():
React.useEffect(() => {
InertiaPlugin.track(inputRef.current, "value");
}, []);
The Observer watches touch and pointer events on the input. A drag is recognized after a 3-pixel threshold. The sliding tween is stored in a ref and destroyed on any interaction, which prevents stale velocity from a track click. On onDragEnd, a new tween is created for the input's value with an auto target, letting the inertia plugin calculate the endpoint from current velocity:
See the Pen [8. Basic Observer Integration 🙌](https://codepen.io/smashingmag/pen/vYpjRQg) by Jhey.
Adding the Bounce
For the bounce, the Modifiers plugin intercepts the value GSAP is about to apply. A wrapYoyo function is created for the min and max bounds, and the modifier uses it to remap the value when the slider reaches either edge:
const WRAP = gsap.utils.wrapYoyo(min, max)
Observer.create({
target: inputRef.current,
type: "touch,pointer",
dragMinimum: 3,
onPress: () => tweenRef.current && tweenRef.current.kill(),
onDragEnd: () => {
tweenRef.current = gsap.to(inputRef.current, {
inertia: {
resistance: 200,
value: "auto"
},
modifiers: {
value: v => WRAP(v)
}
});
}
});
Detecting a collision comes from the tracking array returned by InertiaPlugin.track(). Dividing the current value by the max reveals bounce counts as the number's integer part changes. The modifier logic also reads the velocity to determine which side was struck:
onDragEnd: () => {
let lastCycle = 0;
tweenRef.current = gsap.to(inputRef.current, {
inertia: {
resistance: 200,
value: "auto"
},
modifiers: {
value: (v) => {
const cycle = Math.floor(v / max);
if (cycle !== lastCycle) {
// Bounce!!!
console.info(`BOUNCE ${TRACKER.get('value') < 0 ? 'LEFT' : 'RIGHT'}`);
}
// Update the cycle count
lastCycle = cycle;
return WRAP(v);
}
}
});
}
Adding Whimsy: Nudge and Sound
With collision detection in place, the component can react physically. When a bounce is detected, a horizontal nudge of the entire input element simulates impact. Using the mapRange utility, velocity is mapped to an xPercent offset, clamped to a maximum bump value (defaulting to 10 in props):
const xPercent = gsap.utils.clamp(
-bump,
bump,
gsap.utils.mapRange(-600, 600, -bump, bump, vx)
);
The animation's duration can also respond to velocity so faster impacts produce quicker rebounds. A knocking sound can be played on impact, with its volume scaled by the strength of the hit:
const volume = gsap.utils.clamp(
0.1,
1,
gsap.utils.mapRange(0, 600, 0, 1, Math.abs(vx))
)
The final tween animates the input with yoyo and repeat: 1 so it returns to its original position, and the audio resets and plays on each interaction:
gsap.to(inputRef.current, {
onStart: () => {
KNOCK.pause()
KNOCK.currentTime = 0
KNOCK.volume = volume
KNOCK.play()
},
xPercent,
duration,
yoyo: true,
repeat: 1
});
Keeping React State in Sync
This component stays uncontrolled until the interaction is finished. The parent component controls the input value and passes down an onChange handler plus a labelRef to the slider:
const App = () => {
const labelRef = React.useRef(null)
const [value, setValue] = React.useState(gsap.utils.random(0, 100, 1));
return (
<>
<label htmlFor="slidey" ref={labelRef}>{value}</label>
<SlideySlider
id="slidey"
value={value}
labelRef={labelRef}
onChange={setValue}
/>
<span>{`State value: ${value}`}</span>
</>
);
};
The onChange prop fires from a standard onChange on the input but is also invoked when the inertia tween completes inside onDragEnd:
onDragEnd: () => {
let lastCycle = 0;
tweenRef.current = gsap.to(inputRef.current, {
inertia: {
resistance: 200,
value: "auto"
},
onComplete: () => {
if (onChange) onChange(inputRef.current.value)
},
/* Rest of tween */
}
/* Rest of onDragEnd */
}
The visible label updates live during the slide via gsap.set inside the value modifier, gated on the presence of labelRef:
if (labelRef.current) gsap.set(labelRef.current, { innerText: Math.floor(WRAP(v)) })
The result is a slider that feels alive — it carries momentum, bounces off boundaries, and reports its new value back to React state when the motion settles.



