Article Angry slider
Sliders that fight back
A settings panel whose sliders are slingshots and ropes: stretch a thumb anywhere on screen, watch a dotted trajectory call the exact spot it will land, knock the label letters over, bounce through smoke. All real DOM, one 2D canvas, and zero physics libraries.
9 min read · Part of Angry slider, where the component itself is live
The big picture
Every settings panel on the web signs the same silent contract: sliders are boring. You drag a thumb along a groove, a number changes, nothing has mass. Angry slider breaks the contract on purpose: four sliders where two are slingshots (grab the handle, stretch it anywhere on screen, release, and it fires along a dotted arc to its new value) and two are ropes (grab the line itself, pull it into a bend, and fling the knob to wherever the physics says it will land).
The fun is layered. A flying knob knocks over any letters it passes, its own label first, but every label and value on the panel is fair game; they tumble, pile up on the ground line, and clear away when the panel heals. Hard landings kick up smoke and shake the whole panel. A Boom toggle turns every thumb into a bomb that detonates wherever it lands. And every sound (creaks, whooshes, thuds, the explosion) is synthesized live in the Web Audio API, with not a single audio file.
Angry slider, the live component
Live, try itA panel, not a shader
Most flashy canvas pieces are a shader with a DOM skin. This one inverts the stack: the visible star is a real DOM panel: labels, tags, values, track capsules, and handles are plain elements with CSS gradients, and canvases do the support work. A backdrop layer only clears to the background colour. Above the panel, one full-window 2D canvas paints everything transient: trajectory dots, stretch bands, the flying knob, smoke, ripples, and the fallen letters.
One slider row is worth studying up close, because every interaction distance is declared here: a thumb is grabbable within 18 px, a track click registers within ±13 px of the line, and a rope is grabbable anywhere in a ±20 px band around itself, filled or not. The fork dots that ride the tip of the fill are drawn on the canvas; they exist only while a slingshot is armed.
The bands are a priority system, not three overlapping hot zones. Move your pointer across a row below and watch which target the hit test would pick (nearest thumb wins first, then the rope band, then the track), and click to feel the polite path: on a sling, the value glides to where you clicked.
What a pointer means, decided per pixel
InteractiveTwo implementation details earned their keep. A canvas is a replaced element: an absolutely positioned canvas with only inset: 0 refuses to stretch and renders at its intrinsic size, so it needs explicit width/height: 100% and a backing store that is re-synced against the viewport and device pixel ratio on every resize. And because the panel can shake independently of the canvas, the shake is applied twice, a transform on the panel and the same offset in the canvas transform, so text and effects never tear apart.
One integrator, two truths
The classic way to draw a trajectory preview is a small ad-hoc parabola formula. It always drifts from the real flight, because the flight is integrated step by step while the preview is a closed form. This component refuses that drift: there is exactly one integrator, a semi-implicit Euler step at a fixed 240 Hz, and both the trajectory dots and the flying knob consume it. Accelerate first, apply drag, then move: that order (velocity before position) is what makes it semi-implicit, and it is the cheapest integrator that stays stable when you crank gravity to three times its weight.
// The one integrator: the flight and the preview both run this.const PHYS_DT = 1 / 240; // fixed step: 240 Hzfunction physStep(s) { s.vy += gravity * PHYS_DT; // 1. accelerate const d = Math.max(0, 1 - airDrag * PHYS_DT); s.vx *= d; s.vy *= d; // 2. drag s.x += s.vx * PHYS_DT; // 3. move s.y += s.vy * PHYS_DT;}// The preview: the same step, run ahead in one go.function predict(x, y, vx, vy, stopY, rangeMin, rangeMax) { const s = { x, y, vx, vy }; const dots = []; let acc = 0; for (let i = 0; i < 240 * 12; i++) { const prevY = s.y; physStep(s); acc += PHYS_DT; if (acc >= dotSpacing) { acc = 0; dots.push({ x: s.x, y: s.y }); } // landing = the first descending crossing of the track, in range if (s.vy > 0 && (prevY - stopY) * (s.y - stopY) < 0 && s.x >= rangeMin && s.x <= rangeMax) { return { dots, landing: { x: s.x, y: stopY } }; } }}The landing is not solved for; it is discovered. The prediction runs the exact same step function forward until the trajectory crosses its own track from above while descending, within range. That crossing is the landing, and the value field previews it live; the drawn dots stop within one dot-spacing of it, spacing being a dial. Because preview and flight run the same code, the knob lands exactly on the predicted spot even when you drag gravity or power while aiming.
The flight runs on an accumulator: real frame time is banked and spent in fixed 1/240 s slices, so a dropped frame advances the knob exactly as far as the prediction modelled. The bank itself is capped at 1/30 s per frame: a long stall buys 33 milliseconds of physics, enough to stay honest and no more. Watch it work:
// Real frames arrive late or early; the knob must not care.proj.acc += Math.min(dt, 1 / 30); // bank this frame's timewhile (proj.acc >= PHYS_DT) { // spend it in exact slices proj.acc -= PHYS_DT; physStep(proj.s); // one slice of reality sweepLetters(proj.s); // consequences run here too}One accumulator, two clocks
InteractiveThe slingshot
A sling thumb leads a double life. Grab it and drag along the track: for the first 18 px of travel it is a normal slider. Move more than 13 px off the line, or more than 18 px in total even along the track, and the DOM thumb hides, a canvas knob appears in your hand, two bands stretch from a fork on the track to the knob, and a creak starts rising in pitch with the pull distance. The threshold is the difference between a slider and a slingshot, so it is tuned to feel instant, not deliberate.
The fork is geometry with an opinion. The left dot sits exactly on the tip of the fill (the slingshot is anchored where your value is), and the right dot holds a fixed 18 px span to its right, with both bands attached at the dot centres. Release converts the pull into a launch: velocity is the anchor-to-pointer offset times a constant, aimed from the fork.
// Sling: pull distance becomes launch speed.const draw = clampToMaxStretch(pointer - anchor); // 280 px of stretch, maxconst vx = (anchor.x - draw.x) * LAUNCH_K * power; // LAUNCH_K = 6const vy = (anchor.y - draw.y) * LAUNCH_K * power;// Rope: depth becomes upward speed, the grab side aims it.const depth = pointer.y - grabStart.y; // pulling down loads itconst ratio = clamp(depth / maxStretch, 0, 1);const vx = (grabX - knobX0) * 4.5 * power * (0.35 + 0.65 * ratio) - (pointer.x - grabStart.x) * 2.0 * power;const vy = -depth * 8.0 * power;Clicking the track without grabbing does the polite thing: the value glides to where you clicked, animated with the same curve landings use. And the handle never teleports under you mid-gesture: arming the slingshot freezes the bar at the grabbed value, and when the knob lands the fill sweeps from that frozen position straight to where it landed, left or right.
Aim, predict, fire: one integrator
InteractiveThe rope
The rope rows look identical to the slings, deliberately so; the tag is the only tell. But the whole line is the toy. Grab it anywhere (the filled part and the slack part behave the same), pull, and the line bends through your cursor into a V while the knob threads along it, sinking as the bend deepens. The knot of the problem is a two-line geometry helper: which segment is the knob on, and where on it?
// The bend is the cursor, clamped to the rail. The knob threads// the segment the bend is NOT on, sinking as the bend deepens.const bendX = clamp(pointer.x, trackX, trackX + trackW);const bendY = pointer.y;if (knobX <= bendX) { // straight left segment: the knob rides the line to the bend const t = (knobX - trackX) / Math.max(1, bendX - trackX); knobY = trackY + (bendY - trackY) * t;} else { // right segment: the knob hangs lower the deeper the bend const t = (knobX - bendX) / Math.max(1, trackX + trackW - bendX); knobY = bendY + (trackY - bendY) * t;}Release fires the knob from its hanging position, with vertical speed from the pull depth and horizontal speed shaped by where you grabbed relative to the knob: grabbing the slack side flings it forward, grabbing the progress side shoots it backward. A tiny pull (no deeper than 14 px and less than 8 px sideways) is a click instead, and the knob glides to where you grabbed; any other sub-threshold release is simply discarded. While you pull, the prediction dots start at the hanging ball, so the launch is legible before you let go:
Grab, bend, fling
InteractiveLandings & the S-curve
When the knob touches down on its own track, the landing is a two-part trick. The thumb, the real DOM handle,snaps to the landing spot instantly: it is the object that flew, so it must be where the flight ended. The fill, meanwhile, tweens from its old position to meet it with a sharp easeInOutBack: a whisker of anticipation, a fast sweep, an overshoot past the mark, a settle.
// The only tween a landing gets: the fill's 0.55 s ease.function easeInOutBack(x) { const c2 = 1.70158 * 1.525; return x < 0.5 ? (Math.pow(2 * x, 2) * ((c2 + 1) * 2 * x - c2)) / 2 : (Math.pow(2 * x - 2, 2) * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2;}// The thumb snapped the instant the knob landed; only the fill// catches up, with a whisker of anticipation and an overshoot.fillAnim.t += dt;const k = easeInOutBack(Math.min(1, fillAnim.t / ANIM_DUR));fill.width = lerp(fromWidth, toWidth, k);The only easing a landing spends
InteractiveThe discipline here is negative space: the bar never moves backward as a reset when you arm a slingshot, never animates while you drag, and the value text never re-renders mid-flight; it shows the target the moment the tween starts. The animation budget is spent in exactly one place: the fill sliding forward to meet the ball. That restraint is why the one animated moment reads.
And a missed shot has an ending too. A knob that never crosses its own track lands on the ground, settles for half a second, then arcs back home to its pre-launch value. The panel's one other easing, a plain ease-out cubic with a little hop, is spent entirely on the return flight.
Letters, smoke, boom
The labels are not decoration; they are level geometry. Every letter of every label and value is a measured DOM rect. The flying knob sweeps them each frame, and a hit converts the DOM letter into a canvas particle: velocity from the impact, a random spin, gravity, a bounce, and rest on the ground line. After a few seconds the particles fade away and the untouched DOM spans snap back into place, so the panel always heals.
Landings add the physical consequences: the thumb squashes, a ripple runs along the track, the panel shakes, and smoke kicks up from the ground line, each one a toggleable knob, because what is decoration for one person is noise for another. With Boom on, every thumb grows a sparking fuse and every landing detonates. The Knock All Labels action in the controls panel exists for exactly one reason: knocking all the letters down at once never stops being funny.
Sound from nothing
There is not a single audio asset. Every sound (the rising creak of a stretch, the whoosh of a flight, the thud of a landing, the boom of a detonation) is synthesized from oscillators and one shared buffer of white noise. The AudioContext is created lazily on the first pointer-down, so autoplay policies never block it, and the master gain applies volume squared, because human loudness perception is roughly logarithmic and a linear knob feels dead at the bottom half.
The voices are worth copying. The creak is an oscillator with a slow LFO on its pitch, rising with pull distance: tension you can hear. The whoosh is filtered noise with the cutoff tied to speed. The landing thud is a pitched sine drop plus a slap of noise, and a rope release adds a springy boing (a triangle dropping from 320 Hz) underneath it. And the explosion is a composition in three layers:
// Explosion: nothing like the landing thud. Sub drop, noise// burst, debris crackle: all synthesized, detuned per shot.const detune = 0.85 + Math.random() * 0.3;const osc = ctx.createOscillator(); // sub drop: 160 Hz -> 26 Hzosc.frequency.setValueAtTime(160 * detune, t);osc.frequency.exponentialRampToValueAtTime(26, t + 0.5);const bp = ctx.createBiquadFilter(); // burst: lowpass sweeps shutbp.frequency.setValueAtTime(2600, t);bp.frequency.exponentialRampToValueAtTime(120, t + 0.45);for (let i = 0; i < 5; i++) { // sparse debris pops const at = t + 0.09 + i * 0.06 + Math.random() * 0.03; pop(at, (320 + Math.random() * 520) * detune);}The voices, on demand
InteractiveThat is the whole machine. Every physics, destruction, and sound behaviour in this article is a switch or a slider in the live controls on Angry slider's page (the interaction distances are constants in the engine), and presets like Moon Gravity and Heavy Iron re-weight the physics in one click. The build prompt there carries the exact constants that make it feel right.