Home Recipes Liquid Theme Switch View Transitions

Liquid Theme Switch

Reveal a new theme with a clip-path circle spreading from the click, then ripple the surface with an SVG turbulence filter like a drop hitting water.

Interactive demo

Get started

What it does

Switches a theme by revealing the new colors through an expanding clip-path circle that starts at the click point, then warps the freshly revealed surface with an SVG turbulence filter so it ripples and settles like a drop landing in water.

When to use it

  • Light/dark or palette toggles where the change should feel delightful.
  • Hero sections or showcase cards that reward interaction.
  • Onboarding or settings where a themed preview sells the choice.
  • Any deliberate, user-initiated switch that can afford ~0.8s of motion.

When to avoid it

  • High-frequency toggles where the animation would get in the way.
  • Performance-critical views on low-end devices (the filter is GPU work).
  • When the change must be instant or is triggered programmatically in bulk.
  • When reduced motion is on — fall back to an instant swap.

How it works

  1. Name the scene — give the themable container a view-transition-name so it is captured as its own snapshot.
  2. Swap inside a transition — call document.startViewTransition and flip the theme (a data-mode attribute driving CSS custom properties).
  3. Reveal with a circle — on transition.ready, animate clip-path on ::view-transition-new(scene) from circle(0) at the click point out to the farthest corner. Coordinates are relative to the scene box, not the viewport.
  4. Ripple the water — apply an SVG feTurbulence + feDisplacementMap filter to the new snapshot and animate the displacement scale down to zero so the surface warps then settles.
  5. Disable the default fade — set animation: none on the scene's (and root's) view-transition pseudo-elements so only the clip reveal shows.
  6. Fallback — swap instantly when View Transitions are unsupported or reduced motion is requested.

Source code

HTMLCSSTypeScript
<div class="scene" data-scene data-mode="day">
  <h2 class="scene-title">Liquid theme</h2>
  <p class="scene-text">Tap the drop and watch the new theme ripple across the surface.</p>
  <div class="scene-swatches">
    <span class="swatch swatch--a"></span>
    <span class="swatch swatch--b"></span>
    <span class="swatch swatch--c"></span>
  </div>
</div>

<button type="button" class="drop-toggle" data-toggle aria-pressed="false">Drop</button>

<!-- Water surface: turbulence displaces the revealed snapshot, then settles. -->
<svg width="0" height="0" aria-hidden="true">
  <filter id="liquid" x="-15%" y="-15%" width="130%" height="130%" color-interpolation-filters="sRGB">
    <feTurbulence type="fractalNoise" baseFrequency="0.02 0.03" numOctaves="2" seed="4" result="noise">
      <animate data-turbulence attributeName="baseFrequency" dur="760ms"
        values="0.05 0.07;0.02 0.03;0.012 0.02" keyTimes="0;0.5;1" fill="freeze" begin="indefinite" />
    </feTurbulence>
    <feDisplacementMap in="SourceGraphic" in2="noise" scale="0" xChannelSelector="R" yChannelSelector="G">
      <animate data-displace attributeName="scale" dur="760ms" values="34;0" keyTimes="0;1"
        calcMode="spline" keySplines="0.22 1 0.36 1" fill="freeze" begin="indefinite" />
    </feDisplacementMap>
  </filter>
</svg>
:root {
  color-scheme: dark;
  font-family: system-ui, sans-serif;
}

body {
  margin: 0;
  min-height: 100dvh;
  display: grid;
  place-items: center;
  gap: 1.5rem;
  background: #0a0a0b;
}

/* The scene is lifted into its own view-transition snapshot. */
.scene {
  view-transition-name: scene;

  /* Day palette (default). */
  --bg: #f2ece1;
  --fg: #1c1917;
  --accent: #0ea5e9;
  --accent-2: #f59e0b;

  width: min(100% - 2rem, 320px);
  padding: 1.75rem;
  border-radius: 1.5rem;
  background: var(--bg);
  color: var(--fg);
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
  overflow: hidden;
  isolation: isolate;
}

.scene[data-mode="night"] {
  --bg: #0b1220;
  --fg: #eaf1ff;
  --accent: #38bdf8;
  --accent-2: #a78bfa;
}

.scene-title {
  margin: 0 0 0.5rem;
  font-size: 1.25rem;
}

