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
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
- Name the scene — give the themable container a
view-transition-nameso it is captured as its own snapshot. - Swap inside a transition — call
document.startViewTransitionand flip the theme (adata-modeattribute driving CSS custom properties). - Reveal with a circle — on
transition.ready, animateclip-pathon::view-transition-new(scene)fromcircle(0)at the click point out to the farthest corner. Coordinates are relative to the scene box, not the viewport. - Ripple the water — apply an SVG
feTurbulence+feDisplacementMapfilter to the new snapshot and animate the displacementscaledown to zero so the surface warps then settles. - Disable the default fade — set
animation: noneon the scene's (and root's) view-transition pseudo-elements so only the clip reveal shows. - Fallback — swap instantly when View Transitions are unsupported or reduced motion is requested.
Source code
<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.startViewTransitionto capture the before/after snapshots.- A WAAPI
clip-pathanimation on::view-transition-new(scene). - An SVG
feTurbulence+feDisplacementMapfilter 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(...), notscene.animate(...). - Default cross-fade fighting the reveal. Set
animation: noneon::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.
- 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.
- 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.
| 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. |