Rolling a theme switcher, letter by letter
· Build log
The theme switch in the footer is a word. Press it and the letters roll up and out, the next word rolls in from below, and the button eases to the new width. Three animations that have to read as one, at any speed and from any point.
Problem
The first version used @keyframes and took about 520ms. Click twice and the leaving word jumped back to full before it left. The letters wore a heavy 3px blur, and the button's colour faded over 150ms while the rest of the page snapped, so it lagged behind its own theme.
Then, once it was fast, the text shook while the button resized: it clipped, then jumped into place.
Anatomy
Each word is split into inline-block letters. A letter starts 70% below its line, at opacity 0 behind a 2px blur, and settles in 300ms, 18ms after the one before it. Opacity and blur finish early, at 240ms, so a letter is sharp before it lands.
Leaving is two thirds of arriving: 200ms, 12ms apart. The old word should be out of the way before the new one asks to be read.
.roll {
--in: 300ms; /* letters settle; opacity and blur take 80% of it */
--stagger: 18ms; /* between letters */
--travel: 70%; /* how far below a letter starts */
--blur: 2px;
--ease: cubic-bezier(0.23, 1, 0.32, 1);
}
/* Leaving is two thirds of arriving: 200ms, 12ms apart, fade in 160ms. */Every timing is a variable, so the playground above drives the real component, not a copy.
Frame by frame
One curve, cubic-bezier(0.23, 1, 0.32, 1), for letters and width alike. It starts fast, so the press answers at once, and spends its time arriving.
Interruptible
People click again before the roll ends. With @keyframes, changing the animation restarts it, so an interrupted word flashes back to full before leaving. Transitions carry on from wherever the letters are.
So nothing is replayed. The current word stays mounted and flips a data-out attribute; the next word is added beside it and enters through @starting-style.
// New text: the current word stays mounted and flips to "out";
// the next one is added beside it and enters via @starting-style.
setWords((ws) => [...ws, { id, text, enter: true }])
<span className="roll-word" data-out={!isCurrent || undefined}>Pin the words
The shake came from layout. The words sat in one inline-grid cell, and the column stays as wide as the widest word on screen. As the box narrowed past it, the words aligned to the column's edge, outside the box, so they clipped, then jumped when the old word was removed.
Now every word is absolutely positioned against the right edge, the one edge that never moves, and the box's width eases to the measured incoming word on the same 300ms curve.
One frame for colour
The page and the button should change theme together. For two frames every other transition is off, so colour flips at once and only the letters move.
root.classList.add('theme-switching')
root.dataset.theme = dark ? 'dark' : undefined
requestAnimationFrame(() =>
requestAnimationFrame(() => root.classList.remove('theme-switching')))
/* everything except the letters and their box */
.theme-switching *:not(.roll, .roll-letter) { transition: none !important; }Details
- The box is
height: 1lhwithcontent-boxsizing and a2pxgutter, so blurred letters aren't shaved at the edges. - With reduced motion, letters only fade over
150msand the width jumps. - The button is named “Theme: Light. Switch to Dark”; the letters are
aria-hidden. - The Copy button below uses the same component.
Use it
To build this in your own product, give this prompt to your coding agent. It has every value from this page.
Build a theme switch for a site footer that is a word, not an icon. Each click cycles Light → Dark → System. React and plain CSS, no animation library.
Letters
- Split each word into inline-block letters, aria-hidden. Name the button like "Theme: Light. Switch to Dark".
Enter
- Incoming letters start 70% below, at opacity 0 with blur(2px).
- They settle over 300ms; opacity and blur finish in 240ms. Curve: cubic-bezier(0.23, 1, 0.32, 1).
- 18ms between letters.
Leave
- Outgoing letters roll up 70% and fade: 200ms movement, 160ms fade and blur, 12ms between letters. Leaving is two thirds of arriving.
Interruptible
- Use CSS transitions with @starting-style, not @keyframes, so a click mid-roll carries on from where the letters are instead of restarting.
- Keep the current word mounted and flip a data-out attribute on it; add the next word beside it. Remove words once they've left (about 400ms).
Width
- Measure the incoming word in a layout effect and transition the button's width to it on the same 300ms curve.
- Position every word absolutely against the right edge, the edge that doesn't move. Don't stack them in a grid cell: the column stays as wide as the widest word, so the text clips, then jumps.
- Give the box height: 1lh and overflow: hidden, with box-sizing: content-box and a 2px gutter (padding, cancelled by negative margin) so blurred letters aren't clipped.
Colour
- Switch every other colour in one frame: add a class to <html> for two animation frames that sets transition: none on everything except the letters and the box.
Reduced motion
- Letters only fade, over 150ms with no stagger. The width jumps.References
- Designing Fluid Interfaces, Apple, WWDC 2018: why motion should be interruptible.
- @starting-style, MDN: entry transitions without keyframes.
- Slider, Fluid Functionalism: the scrubber rows in the playground.
- Card to dialog, Ayomidé Daniel: the shape of this write-up.