feat: add circular reveal animation to theme toggle using View Transitions API

This commit is contained in:
2026-01-15 13:08:44 -06:00
parent 8aa14a2cab
commit 6a09a871cc
9 changed files with 110 additions and 54 deletions
+30 -31
View File
@@ -77,9 +77,6 @@ html,
body {
@apply font-inter overflow-x-hidden;
color: var(--color-text-primary);
transition:
background-color 0.3s ease-in-out,
color 0.3s ease-in-out;
}
body {
@@ -87,19 +84,6 @@ body {
background-color: var(--color-bg-primary);
}
/* Smooth theme transitions for all elements */
*:not(canvas):not([class*="animate-"]) {
transition-property: background-color, border-color, color, fill, stroke;
transition-duration: 0.3s;
transition-timing-function: ease-in-out;
}
/* Elements with explicit transition classes should extend, not replace */
[class*="transition-colors"],
[class*="transition-all"] {
transition-duration: 0.3s !important;
}
html:not(.dark) {
.os-scrollbar {
--os-handle-bg: rgba(0, 0, 0, 0.25) !important;
@@ -120,7 +104,7 @@ html.dark {
border-radius: 4px;
}
/* Native scrollbars (Webkit: Chrome, Safari, Edge) */
/* Native scrollbars for other elements (Webkit: Chrome, Safari, Edge) */
html:not(.dark) ::-webkit-scrollbar {
width: 10px;
height: 10px;
@@ -167,25 +151,46 @@ html.dark ::-webkit-scrollbar-thumb:active {
background: rgba(255, 255, 255, 0.55);
}
/* Native scrollbars (Firefox) */
html:not(.dark) {
scrollbar-color: rgba(0, 0, 0, 0.25) rgba(0, 0, 0, 0.05);
/* Native scrollbars for other elements (Firefox) - applied to a wrapper class */
.native-scrollbar {
scrollbar-width: thin;
}
html.dark {
html:not(.dark) .native-scrollbar {
scrollbar-color: rgba(0, 0, 0, 0.25) rgba(0, 0, 0, 0.05);
}
html.dark .native-scrollbar {
scrollbar-color: rgba(255, 255, 255, 0.35) rgba(255, 255, 255, 0.05);
scrollbar-width: thin;
}
/* Hide native scrollbar on html/body - OverlayScrollbars handles body scrolling */
/* Must come AFTER general scrollbar styles to win via cascade */
html,
body {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
/* Utility class for page main wrapper */
.page-main {
@apply relative min-h-screen text-zinc-900 dark:text-zinc-50 transition-colors duration-300;
@apply relative min-h-screen text-zinc-900 dark:text-zinc-50;
}
/* View Transitions API - page transition animations */
/* View Transitions API - theme toggle animation */
/* Disable default cross-fade so JS can animate clip-path instead */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
/* Persistent elements (background with dots, theme toggle) - excluded from transition */
/* Persistent elements (background with dots, theme toggle) - excluded from page transitions */
/* Hide old snapshots entirely so only the live element shows (prevents doubling/ghosting) */
::view-transition-old(background),
::view-transition-old(theme-toggle) {
@@ -220,12 +225,6 @@ html.dark {
}
}
/* Only animate page content, not root (persistent UI stays static) */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
::view-transition-old(page-content) {
animation: vt-slide-to-left 250ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
+3 -2
View File
@@ -9,9 +9,10 @@
body {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
&::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
</style>
<script>
+1 -3
View File
@@ -879,9 +879,7 @@
<!-- Wrapper for background + ASCII clouds canvas -->
<div class="pointer-events-none fixed inset-0 -z-20" {style}>
<!-- Background overlay (also serves as fallback when WebGL fails) -->
<div
class="absolute inset-0 bg-white dark:bg-black transition-colors duration-300"
></div>
<div class="absolute inset-0 bg-white dark:bg-black"></div>
<!-- ASCII Clouds canvas (hidden if WebGL failed) -->
{#if !webglFailed}
+1 -3
View File
@@ -409,9 +409,7 @@
<!-- Wrapper for background + dots canvas - single persistent unit -->
<div class="pointer-events-none fixed inset-0 -z-20" {style}>
<!-- Background overlay -->
<div
class="absolute inset-0 bg-white dark:bg-black transition-colors duration-300"
></div>
<div class="absolute inset-0 bg-white dark:bg-black"></div>
<!-- Dots canvas -->
<canvas
+66 -2
View File
@@ -1,22 +1,86 @@
<script lang="ts">
import { tick } from "svelte";
import { themeStore } from "$lib/stores/theme.svelte";
import { telemetry } from "$lib/telemetry";
import IconSun from "~icons/lucide/sun";
import IconMoon from "~icons/lucide/moon";
function handleToggle() {
/**
* Theme toggle with View Transitions API circular reveal animation.
* The clip-path circle expands from the click point to cover the viewport.
*/
async function handleToggle(event: MouseEvent) {
const newTheme = themeStore.isDark ? "light" : "dark";
const supportsViewTransition =
typeof document !== "undefined" &&
"startViewTransition" in document &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!supportsViewTransition) {
themeStore.toggle();
telemetry.track({
name: "theme_change",
properties: { theme: newTheme },
});
return;
}
// Calculate animation origin from click coordinates
const x = event.clientX;
const y = event.clientY;
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y),
);
// Remove view-transition-names so all elements are captured in root snapshot
// (named elements would otherwise appear through "holes" in the circular reveal)
const elementsWithVTN = document.querySelectorAll(
'[style*="view-transition-name"]',
);
const savedStyles: Array<{ el: Element; style: string }> = [];
elementsWithVTN.forEach((el) => {
savedStyles.push({ el, style: el.getAttribute("style") || "" });
(el as HTMLElement).style.viewTransitionName = "none";
});
void document.documentElement.offsetHeight;
const transition = document.startViewTransition(async () => {
themeStore.toggle();
await tick();
});
transition.ready.then(() => {
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
],
},
{
duration: 500,
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
pseudoElement: "::view-transition-new(root)",
},
);
});
await transition.finished;
// Restore original view-transition-name styles
savedStyles.forEach(({ el, style }) => {
el.setAttribute("style", style);
});
telemetry.track({ name: "theme_change", properties: { theme: newTheme } });
}
</script>
<button
type="button"
onclick={handleToggle}
onclick={(e) => handleToggle(e)}
aria-label={themeStore.isDark
? "Switch to light mode"
: "Switch to dark mode"}
+1 -1
View File
@@ -153,6 +153,6 @@
{/if}
<!-- Page content wrapper - this is what transitions between pages -->
<div class="pb-12" style="view-transition-name: page-content">
<div style="view-transition-name: page-content">
{@render children()}
</div>
+1 -1
View File
@@ -26,7 +26,7 @@
}
</script>
<main class="page-main overflow-x-hidden font-schibsted">
<main class="page-main overflow-x-hidden font-schibsted pb-12">
<div class="flex items-center flex-col pt-14">
<div
class="max-w-2xl mx-4 border-b border-zinc-200 dark:border-zinc-700 divide-y divide-zinc-200 dark:divide-zinc-700 sm:mx-6"
+1 -3
View File
@@ -56,9 +56,7 @@
{@render children()}
{:else}
<!-- Admin layout with sidebar -->
<div
class="pointer-events-none fixed inset-0 -z-20 bg-admin-bg transition-colors duration-300"
></div>
<div class="pointer-events-none fixed inset-0 -z-20 bg-admin-bg"></div>
<Sidebar
projectCount={stats?.totalProjects ?? 0}
tagCount={stats?.totalTags ?? 0}
+1 -3
View File
@@ -38,9 +38,7 @@
<title>Admin Login | xevion.dev</title>
</svelte:head>
<div
class="pointer-events-none fixed inset-0 -z-20 bg-admin-bg transition-colors duration-300"
></div>
<div class="pointer-events-none fixed inset-0 -z-20 bg-admin-bg"></div>
<main class="page-main text-admin-text">
<div class="flex min-h-screen items-center justify-center px-4">
<div class="w-full max-w-md space-y-4">