Article Save button
A save you can see land
Saving is two events: something changes where you clicked, and something changes where the saved things are kept. This is the climb from a bookmark that only fills to one that ripples, and a count that answers it from across the page.
7 min read · Part of Save button, where the component itself is live
What you are looking at
Two things, a little apart. On the right, a bookmark button: the one you click. On the left, a row that says Saved and keeps a count. Click the bookmark and both of them move.
Save button, the live component
Live, try itIn a real interface the two are far apart: the button sits on a card, the row sits in a sidebar. Here they are side by side so you can watch one pass the news to the other. This article builds the pair a layer at a time, starting with a save that does not move at all.
It just fills
- What it does
- Remembers that you saved something: the bookmark fills, and the count goes up by one.
- Why it exists
- This is the whole job. Every layer after this one changes how clearly you see it happen, never what happens.
Click the bookmark
InteractiveThe bookmark fills and 12 becomes 13. Both are correct. Did you see the number change? You were looking at the button, and the number is somewhere else.
It works, and half of it goes unseen. The fill happens under your pointer, so you catch it. The count changes somewhere you are not looking, and a number that swaps in place gives the eye nothing to notice.
An icon with points to carry
- What it does
- Swaps the icon for a copy made of many small points, only while it is moving.
- Why it exists
- The icon as it ships is a handful of straight lines. A straight line cannot bend, so there is nothing for a ripple to travel along.
Give the wave more points
InteractiveStart at the left: the icon as it ships has a point at each corner and nothing between them, so the sides stay straight and only the tips move. Drag points to the right and the same swell turns into a curve. Then press play and watch the points carry it down.
The ripple you saw is not a filter or a picture being bent. The outline itself is redrawn every frame, with each point pushed a little out of place. That needs points along the whole edge, not only at the corners.
One level deeper. The icon is an SVG path: a short list of instructions like “line to here” and “arc to there”. The copy is a polyline, a path made only of straight hops, with a point every half unit of edge on the icon's 24 unit grid and eight hops round each top corner: 128 points. The hops are far smaller than a pixel at icon size, so the copy and the original cannot be told apart, and a bend drawn through them looks like a curve. When the swell has passed, the original path is put back.
One swell, top to tip
- What it does
- Sends one bump down the bookmark, from the top edge to the two tips, the way a ribbon moves when you flick it.
- Why it exists
- It happens exactly where you are looking, the moment you click, so there is no doubt the click was heard.
- Without it
- The bookmark fills, which is easy to miss on a 15 pixel icon, and that is all.
Move the swell by hand
InteractiveOn the right is the ribbon edge on. Where it leans toward you, the front view gets wider; where it falls back behind the page, the front view pinches. The left picture is only the right one, seen from in front.
A bookmark is a ribbon pinned at the top and loose at the bottom, so that is how it moves. The top edge stays where it is. A single bump enters at the top and runs down to the tips. Wherever the bump is, the ribbon has lifted off the page toward you; just behind it, it dips back the other way before it lies flat again.
One level deeper. The ribbon is worked out edge on first, as one number per height: how far it stands off the page. The bump is a bell curve, a Gaussian. lift is its height: at 0.5 the crest comes 4 units off the page, which the camera turns into half again as wide. spread is its width, as a share of the ribbon's length: at 0.34 about a third of the ribbon is lifted at any moment. Behind it comes a shallower bump of the opposite sign, the echo, 35% as deep. A part can lift in proportion to how low it hangs, so the pinned top cannot move at all.
Then two rules turn that side view into the picture you see. A ribbon does not stretch: where it leans toward you it covers less of the page, so the tips ride up a little while the swell is on them. Nearer things look bigger: every point is seen through a camera 12 units from the page, so the lifted part spreads away from the middle of the icon (half again as wide at the crest) and the dipped part pinches in. Nothing is drawn in 3D; the outline is still a flat path.
// How far the ribbon stands off the page at `hang` (0 at the pin, 1 at the tips),// in grid units toward the eye. One crest, and a shallower dip behind it.export function depthAt(hang: number, progress: number, feel: WaveFeel = WAVE): number { const crest = crestAt(progress, feel); const swell = bell(hang - crest, feel.spread) - feel.echo * bell(hang - crest + 1.5 * feel.spread, 0.8 * feel.spread); return DEPTH * feel.lift * settleAt(progress) * Math.max(hang, 0) ** 0.85 * swell;}// The ribbon seen from the side. It does not stretch, so where it leans// toward the eye it covers less of the page, and the tips ride up.export function ribbonAt(progress: number, feel: WaveFeel = WAVE): { z: number[]; y: number[] } { const z: number[] = []; const y: number[] = [0]; for (let i = 0; i <= STEPS; i += 1) z.push(depthAt(i / STEPS, progress, feel)); for (let i = 1; i <= STEPS; i += 1) { const slope = (z[i] - z[i - 1]) / (LENGTH / STEPS); y.push(y[i - 1] + Math.sqrt(Math.max(0.2, 1 - slope * slope)) / STEPS); } return { z, y };}/** Every outline point at progress 0..1 of one swell, as the eye sees it. */export function wavePoints(progress: number, feel: WaveFeel = WAVE): Array<[number, number]> { const ribbon = ribbonAt(progress, feel); return OUTLINE.map(([x, y]) => { // ... read depth and down from the ribbon at this point's height ... // The camera looks at the middle of the icon: nearer things spread away from it. const scale = CAMERA / (CAMERA - depth); return [12 + (x - 12) * scale, 12 + (down - 12) * scale]; });}The same click, with and without
InteractiveClick each, then click again to take the save back. The swell only runs on the way in: saving is celebrated, unsaving is not.
The swell is drawn with requestAnimationFrame, 900 ms from start to end, and when it ends the original path is put back. Whatever else styles that icon, a hover colour or a filled state, is styling the ordinary icon again.
function wave(): void { cancelAnimationFrame(frame); if (stillOnly()) return; const feel = options.wave(); const start = performance.now(); const tick = (now: number): void => { const progress = Math.min(1, (now - start) / Math.max(feel.duration, 1)); if (progress < 1) { path.setAttribute('d', wavePath(progress, feel)); frame = requestAnimationFrame(tick); } else { // The swell has left: hand the icon back, exactly as it was drawn. path.setAttribute('d', REST_PATH); } }; frame = requestAnimationFrame(tick);}The row answers
- What it does
- Makes the count's row react: its icon jumps, the digit that changed rolls over to the new one, and a ring spreads out from the row and fades.
- Why it exists
- The number is where your saved things are kept, and you were not looking at it. Something has to pull your eye there once.
- Without it
- The count is right and nobody sees it change. People save something and then go looking for where it went.
Add saves and watch the row
InteractivePress Add a save a few times, past 19 if you like: only the digits that change roll. Then take the pop down to 1.00x and switch the ring off. The number still rolls, and that alone may be enough for a dense interface.
Each of the three does a different job. The pop is the loudest and is on the icon, which matches the icon you just clicked. The number rolls upward like a counter, which says “more” without words, and only the digit that changed moves: from 12 to 13 the 1 stays still. The ring is the only part that leaves the row, so it is the part you catch from the corner of your eye.
One level deeper. The pop is one animation with four stops over 620 ms: rest, up to 1.45 times its size tilted back 12 degrees, down to 0.92 tilted 6 degrees the other way, rest. Each leg has its own ease, fast out and slow in. The undershoot is what makes it land instead of deflate. Each digit sits in its own slot, a window one line tall. A digit that changes leaves through the top of its window over 300 ms while the new one comes in from the bottom over 420 ms; digits are matched from the right, so going from 19 to 20 rolls both and going from 20 to 21 rolls one. The ring is a shadow with no blur on an element of its own laid over the row, growing from nothing to 10 px while it fades out, so the row's real edge never changes.
function bump(next: number): void { window.clearTimeout(timer); write(next, 1); // the digits that changed roll up if (stillOnly() || options.plain) return; const pop = options.pop(); const u = options.scale(); // The pop: up and back, down and forward, home. Three legs, each eased; transform only. // It is on the drawing inside the icon, so every frame is drawn as a shape at its real size. mark.body.animate( [ { transform: 'scale(1)', easing: EASE }, { transform: `scale(${pop.scale}) rotate(${-pop.tilt}deg)`, offset: 0.35, easing: EASE }, { transform: `scale(${1 - (pop.scale - 1) * 0.18}) rotate(${pop.tilt / 2}deg)`, offset: 0.65, easing: EASE }, { transform: 'none' }, ], // The easing belongs to each leg. On the whole animation it would spend the first leg in a few frames. { duration: pop.duration }, ); if (pop.ring) { ring.animate( [{ boxShadow: `0 0 0 0 color-mix(in srgb, ${ink} 45%, transparent)` }, { boxShadow: `0 0 0 ${pop.ringSize * u}px transparent` }], { duration: pop.duration, easing: 'ease-out' }, ); } // The swell starts while the pop is still settling, so the two overlap instead of queueing. if (options.swell !== false) timer = window.setTimeout(() => mark.wave(), Math.max(options.handoff(), 0));}One after the other
- What it does
- Starts the row's own swell part of the way into its pop, so the save seems to travel from the button to the row.
- Why it exists
- Two bookmarks rippling in the same instant look like a coincidence. One after the other looks like cause and effect.
Move the gap
InteractiveAt 0 ms the pop and the swell fight over the same icon. Past about 500 ms the swell arrives after everything has stopped and reads as a second, unrelated event. In between, one motion passes into the next.
One level deeper. The wait is a timer from the click, not a callback from the end of the pop. That is deliberate: the pop takes 620 ms, and a swell that waited for it would arrive late and alone. The start is set as a share of the pop, 27% of the way in, which is about 170 ms. The icon is still on its way up then, so the swell runs down a bookmark that is in the middle of its jump, and the two read as one gesture. Because it is a share, a slower pop moves the swell with it.
const row = mountSavedRow(set, { count: shown, wave, pop, handoff, scale: () => u });const button = mountSaveButton(set, { wave, onChange(saved) { shown = base() + (saved ? 1 : 0); if (saved) row.bump(shown); // celebrated else row.settle(shown); // quiet: the number comes down, nothing else moves },});The small truths
- What it does
- Five decisions that nobody notices when they are right.
Unsaving is quiet. Take the save back and the bookmark empties and the digit rolls the other way, downward. No swell, no pop, no ring. A celebration for removing something would be noise, and it would cheapen the one for adding.
The pop stays sharp. Scale an icon with a transform on the icon itself and the browser may enlarge a picture of it that was taken at its resting size, which goes soft at 1.45 times and softer beyond. The pop is applied to the drawing inside the icon, so each frame is drawn as a shape at the size it is shown.
The swell needs room. Under the crest the ribbon is wider than the icon's own box, and an SVG clips to its box by default. The icon is told not to (overflow: visible), or the widest moment of the swell would be cut flat on both sides.
Motion can be switched off. With reduced motion on, the bookmark fills and the number changes, and nothing travels. The state was always carried by the fill and the number; the motion only points at them.
It is said out loud, once. The button is a real button that reports pressed or not, and its name changes from Save to Remove from saved. The row is a status region, so a screen reader hears the new count without being moved there.
Two parts, one save
Put the layers back together and a save is five small motions and one number: a swell where you clicked, then a pop, a rolling digit, a ring, and a second swell about 170 ms behind, where the save landed. The swell is 900 ms with a lift of 0.5 and an echo of 0.35; the pop is 1.45 times over 620 ms.
That is the whole machine. Every number here is a control on the component's page, with presets from calm to loud, and the prompt there builds the pair.