A theme-switch ripple with the View Transitions API
A circle growing from the click point reveals the new theme. The radius must reach the farthest corner, and the custom properties must be cleaned up or they leak into the next switch.
The dark-mode switch is the only whole-page colour change on the site, and cutting to it abruptly reads as a flash. The fix is the View Transitions API: stack the old and new snapshots, then reveal the new theme with a circular clip-path grown from the click point.
The radius has to reach the farthest corner
The centre is the click point and the radius is its greatest distance to any viewport corner:
const dx = Math.max(x, innerWidth - x);
const dy = Math.max(y, innerHeight - y);
const radius = Math.hypot(dx, dy);
Undershoot and a wedge of the old theme survives in a corner. It is tempting to use the screen diagonal instead, but unless the click is dead centre that overflows: the animation finishes before reaching the corner and leaves the same wedge.
Geometry goes into custom properties
JS only writes the centre and radius to the root element; the stylesheet only reads them:
document.documentElement.style.setProperty('--ripple-x', x + 'px');
document.documentElement.style.setProperty('--ripple-r', radius + 'px');
document.documentElement.dataset.ripple = '1';
Cleanup on the way out
When the animation ends, remove data-ripple and the --ripple-* values. Leave them and the next switch starts from the previous centre and radius, which reads as a flicker. That class of bug only appears on the second click, which is exactly why it ships.
Degrading
Without View Transitions, or with prefers-reduced-motion set, the theme switches with no animation. The theme is the feature; the ripple is decoration, and the two should not be coupled.

Comments
…