# Soft-edged theme wipe

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.

> 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?

Status: Prototype · Built with: CSS, View Transitions API, TypeScript · Topics: Theme switching, View transitions, Motion · Year: 2026

Source: https://www.ahmedamr.com/lab/theme-wipe

*A live prototype runs at this point on the page.*

## 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](https://akashhamirwasia.com/blog/full-page-theme-toggle-animation-with-view-transitions-api/)
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](https://expensive.toys/blog/fancy-css-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 wipe** · `theme-wipe.css`

```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 toggle** · `theme-toggle.ts`

```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.

## Related reading

- [Fancy reveal animations with CSS masks and @property](https://expensive.toys/blog/fancy-css-reveal-effects),
  Artur Bień. The mask half of this note.
- [Full-page theme toggle animation with View Transitions API](https://akashhamirwasia.com/blog/full-page-theme-toggle-animation-with-view-transitions-api/),
  Akash Hamirwasia. The circular reveal, and the view-transition half.
- [Animated dark mode transition with modern CSS](https://jonshamir.com/writing/color-mode/),
  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.
