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 {
|
body {
|
||||||
@apply font-inter overflow-x-hidden;
|
@apply font-inter overflow-x-hidden;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
transition:
|
|
||||||
background-color 0.3s ease-in-out,
|
|
||||||
color 0.3s ease-in-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -87,19 +84,6 @@ body {
|
|||||||
background-color: var(--color-bg-primary);
|
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) {
|
html:not(.dark) {
|
||||||
.os-scrollbar {
|
.os-scrollbar {
|
||||||
--os-handle-bg: rgba(0, 0, 0, 0.25) !important;
|
--os-handle-bg: rgba(0, 0, 0, 0.25) !important;
|
||||||
@@ -120,7 +104,7 @@ html.dark {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Native scrollbars (Webkit: Chrome, Safari, Edge) */
|
/* Native scrollbars for other elements (Webkit: Chrome, Safari, Edge) */
|
||||||
html:not(.dark) ::-webkit-scrollbar {
|
html:not(.dark) ::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
@@ -167,25 +151,46 @@ html.dark ::-webkit-scrollbar-thumb:active {
|
|||||||
background: rgba(255, 255, 255, 0.55);
|
background: rgba(255, 255, 255, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Native scrollbars (Firefox) */
|
/* Native scrollbars for other elements (Firefox) - applied to a wrapper class */
|
||||||
html:not(.dark) {
|
.native-scrollbar {
|
||||||
scrollbar-color: rgba(0, 0, 0, 0.25) rgba(0, 0, 0, 0.05);
|
|
||||||
scrollbar-width: thin;
|
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-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 */
|
/* Utility class for page main wrapper */
|
||||||
.page-main {
|
.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) */
|
/* Hide old snapshots entirely so only the live element shows (prevents doubling/ghosting) */
|
||||||
::view-transition-old(background),
|
::view-transition-old(background),
|
||||||
::view-transition-old(theme-toggle) {
|
::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) {
|
::view-transition-old(page-content) {
|
||||||
animation: vt-slide-to-left 250ms cubic-bezier(0.4, 0, 0.2, 1) both;
|
animation: vt-slide-to-left 250ms cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -9,9 +9,10 @@
|
|||||||
body {
|
body {
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
-ms-overflow-style: none; /* IE/Edge */
|
-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>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -879,9 +879,7 @@
|
|||||||
<!-- Wrapper for background + ASCII clouds canvas -->
|
<!-- Wrapper for background + ASCII clouds canvas -->
|
||||||
<div class="pointer-events-none fixed inset-0 -z-20" {style}>
|
<div class="pointer-events-none fixed inset-0 -z-20" {style}>
|
||||||
<!-- Background overlay (also serves as fallback when WebGL fails) -->
|
<!-- Background overlay (also serves as fallback when WebGL fails) -->
|
||||||
<div
|
<div class="absolute inset-0 bg-white dark:bg-black"></div>
|
||||||
class="absolute inset-0 bg-white dark:bg-black transition-colors duration-300"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- ASCII Clouds canvas (hidden if WebGL failed) -->
|
<!-- ASCII Clouds canvas (hidden if WebGL failed) -->
|
||||||
{#if !webglFailed}
|
{#if !webglFailed}
|
||||||
|
|||||||
@@ -409,9 +409,7 @@
|
|||||||
<!-- Wrapper for background + dots canvas - single persistent unit -->
|
<!-- Wrapper for background + dots canvas - single persistent unit -->
|
||||||
<div class="pointer-events-none fixed inset-0 -z-20" {style}>
|
<div class="pointer-events-none fixed inset-0 -z-20" {style}>
|
||||||
<!-- Background overlay -->
|
<!-- Background overlay -->
|
||||||
<div
|
<div class="absolute inset-0 bg-white dark:bg-black"></div>
|
||||||
class="absolute inset-0 bg-white dark:bg-black transition-colors duration-300"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Dots canvas -->
|
<!-- Dots canvas -->
|
||||||
<canvas
|
<canvas
|
||||||
|
|||||||
@@ -1,22 +1,86 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { tick } from "svelte";
|
||||||
import { themeStore } from "$lib/stores/theme.svelte";
|
import { themeStore } from "$lib/stores/theme.svelte";
|
||||||
import { telemetry } from "$lib/telemetry";
|
import { telemetry } from "$lib/telemetry";
|
||||||
import IconSun from "~icons/lucide/sun";
|
import IconSun from "~icons/lucide/sun";
|
||||||
import IconMoon from "~icons/lucide/moon";
|
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 newTheme = themeStore.isDark ? "light" : "dark";
|
||||||
themeStore.toggle();
|
|
||||||
telemetry.track({
|
const supportsViewTransition =
|
||||||
name: "theme_change",
|
typeof document !== "undefined" &&
|
||||||
properties: { theme: newTheme },
|
"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>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={handleToggle}
|
onclick={(e) => handleToggle(e)}
|
||||||
aria-label={themeStore.isDark
|
aria-label={themeStore.isDark
|
||||||
? "Switch to light mode"
|
? "Switch to light mode"
|
||||||
: "Switch to dark mode"}
|
: "Switch to dark mode"}
|
||||||
|
|||||||
@@ -153,6 +153,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Page content wrapper - this is what transitions between pages -->
|
<!-- 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()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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="flex items-center flex-col pt-14">
|
||||||
<div
|
<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"
|
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()}
|
{@render children()}
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Admin layout with sidebar -->
|
<!-- Admin layout with sidebar -->
|
||||||
<div
|
<div class="pointer-events-none fixed inset-0 -z-20 bg-admin-bg"></div>
|
||||||
class="pointer-events-none fixed inset-0 -z-20 bg-admin-bg transition-colors duration-300"
|
|
||||||
></div>
|
|
||||||
<Sidebar
|
<Sidebar
|
||||||
projectCount={stats?.totalProjects ?? 0}
|
projectCount={stats?.totalProjects ?? 0}
|
||||||
tagCount={stats?.totalTags ?? 0}
|
tagCount={stats?.totalTags ?? 0}
|
||||||
|
|||||||
@@ -38,9 +38,7 @@
|
|||||||
<title>Admin Login | xevion.dev</title>
|
<title>Admin Login | xevion.dev</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div
|
<div class="pointer-events-none fixed inset-0 -z-20 bg-admin-bg"></div>
|
||||||
class="pointer-events-none fixed inset-0 -z-20 bg-admin-bg transition-colors duration-300"
|
|
||||||
></div>
|
|
||||||
<main class="page-main text-admin-text">
|
<main class="page-main text-admin-text">
|
||||||
<div class="flex min-h-screen items-center justify-center px-4">
|
<div class="flex min-h-screen items-center justify-center px-4">
|
||||||
<div class="w-full max-w-md space-y-4">
|
<div class="w-full max-w-md space-y-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user