.scene-text {
  margin: 0 0 1.25rem;
  opacity: 0.7;
  line-height: 1.5;
}

.scene-swatches {
  display: flex;
  gap: 0.5rem;
}

.swatch {
  flex: 1;
  height: 2.25rem;
  border-radius: 0.5rem;
}

.swatch--a { background: var(--accent); }
.swatch--b { background: var(--accent-2); }
.swatch--c { background: color-mix(in oklab, var(--accent) 55%, var(--bg)); }

.drop-toggle {
  padding: 0.5rem 1.5rem;
  font: inherit;
  font-weight: 600;
  color: #f4f4f5;
  background: #16161a;
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 9999px;
  cursor: pointer;
}

/* Reveal the new theme with the clip-path circle only (no default cross-fade). */
::view-transition-old(scene),
::view-transition-new(scene) {
  animation: none;
  mix-blend-mode: normal;
}

::view-transition-old(scene) { z-index: 0; }

::view-transition-new(scene) {
  z-index: 1;
  filter: url(#liquid); /* water wobble */
}

/* Keep the unchanged rest of the page from cross-fading. */
::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(scene),
  ::view-transition-old(scene),
  ::view-transition-new(scene) {
    animation: none !important;
  }
}
const REVEAL_MS = 760;
const REVEAL_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";

