Article Elastic toggles
A toggle, one layer at a time
A toggle holds one true or false value. Everything else is how it moves. This is the climb from a checkbox to a knob that stretches, springs, leans into your press, and keeps moving while the page behind it is frozen.
8 min read · Part of Elastic toggles, where the component itself is live
What you are looking at
Six toggles. On the left, three switches: click anywhere on one and it flips. On the right, three two-sided toggles: there you click the side you want. Each row has its own temperament: balanced, firm, and elastic to the point of being a cartoon.
Elastic toggles, the live component
Live, try itAll six are one machine with different numbers. This article builds it a layer at a time, starting from a toggle with no motion at all, so you can decide where to stop for your own project.
It just flips
- What it does
- Holds one true or false value and changes it when you click.
- Why it exists
- This is the whole job. Every layer after this one changes how it feels, never what it does.
Click them
InteractiveAll three hold one true or false value and change it the moment you click. Nothing travels: the knob is in one place, then it is in the other.
They work, and they feel like nothing. The knob is in one place, then in the other, and your eye has to find it again.
It slides
- What it does
- Moves the knob across over a moment instead of placing it.
- Without it
- The change is a cut. The eye loses the knob and finds it again on the other side.
The same toggles, now sliding
InteractiveNow your eye can follow the knob across. Drag the duration up and it turns sluggish; around 200 ms it reads as instant but connected.
One level deeper. The knob moves with transform: translateX(), never with left: a position property makes the browser lay the page out again on every frame, a transform only moves a finished picture. And it leaves at full speed and slows into the stop. A curve that winds up slowly feels like the toggle hesitated.
This is the firm row: 220 ms, no stretch, no overshoot. It is a complete toggle, and many interfaces should stop here.
It stretches
- What it does
- Lets the knob grow long as it leaves and shrink back as it lands, like something soft being pulled.
- Why it exists
- A rigid knob shows that it moved. A stretching one shows which way it went, and how fast.
There is an obvious way to make a shape longer, and it is wrong. Try both in slow motion and watch the round ends.
Stretch it two ways
InteractiveWatch the ends while each one is long. The scaled knob turns into an egg and the scaled plate's corners smear; the built ones keep the same round ends at any length.
One level deeper. The obvious way is scaleX, but scaling stretches the corners too. So the shape is built from three pieces: two copies of itself and a plain bar between them. One copy stays, the other slides out, and the bar scales to fill the gap. The plate of the two-sided toggle is built the same way.
The motion is one move, not a stretch followed by a settle. The stretch follows the speed: longest when the knob is fastest, gone by the time it has slowed down. So the front never reaches the wall first and waits for the back to pile in behind it, which is what reads as a rubbery landing.
// The trailing edge makes the trip on a spring; the stretch follows its speed.const trail = trail0 + (trail1 - trail0) * springAt(t, bounce);let q = (reach * speed(t)) / top;// A calm feel lands round: its front edge keeps a share of the remaining// distance in hand, so front and back close in on the wall together.const left = dir * (trail1 - trail);const cap = Math.max(left, 0) * LANDING_SHARE; // 0.6const landed = (q * cap) / Math.cbrt(q * q * q + cap * cap * cap || 1);q = landed + (q - landed) * Math.min(feel.bounce / 0.5, 1);// Drawing a pose touches only transforms.function setPose(pose: Pose): void { const u = scale(); mover.style.transform = `translateX(${pose.x * u}px)`; body.style.transform = `scaleY(${pose.sy})`; far.style.transform = `translateX(${pose.e * u}px)`; bar.style.transform = `scaleX(${pose.e / base})`;}It springs back
- What it does
- Decides how the knob comes to rest: dead on the spot, with one soft overshoot, or with a wobble.
- Why it exists
- This one number is most of what people mean by how a toggle feels.
How it comes to rest
InteractiveAt 0 the curve meets the rest line and stops. Raise it and the curve shoots past and swings back. The balanced and firm rows sit at 0; the elastic row lives at the far end.
One level deeper. The curve is a damped spring: a weight on a spring, released. bounce sets the damping. At 0 it reaches rest as fast as it can without passing it; above 0 it passes, swings back, and the swings die away. The elastic row adds squash: the knob is 22% flatter while it is long, and springs past its full height on the way home.
export function springAt(t: number, bounce: number): number { if (t <= 0) return 0; if (t >= 1) return 1; const zeta = Math.max(0.08, 1 - Math.min(Math.max(bounce, 0), 0.92)); if (zeta >= 0.999) { const w = 9.5; return 1 - Math.exp(-w * t) * (1 + w * t); } const decay = 6.9; // e^-6.9 is 0.1%: settled const wd = (decay * Math.sqrt(1 - zeta * zeta)) / zeta; return 1 - Math.exp(-decay * t) * (Math.cos(wd * t) + (decay / wd) * Math.sin(wd * t));}It answers the press
- What it does
- Reacts the moment the mouse button goes down, before the click is finished.
- Why it exists
- A click only counts when the button comes back up. Without this, the toggle ignores you until then.
Press and hold, then let go
InteractiveHold the mouse button down without releasing. The knob leans toward where it will go and waits. Let go and it leaves from that pose; drag off before letting go and it springs back.
One level deeper. On pointer down the knob takes 150 ms to lean toward where it would go, and holds. Letting go commits from that pose, so press and release read as one gesture; dragging off springs it back. A switch always leans toward its other state. A two-sided toggle leans toward the side under the pointer, and if that side is already chosen it only squeezes a little.
// How far a held press leans, in base px. A stiffer feel leans less.const lean = (base, feel, amount) => amount * base * (0.18 + 0.4 * feel.stretch);function onPointerDown(event: PointerEvent): void { const { enabled, amount } = press(); if (!enabled || amount <= 0) return; const target = kind === "switch" ? !on : side === 1; const now = part.current(); const p = lean(base, options.feel(), amount); const pose: Pose = target === on ? { x: now.x, e: 0, sy: 1 - 0.14 * amount } // already there: a small squeeze, nothing more : target ? { x: now.x, e: p, sy: 1 - 0.06 * amount } : { x: now.x - p, e: p, sy: 1 - 0.06 * amount }; held = true; void part.fly([{ pose, ms: 150, easeX: GO, easeShape: GO }]);}It cannot be stalled
- What it does
- Gives the whole flight to the browser in the click, so it plays even while the page is frozen.
- Why it exists
- Toggles switch things on, and whatever they switch on makes the page busy just as the knob is moving.
- Without it
- The knob stretches, then hangs there, long and stuck, until the page recovers.
Flip both while the page is busy
InteractiveThe page freezes right after the flip, as it would while something heavy loads. The chained knob hangs stretched until the page comes back, because the second half of its flight is waiting for script. The other one never notices.
One level deeper. Script and layout share one thread. A second one, the compositor, can move and fade finished layers by itself, so a transform animation survives a frozen page, but only if it was described in full up front. The chained toggle starts the second half of its flight from script, and script cannot run while the page is busy. The fix is one animation per moving part, with the whole flight written out as keyframes in the click.
function animate(from: Pose, beats: Beat[]): Animation[] { const u = scale(); const total = beats.reduce((sum, beat) => sum + beat.ms, 0); let elapsed = 0; const frames = [{ pose: from, offset: 0 }, ...beats.map((beat) => ({ pose: beat.pose, offset: (elapsed += beat.ms) / total }))]; // A keyframe's easing shapes the stretch of time up to the next keyframe. const keys = (value, ease) => frames.map((frame, i) => ({ transform: value(frame.pose), offset: frame.offset, easing: beats[i] ? ease(beats[i]) : "linear" })); const timing = { duration: total, fill: "forwards" }; return [ mover.animate(keys((p) => `translateX(${p.x * u}px)`, (b) => b.easeX), timing), body.animate(keys(bodyTransform, (b) => b.easeHeight ?? b.easeShape), timing), far.animate(keys((p) => `translateX(${p.e * u}px)`, (b) => b.easeShape), timing), bar.animate(keys((p) => `scaleX(${p.e / base})`, (b) => b.easeShape), timing), ];}The small truths
- What it does
- Three corrections that nobody notices when they are right.
On and off are the same size. The edge is a ring drawn on top of everything, the same in both states. If the fill covered a border instead, the track would look larger when on and the knob smaller. The knob is placed by arithmetic, 4px from every side, so it stays centred at any scale.
Colour is a fade, not a repaint. The on colours already exist as layers over the resting ones, and turning on only fades them in. Opacity, like transform, belongs to the compositor. The on fill is deliberately quiet, so the bright knob carries the state.
Nothing leaves the track. Before each flight the bounce is lowered until its widest swing fits in the gap around the knob. The swing is sized to the room it has, not cut off by it. The elastic row keeps its wobble in its height, where there is space.
// The most bounce, up to `wanted`, whose widest swing over `distance` stays within `room`.function bounceWithin(wanted: number, distance: number, room: number): number { let bounce = Math.min(Math.max(wanted, 0), 0.92); while (bounce > 0 && overshootOf(bounce) * distance > room) bounce -= 0.02; return Math.max(bounce, 0);}// The trip rides this spring, so the part cannot swing past the gap around it.const bounce = bounceWithin(feel.bounce, trip, room);Two toggles, three feels
Put the layers back together and the six toggles are four numbers per row. Balanced is stretch 0.45, squash 0, bounce 0 over 520 ms. Firm is 0, 0, 0 over 220 ms. Elastic is 1, 0.22, 0.8 over 1100 ms. With reduced motion switched on, all three skip the flight and place the knob.
That is the whole machine. Every number here is a control on the component's page, next to the presets, and the prompt there builds the switch by default, with the two-sided toggle and the full set as options.