Article Elastic toggles
Adding fidelity to a toggle
A checkbox already does the job. This is what it takes to make a toggle feel made with care: small layers of motion and detail that never change what it does, only how it answers your hand.
10 min read · Part of Elastic toggles, where the component itself is live
What you are looking at
A toggle is one of the smallest things on a screen and one of the most touched. That makes it a good place to show what care for detail adds to an interface that already works.
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.
The baseline
This is the starting point: a value that changes when you click, and nothing more.
Click them
InteractiveThis already does the job. Every layer from here changes how it feels, never what it does.
They work, and they feel like nothing. Your eye has to find the knob again after every click.
It slides
The first layer is motion: the knob travels across instead of jumping, so your eye can follow it.
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
A rigid knob shows that it moved. A knob that stretches as it leaves 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
How the knob comes to rest is most of what people mean when they say a toggle feels good.
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 firm row sits at 0, the balanced row at 0.3, and the elastic row 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
A click only counts when the button comes back up. This layer lets the toggle react the moment it goes down, so it never ignores you.
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
Whatever a toggle switches on tends to make the page busy just as the knob is moving. This layer keeps the motion smooth through that.
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), ];}Details nobody sees
Three corrections that nobody notices when they are right, and everybody feels when they are wrong.
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);Where to stop
Put the layers back together and the six toggles are four numbers per row. Balanced is stretch 0.2, squash 0.1, bounce 0.3 over 720 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.
Switch, checkbox, or segmented control
Everything above is about how a toggle feels. This last part is about when to use one at all. Switches look good, so they get picked for jobs that belong to something else, and the person using it pays for that with a moment of doubt.
Four controls, four jobs
InteractiveClick anywhere on the switch and it flips. On a segmented control you click the option you want, and clicking the chosen one does nothing.
A checkbox is a choice you confirm later. Ticking it doesn't do anything yet. Think of the “Send me the newsletter” box at checkout: nothing is sent until you place the order. Same with picking toppings for a pizza, or the three files you want to delete. Checkboxes are for forms and for lists where you can pick more than one. They also have a trick the others don't: in Gmail, select a few emails and the box at the top shows a dash, because some are picked and some aren't.
A switch is a setting that changes now. Airplane mode is the textbook case. You flip it and the radios go off, right then, with no Save button anywhere. Nielsen Norman Group puts it plainly: “Toggle switches should take immediate effect.” So if a settings page has a Save button at the bottom, the rows above it should be checkboxes, because a switch that hasn't done anything yet is lying to you. A switch also only knows on and off, and its label names the thing (Wi-Fi), not the state it is in.
A segmented control is one of a few named options. What this article calls the two-sided toggle is a segmented control, and it doesn't stop at two. Day, Week and Month in a calendar is the same control with three. Icon, List, Columns and Gallery in the Mac's Finder has four. All and Missed at the top of the iPhone's call history has two. Every system has its own name for it: Apple says segmented control, Material says segmented button, IBM Carbon says content switcher, MUI says toggle button group. It works for two to about five related options that change what you're looking at. Past five, use a dropdown or a list of radio buttons. If each option takes you to a different page, you want tabs.
A toggle button is one button that stays pressed. Bold in a text editor. Mute in a video call. The bookmark you tap to save a post. Each is a switch dressed as a button, and it makes sense where buttons already live: toolbars, cards, the row of actions under a video.
Why a switch flips anywhere and a segmented control doesn't. A switch has one subject, so a click can only mean one thing: the other state. That makes the whole thing a target, and a big target is easy to hit. A segmented control has a separate answer in every segment, so a click means “this one” and has to land on the option you mean. Clicking the one that's already chosen does nothing, and that matters more than it sounds. Picture someone in Finder who isn't sure they're in List view and clicks List to make sure. If the control flipped on any click, they'd end up in Icon view, the opposite of what they asked for. The keyboard follows the same logic: Space flips a switch, while the arrow keys move between segments.
The test. Say the label with “off” after it. “Wi-Fi off” makes sense, so it's a switch. “Grid off” doesn't tell you what you get instead, so the options need their own names: that's a segmented control. And if nothing should happen until someone presses Save or Next, it's a checkbox. Platforms bend the rules a little. On the Mac, Apple prefers a checkbox even for a single setting and keeps switches for rows in a settings list.
To read further: Checkbox or switch? on Bootcamp, Nielsen Norman Group's toggle switch guidelines, and the W3C patterns for the switch and the radio group, which is how a segmented control is built.