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 it
The component itself, live. Every knob it exposes (gravity, drag, dot spacing, destruction, sound) is in the controls panel on its page.

A 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.

FX canvas (2D)trajectory dots · stretch bands · flying knob · smoke · fallen letterspointer-events: noneDOM panellabels · tags · values · track capsules · handles: the only layer that takes pointerspointer-events: autoBackdrop canvasclears to the background colour, nothing elsepointer-events: nonepointers land hereone transform shakes the panel,a matching offset shakes the canvas,type and effects move as one object
Only the DOM panel accepts pointers; the canvases are inert surfaces. The panel shake is a transform on the panel mirrored by a matching offset in the canvas transform, so typography and effects move as one object.

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.

ExposureSLING+0.4 EVgrab a thumb within 18 px ·click the track within ±13 px ·grab a rope anywhere in ±20 pxfork dots: tip · tip + 18 pxfill = the value (sweeps when a value is set: a landing or a polite click)handle = a 22 px shaded sphere, plain DOM + CSS gradientsletters of the label are measured rects: level geometry, see 07
The anatomy of a row. The dashed zone is the interaction band the hit test actually uses. The visible capsule is decoration; these numbers are the interface.

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

Interactive
The invisible interface, made visible. Hover to see the hit test's verdict at each point; click a sling track to set the value the polite way.

Two 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 shared integrator and the prediction loop
// 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:

Flight = prediction + consequences
// 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

Interactive
Frames arrive unevenly; the accumulator converts them into uniform slices. Whether a frame is 12 ms or 60 ms late, the physics advances by exactly the slices the banked time buys.

The 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.

Two toys, two velocity models
// 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

Interactive
A miniature of the whole deal: drag anywhere to aim, watch the prediction end on the marker, release, and the knob flies the same integrator to that exact spot. Stall the physics with the sliders; the dots never stop agreeing with the landing.

The 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?

Knob-on-rope interpolation
// 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;}
the rail at restthe knobthe bend = your cursor, clamped to the raillaunch fires from the hanging ball,the dots begin where your eyes already areprogress side (filled) and slack side behave the same: every point of the line is grabbable
Grabbing bends the rope through the cursor; the knob threads the opposite segment. The launch fires from the hanging ball, not the rail, so the dots begin where your eyes already are.

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

Interactive
The whole rope, in miniature: grab the line anywhere, pull down to load it, watch the dots begin at the hanging knob, release to fling. Grab the slack side and it flies forward; grab the progress side and it fires backward.

Landings & 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 easing a landing spends
// 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

Interactive
Play the landing, then switch to a plain ease-out. The dip below zero is the anticipation (the fill leans back for a frame); the hump past 1.0 is the overshoot. The whole personality of the landing is in one cubic.

The 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.

1 · MEASUREDsa DOM span witha measured rect2 · PARTICLEsimpact velocity,random spin, gravity3 · DEBRISsambounce, rest on theground line4 · RESTOREsamplesafter a timeout the particleclears: the untouched span snaps back
A letter's four lives. The DOM is the source of truth the whole time: the particle is a stunt double, and the originals never moved.

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:

The explosion voice
// 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

Interactive
The component's voices, ported straight from sound.ts: synthesized on the spot, nothing recorded. The creak rises with a pretend stretch, the way it follows your pull in the real panel.

That 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.