mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 04:26:43 -06:00
feat: add circular reveal animation to theme toggle using View Transitions API
This commit is contained in:
+30
-31
@@ -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;
|
||||
}
|
||||
|
||||
+4
-3
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
themeStore.toggle();
|
||||
telemetry.track({
|
||||
name: "theme_change",
|
||||
properties: { theme: newTheme },
|
||||
|
||||
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"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user