A light and dark swap revealed top to bottom behind a feathered edge, on the View Transitions API and one registered custom property, with no JavaScript running per frame.
Written by
Ahmed Amr
Year
2026
Status
Prototype
Built with
CSS, View Transitions API, TypeScript
Does a theme switch have to be a snap, or can the new colour arrive from a direction without the page paying for it every frame?
In this article6
Loading lab note
Soft-edged theme wipe
WorkLabWriting
Lab
Interface experiments
Smaller than a case study and more honest than a mockup. Each note opens with a question.
Soft-edged theme wipe
Prototype · 2026
What I was testing
A theme switch is usually a snap. Every colour on the page changes in the same
frame, and the eye has nothing to hold on to: it reads as the page being
replaced rather than changed.
The usual answer is the Telegram circle. A disc grows out of the toggle until
it covers the page. It has been written up many times, and
this one
is the version most implementations trace back to. It is good, and it is also a
spotlight: it says the change came from the button. I wanted the change to
arrive from a direction instead, the way a blind is drawn, and with a soft edge
rather than a line.
I could not find that written up anywhere. The two halves exist separately.
The View Transitions API hands you the old page and the new page as two stacked
images, and Artur Bień's reveal effects
show that a gradient mask becomes animatable once its stop is a registered
property. Put together they are the whole effect, and nothing runs in
JavaScript per frame.
The old snapshot sits still.startViewTransition captures the page, runs
the class change, captures it again, and hands back two pseudo-elements. The
browser's own plan is to cross-fade them. Here the old one is left exactly
where it is, with its fade removed.
The new one is masked, not clipped. It is stacked above the old one and
revealed through a linear-gradient mask: opaque above a stop, transparent
below it, with a band between the two where it feathers. clip-path: inset()
would do the same job with a razor edge. The band is the point.
The stop position is a registered property. Gradients are not
interpolatable, so animating mask-image directly jumps between keyframes.
Registering the stop as a typed <percentage> with @property makes it a
real animatable value, and the gradient re-resolves from it on every frame.
The wipetheme-wipe.css
/* The stop has to be a typed property. A gradient cannot be interpolated, but a registered <percentage> can, and the gradient is re-resolved from it on every frame. */@property --theme-wipe { syntax: "<percentage>"; inherits: false; initial-value: 100%;}:root { --theme-wipe-duration: 800ms; --theme-wipe-ease: cubic-bezier(0.32, 0.72, 0, 1); /* Depth of the feather, as a share of the viewport. */ --theme-wipe-bleed: 14%;}/* Scoped to one attribute, so nothing here runs for any other view transition on the page. The toggle sets it before starting and clears it when the transition finishes. */:root[data-theme-wipe]::view-transition-old(root),:root[data-theme-wipe]::view-transition-new(root) { /* The browser's cross-fade and its plus-lighter blend, both dropped. */ animation: none; mix-blend-mode: normal;}/* The outgoing page sits still and gets covered. */:root[data-theme-wipe]::view-transition-old(root) { z-index: 0;}/* The incoming page is revealed through the mask: opaque above the stop, transparent below it, feathered across the bleed. */:root[data-theme-wipe]::view-transition-new(root) { z-index: 1; mask-image: linear-gradient( to bottom, #000 var(--theme-wipe), transparent calc(var(--theme-wipe) + var(--theme-wipe-bleed)) ); animation: theme-wipe-down var(--theme-wipe-duration) var(--theme-wipe-ease) both;}/* Starts one bleed above the top edge, so the feather is off-screen on the first frame and none of the new theme shows early. */@keyframes theme-wipe-down { from { --theme-wipe: -14%; } to { --theme-wipe: 100%; }}@media (prefers-reduced-motion: reduce) { /* The toggle already skips the transition. This covers a preference that changes mid-wipe, where a zeroed animation would freeze the mask on its opening frame and leave the new page invisible. */ :root[data-theme-wipe]::view-transition-new(root) { mask-image: none; } ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation-duration: 0s !important; }}
The toggle's job is small: put an attribute on <html> so the rules above
apply to this one transition, run the class change inside
startViewTransition, and clear the attribute when the transition finishes.
Both fallbacks land on a plain swap, which is exactly where the toggle was
before the animation existed.
The toggletheme-toggle.ts
type Theme = "light" | "dark";/* Rapid clicks skip the running transition and start another, so the first one's cleanup must not pull the attribute out from under the second. */let wipeId = 0;function applyTheme(next: Theme) { document.documentElement.classList.toggle("dark", next === "dark"); 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; // Before the transition starts: the outgoing snapshot is captured the // moment startViewTransition is called, and the stylesheet keys off this. root.dataset.themeWipe = ""; const transition = document.startViewTransition(() => applyTheme(next)); transition.finished .catch(() => {}) .finally(() => { if (id === wipeId) delete root.dataset.themeWipe; });}
If something on the page carries its own view-transition-name, a sticky
header for instance, it is lifted out of the root snapshot and snaps to the new
theme on its own layer while the rest of the page is still turning. Release it
under the same attribute:
:root[data-theme-wipe] .site-header { view-transition-name: none; }.
Design decisions
A mask, not a clip
Chose
The new snapshot is revealed through a gradient mask with a 14% feather, so the new theme bleeds into the old one instead of guillotining it.
Trade-off
A mask is a paint-level animation, and this is the only one on the site. It is acceptable here because it runs once per explicit click, never on scroll, and it plays over a static snapshot rather than live content, so nothing underneath re-lays-out or re-rasterises.
Scoped to an attribute, never to the bare root
Chose
Every rule is written against the data-theme-wipe attribute on the root. The toggle sets it before starting the transition and clears it after.
Trade-off
One more thing to set and to clean up, and a counter so a rapid second click does not have the first click's cleanup pull the attribute out from under it. The alternative is worse: this site already animates the root snapshot on navigation, and an unscoped rule would drop an 800ms curtain on every link click.
800ms on a drawer curve
Chose
Longer than anything else on the site, on the Ionic and Vaul drawer curve: most of the distance in the first third, then a long settle.
Trade-off
The page is frozen for the duration, so this is input latency, and it is the longest animation on the site. 400ms and 600ms were both tried and both read as a flicker, which defeats an animation whose only job is to soften an abrupt change.
Two things that silently break it
The blend mode. The browser's own stylesheet sets
mix-blend-mode: plus-lighter on both snapshots. That is right for its
cross-fade and wrong for stacking one opaque image over another: the wipe blows
out to white through the middle. Both snapshots go back to normal.
The old snapshot's fade.animation: none on the old snapshot is not
tidiness. Without it the default fade-out still runs underneath, and the page
dims through the middle of the wipe.
Accessibility notes
Reduced motion is handled twice: the toggle skips the transition entirely,
and the stylesheet drops the mask, which covers a preference that changes
while a wipe is running.
The control stays a plain button with a label. The animation is on the page,
not on the control, and nothing about the control changes to make it happen.
The theme-color meta is rewritten inside the transition callback, so the
mobile browser chrome turns on the same frame as the page rather than a beat
later.
No JavaScript runs per frame and nothing in the live DOM moves. The
animation is entirely the keyframes above, played over two images.
Still open
Browsers without same-document view transitions get the plain swap, and that
is the intended fallback rather than a job for a polyfill. The frozen frame is
a cost and not a feature: for the length of the wipe nothing on the page
responds, which is why the duration is the one dial worth arguing about, and
the prototype above is where to argue it. The edge could also start from the
toggle's side of the viewport instead of the top, which would tie the direction
to the gesture the way the circle does, without becoming a spotlight.
Animated dark mode transition with modern CSS,
Jon Shamir. The other road: register the palette itself with @property and
let every colour interpolate in place, with no frozen frame and no direction.