function prefersReducedMotion(): boolean {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

const scene = document.querySelector<HTMLElement>("[data-scene]")!;
const toggle = document.querySelector<HTMLElement>("[data-toggle]")!;
const turbulence = document.querySelector<SVGAnimateElement>("[data-turbulence]");
const displace = document.querySelector<SVGAnimateElement>("[data-displace]");

function swap(): void {
  const next = scene.dataset.mode === "night" ? "day" : "night";
  scene.dataset.mode = next;
  toggle.setAttribute("aria-pressed", String(next === "night"));
}

const hasViewTransition = "startViewTransition" in document;

toggle.addEventListener("click", (event) => {
  // Fallback: swap instantly with no animation.
  if (!hasViewTransition || prefersReducedMotion()) {
    swap();
    return;
  }

  // Click position relative to the scene (falls back to its center on keyboard).
  const rect = scene.getBoundingClientRect();
  const pointer = event as PointerEvent;
  const x = pointer.clientX || pointer.clientY ? pointer.clientX - rect.left : rect.width / 2;
  const y = pointer.clientX || pointer.clientY ? pointer.clientY - rect.top : rect.height / 2;

  const start = (
    document as Document & {
      startViewTransition: (cb: () => void) => { ready: Promise<void> };
    }
  ).startViewTransition;

  const transition = start(() => swap());

  transition.ready.then(() => {
    const radius = Math.hypot(
      Math.max(x, scene.clientWidth - x),
      Math.max(y, scene.clientHeight - y),
    );

    // The drop: expand a clip-path circle from the click point.
    document.documentElement.animate(
      {
        clipPath: [
          `circle(0px at ${x}px ${y}px)`,
          `circle(${radius}px at ${x}px ${y}px)`,
        ],
      },
      {
        duration: REVEAL_MS,
        easing: REVEAL_EASE,
        pseudoElement: "::view-transition-new(scene)",
      },
    );

    // The ripples: kick the SVG turbulence + displacement so the surface settles.
    try {
      turbulence?.beginElement();
      displace?.beginElement();
    } catch {
      /* SMIL unsupported — the clip-path reveal still plays. */
    }
  });
});

export {};

Implementation recipe

Liquid Theme Switch (Water Drop) Implementation Recipe

Goal

Reveal a new theme with an expanding clip-path circle that originates at the click point — a drop falling — then ripple the freshly revealed surface with an SVG turbulence filter so it settles like disturbed water. Built entirely on the View Transitions API with a graceful instant-swap fallback.

Requirements

  • A "scene" element with a view-transition-name.
  • A toggle that swaps theme state (CSS custom properties or a data- attribute).
  • document.startViewTransition to capture the before/after snapshots.
  • A WAAPI clip-path animation on ::view-transition-new(scene).
  • An SVG feTurbulence + feDisplacementMap filter for the water wobble.
  • Fallback for browsers without View Transitions, and reduced-motion support.

Step 1: Build the scene

Give the themable container a view-transition-name so it is captured as its own snapshot, independent of the rest of the page:

.scene {
  view-transition-name: scene;
}

Drive its colors from custom properties that a data-mode attribute overrides, so the swap is a single attribute change.

Step 2: Swap state inside a view transition

const transition = document.startViewTransition(() => swap());

swap() just flips scene.dataset.mode. The API snapshots the old colors, runs the callback, then snapshots the new colors.

Step 3: Reveal with a clip-path circle (the drop)

Disable the default cross-fade and animate the new snapshot's clip-path from a zero-radius circle at the click point out to the farthest corner:

::view-transition-old(scene),
::view-transition-new(scene) { animation: none; }
::view-transition-new(scene) { z-index: 1; }
const radius = Math.hypot(
  Math.max(x, w - x),
  Math.max(y, h - y),
);
document.documentElement.animate(
  { clipPath: [
      `circle(0px at ${x}px ${y}px)`,
      `circle(${radius}px at ${x}px ${y}px)`,
  ] },
  { duration: 760, easing: "cubic-bezier(0.22, 1, 0.36, 1)",
    pseudoElement: "::view-transition-new(scene)" },
);

Coordinates are relative to the scene box, because the pseudo-element is sized and positioned to the named element — not the viewport.

Step 4: Ripple the surface (the water)

Apply an SVG turbulence + displacement filter to the new snapshot:

::view-transition-new(scene) { filter: url(#liquid); }

Animate the displacement scale from a strong value down to 0, and drift the turbulence baseFrequency, so the incoming surface warps then settles:

<feDisplacementMap in="SourceGraphic" in2="noise" scale="0" ...>
  <animate data-displace attributeName="scale" dur="760ms"
    values="34;0" calcMode="spline" keySplines="0.22 1 0.36 1"
    fill="freeze" begin="indefinite" />
</feDisplacementMap>

Trigger the SMIL animations the moment the transition is ready:

transition.ready.then(() => {
  displace?.beginElement();
  turbulence?.beginElement();
});

Step 5: Provide a fallback

If startViewTransition is missing or the user prefers reduced motion, call swap() directly — the theme still changes, just without animation.

Common pitfalls

  • Wrong coordinate space. For a named transition the clip-path origin is relative to the element, not the viewport — subtract the scene's rect.
  • Animating on the wrong element. View-transition pseudo-elements belong to the document root, so call document.documentElement.animate(...), not scene.animate(...).
  • Default cross-fade fighting the reveal. Set animation: none on ::view-transition-old/new(scene) or the built-in fade muddies the clip.
  • Root snapshot flashing. Also silence ::view-transition-*(root) so the unchanged page doesn't fade.
  • Astro-scoped pseudo rules. View-transition pseudo-elements can't be scoped; put them in a global stylesheet.
  • Starting a transition while one is running can throw — guard rapid clicks.

Verification checklist

  • The new theme reveals as a circle growing from the click point.
  • The surface visibly ripples and settles (turbulence displacement).
  • Clicking with the keyboard reveals from the scene's center.
  • Unsupported browsers swap the theme instantly.
  • Reduced motion disables the reveal and the ripple.
  • Rapid repeated clicks never leave the scene half-clipped.
Accessibility notes
  • Drive the toggle from a real <button> with aria-pressed reflecting state.
  • Keyboard activation reveals from the scene's center, so no pointer is required.
  • Respect prefers-reduced-motion by swapping the theme with no animation.
  • Ensure both palettes meet contrast requirements — the motion is decorative.
  • Keep focus on the toggle after activation; the transition never moves focus.
Performance notes
  • The whole scene snapshot is displaced by the SVG filter each frame — keep the scene modestly sized and the effect short (~760ms).
  • Animate only clip-path (compositable) via WAAPI; let SMIL drive the filter.
  • Scope the transition with a single view-transition-name to limit capture cost.
  • Guard against starting a new transition while one is still running.
  • The filter is a one-shot on a deliberate action, not a continuous animation.
Browser support & fallbacks
API / Feature Support Fallback strategy
document.startViewTransition Chrome 111+, Edge 111+, Safari 18+ Instant theme swap with no reveal.
clip-path circle() on view-transition pseudo Same as startViewTransition New theme still applies without the circular reveal.
SVG feTurbulence / feDisplacementMap All modern browsers (SMIL beginElement in Chrome, Safari) Clip-path reveal plays without the water wobble.
prefers-reduced-motion All modern browsers Disable the reveal and the ripple entirely.