Most theme switches are a snap. You press the button and every colour on the
page changes in the same frame, so for a moment it looks as if a different page
has loaded under you.
The best-known way round it is the circle Telegram's app made familiar: a disc
of the new theme grows out of the toggle until it covers the page.
Akash Hamirwasia's write-up
is the version most implementations trace back to, and it works well. It also
reads as a spotlight, as if the change came out of the button. I wanted the new
colours to come down from the top of the page, with a soft edge where they meet
the old ones.
I could not find that written up anywhere, though the two halves exist.
Browsers can now hand you a picture of the page from before a change and one
from after it, stacked one over the other, and
Artur Bień's reveal effects
show how to animate a gradient mask, a kind of stencil this note builds below,
with one extra rule.
The frame above runs it. Press the sun in the framed page's nav, or the
Switch to dark button, then change a dial and run it again. The note assumes
you already have a light theme and a dark one, switched by a dark class on
<html>, which is how this site does it.
Two pictures of the page
The wipe rests on one browser function, document.startViewTransition(). You
pass it a function that changes the page. The browser takes a picture of the
page as it is, runs your change, then takes a second picture of the result.
Until the transition ends, it lays the two pictures over the real page like two
photos stacked on a table, the new one on top. Then it lifts them away, and the
real page underneath is already in its new colours.
Nothing on the real page moves while this happens. The animation plays on the
two pictures, and CSS can reach them by name: ::view-transition-old(root) is
the picture from before the change, and ::view-transition-new(root) the one
from after. The double colon marks them as pseudo-elements, parts of the page
you can style but never wrote in your HTML. You may have met ::before and
::after; these are the same kind of thing, made by the browser for the length
of a transition. root is the name it gives the picture of the full page.
Left to itself, the browser cross-fades the two: the old picture fades out as
the new one fades in. That is gentler than a snap, and it still has no
direction, since every part of the page changes at the same pace.
For the wipe, the old picture, in the old theme, stays still. Over the new
picture sits a stencil. Above a line, the stencil is open and you see the new
picture. Below the line it is closed, so the new picture is hidden there and
the old one shows from underneath. The line is smudged across a short band,
where you see a little of both. Then the line slides from slightly above the
top of the screen to the bottom, and the new theme follows it down.
Every rule that styles the two pictures for the wipe starts with
:root[data-theme-wipe].
:root is the <html> element, and the square brackets match an attribute, so
[data-theme-wipe] limits the rule to the moments when <html> carries an
attribute of that name. Any attribute that starts with data- is yours to
invent. The toggle adds this one right before a wipe and removes it afterwards.
The two pictures hang off <html>, which is why each selector starts from
:root. The (root) at the end is the picture's name, a different thing that
shares the word. Every view transition makes its own two root pictures, so without
that guard the rules would catch all of them. This site already runs a view
transition each time you move between pages, so every link click would bring
the wipe down too.
The first rule undoes two things the browser sets on both pictures for its
cross-fade. One is the fade itself, which the browser runs as an animation on
each picture, so animation: none switches it off. The other is a blend mode,
which decides how a picture mixes with whatever sits behind it, like the layer
modes in Figma or Photoshop. normal is the one you already know, where the
top layer covers what is beneath. What goes wrong without each line comes up
with the traps further down.
Every CSS piece in this note goes into the same stylesheet, in the order shown.
To uncover the new picture from the top down, it gets a mask. A mask sets how
much of an element shows at each point. Where the mask is solid, the element
shows, and the more transparent the mask, the less of the element you see. In
the stencil above, the open part is where the mask is solid, which can feel
backwards the first time. The browser reads only how solid the mask is, so the
#000 below stands for solid and could be any colour.
Anywhere CSS takes an image, it also takes a gradient, so the mask can be the
same linear-gradient() you may have used for a background. Take
linear-gradient(to bottom, #000 40%, transparent 54%). It runs from the top
edge down through two stops, the points where a colour is pinned. It is solid
down to 40% of the height, transparent from 54%, and fades from one to the
other in between. On the new picture, that is a blind pulled 40% of the way
down, its bottom edge blurred over the next 14%.
The Edge dial in the prototype sets the depth of the band. Hard is 0%: both
stops sit at the same point with no room to fade between them, so the edge is a
clean line. Soft is 14% and Wide is 30%.
To wipe, the first stop has to travel down the page while the gap to the second
stays the same. So the position of the edge and the depth of the band each go
into a custom property, which you may know as a CSS variable: the animation in
the next part moves the first, and the gradient reads both. You set one with a
name that starts with two hyphens, --theme-wipe-bleed: 14%, and read it
anywhere with var(--theme-wipe-bleed). --theme-wipe holds where the solid
part ends, and --theme-wipe-bleed the depth of the soft band. calc() adds
two values together, here the two properties, to place the transparent stop.
Both get their values further down.
The same sample keeps the new picture on top with z-index: in a stack, the
higher number sits nearer to you, so the old picture gets 0 and the new one 1.
Stack the pictures and mask the new onetheme-wipe.css
With these rules alone the switch is a snap again: nothing moves the edge yet,
so the new picture covers the old one at once.
The Direction dial in the prototype swaps that to bottom for to top,
to right or to left, and the edge travels that way. On this site the wipe
always comes from the top. Starting it from whichever side the toggle sits on
would tie it to the button the way the circle does; the dial picks a side by
hand, and I have not wired it to the toggle.
Making the edge move
An animation in CSS comes in two parts. @keyframes gives a name to a list of
states, here only two, from and to, and the animation property plays that
list on an element over a length of time. The browser works out every frame in
between. Halfway from 0px to 100px is 50px; halfway from red to blue is a
purple. A gradient has no halfway, so animating mask-image itself makes it
jump from the first gradient to the last. A custom property jumps as well,
because until you register it the browser keeps its value as a piece of text.
@property turns --theme-wipe from a light switch into a dimmer. It
registers the property and tells the browser what type of value it holds. Once
the browser knows the value is a percentage, it can find every step between two
percentages, and the gradient that reads it is drawn again from the new value
on every frame.
Register the edgetheme-wipe.css
@property --theme-wipe { syntax: "<percentage>"; /* The value stays on the element that sets it. */ inherits: false; /* Its value while nothing animates it: solid all the way down, so the new page shows in full. */ initial-value: 100%;}
The keyframes for the wipe start one band's depth above the top of the page:
calc(-1 * var(--theme-wipe-bleed)), which works out to -14% here. The soft
band reaches that far below the solid edge, so starting there keeps all of it
off the page on the first frame. From 0%, a faded sliver of the new theme
would show at the top before anything had moved. Because the start reads the
depth back, it follows the Edge dial on its own. The keyframes end at 100%,
with the solid edge at the bottom and the band below it, out of sight.
From above the page to the bottomtheme-wipe.css
@keyframes theme-wipe-down { from { --theme-wipe: calc(-1 * var(--theme-wipe-bleed)); } to { --theme-wipe: 100%; }}
The numbers worth tuning go on :root as ordinary custom properties. The wipe
is the longest animation on this site. I tried 400ms and 600ms first, and both
read as a flicker, the abrupt change the wipe is there to soften; the
prototype's Duration dial has 400 if you want to see it. A timing curve decides
how the movement is spread over the time. It is the same idea as the
ease-in-out you may have put on a hover, and cubic-bezier() lets you shape
your own with four numbers. cubic-bezier(0.32, 0.72, 0, 1) is the curve the
Ionic and Vaul libraries use for their drawers, the panels that slide in from
an edge of the screen. It covers most of the distance in the first third and
spends the rest settling, so the page starts turning as soon as you click.
The dialstheme-wipe.css
:root { --theme-wipe-duration: 800ms; --theme-wipe-ease: cubic-bezier(0.32, 0.72, 0, 1); /* Depth of the soft band. 0% gives a hard edge. */ --theme-wipe-bleed: 14%;}
This rule plays the keyframes on the new picture, with the duration and curve
from :root. both keeps the first frame in place before the animation starts
and holds the last one after it ends. The first rule set animation: none on
this same picture, and this one wins because it comes later: when two rules use
the same selector, the later one decides.
The toggle starts the wipe. It works out the next theme, adds the attribute to
<html>, makes the change inside startViewTransition(), and takes the
attribute off once the transition is over. root.dataset.themeWipe is how
JavaScript writes the data-theme-wipe attribute.
Two cases skip the animation and change the theme at once: a reader who has
asked their system for less motion, and a browser without
startViewTransition(). matchMedia checks the first by asking the browser the
same question a CSS media query would.
applyTheme switches the class, saves the choice in localStorage for the
next visit, and rewrites the theme-color meta tag, which sets the colour of a
phone browser's own bar. A class on <html> cannot reach that bar. The rewrite
runs inside the function passed to startViewTransition(), next to the class
change, so the bar turns on the same frame as the page.
startViewTransition() hands back an object, kept here as transition, and
its finished is a promise, JavaScript's way of saying "run this once that is
done". Here finally runs once the transition is over, however it ended, and
takes the attribute off. The empty catch keeps a failed transition from
turning into an error in the console.
The sample is TypeScript. The type Theme line and the types written after a
name, as in next: Theme, only describe the values, and deleting them leaves
plain JavaScript. () => applyTheme(next) is the function handed to
startViewTransition(), written short, and ?. skips the setAttribute call
when the page has no theme-color tag.
The toggletheme-toggle.ts
type Theme = "light" | "dark";/* Click again mid-wipe and the browser skips the first wipe and starts a second. Each wipe takes a number, and the first one's cleanup removes the attribute only when no newer wipe has started. */let wipeId = 0;// Your page's background colour in each theme.const THEME_COLOR: Record<Theme, string> = { light: "#fdfcf9", dark: "#19181a",};function applyTheme(next: Theme) { document.documentElement.classList.toggle("dark", next === "dark"); document .querySelector('meta[name="theme-color"]') ?.setAttribute("content", THEME_COLOR[next]); localStorage.setItem("theme", next);}export function toggleTheme() { const root = document.documentElement; const next: Theme = root.classList.contains("dark") ? "light" : "dark"; const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduced || typeof document.startViewTransition !== "function") { applyTheme(next); return; } const id = ++wipeId; // Set before the call, so the attribute is in place when the browser // takes the old picture. root.dataset.themeWipe = ""; const transition = document.startViewTransition(() => applyTheme(next)); transition.finished .catch(() => {}) .finally(() => { if (id === wipeId) delete root.dataset.themeWipe; });}
Where it goes wrong
None of these shows an error. The theme still changes, and only the wipe looks
wrong.
The first trap is anything on your page with its own view-transition-name.
Naming an element tells the browser to take a separate picture of it, so it can
move by itself while the page around it changes. This site names its sticky
header, which is how the header holds still while you move between pages.
During a wipe, a named header is lifted out of the page's picture and snaps to
the new theme on its own layer while the rest of the page is still turning.
Take the name away for the length of the wipe, behind the same
[data-theme-wipe] guard. For a header with the class site-header, that is
one more line in the same stylesheet:
:root[data-theme-wipe] .site-header { view-transition-name: none; }. The
browser reads names when it takes the old picture, and the toggle sets the
attribute before that, so by then the header has lost its name.
The second is the blend mode. The browser's own stylesheet, the defaults it
applies before yours, gives both pictures mix-blend-mode: plus-lighter, which
adds the light of the two together. That suits a cross-fade, where both
pictures are half see-through at the midpoint and adding them gives back one
page at full strength. Under the stencil both pictures are solid where they
overlap, so the sum is brighter than either page and the wipe blows out to
white through the middle, which is why the first rule sets both back to
normal.
The third is the old picture's fade. animation: none on the old picture looks
unneeded, since nothing in the wipe moves it. Without it the browser's fade-out
still runs underneath, and the page dims through the middle of the wipe.
What it costs, and when to skip it
While a wipe plays, the two pictures lie over the real page, so nothing on it
responds and every millisecond of the wipe is a delay on the reader's next
click. Set the prototype at the top to 1200ms, then 800ms, and compare. When
you have settings you like there, Copy for AI in its top bar copies a prompt
for your coding assistant with your choices written into the code.
The mask also makes the browser repaint: it draws the masked picture's pixels
again on every frame, which is heavier work than moving or fading something the
browser has already drawn. It runs once per click of the toggle, and the
picture under the mask never changes, so nothing beneath it has to be measured
or drawn again.
Some people are sensitive to motion on screen and turn on a system setting to
reduce it. CSS reads the setting with @media (prefers-reduced-motion: reduce),
and the rules inside it apply while the setting is on. The toggle has already
checked it at the click, so the stylesheet is there for the setting changing
while a wipe is running. Its second rule takes the wipe's animations down to
zero seconds. A zero-second animation could still leave the
mask covering part of the new page, so the first rule takes the mask away too.
Less motiontheme-wipe.css
@media (prefers-reduced-motion: reduce) { :root[data-theme-wipe]::view-transition-new(root) { mask-image: none; } /* (*) is every picture the browser takes, and a group holds each old and new pair. !important wins over the animations set above. */ :root[data-theme-wipe]::view-transition-group(*), :root[data-theme-wipe]::view-transition-old(*), :root[data-theme-wipe]::view-transition-new(*) { animation-duration: 0s !important; }}