feat: add View Transitions API with persistent backgrounds

- Add fade in/out page transitions using View Transitions API
- Move background/dots to root layout for persistence across routes
- Hide native scrollbar immediately to prevent FOUC
- Set body background in theme script to prevent flash
- Increase inline style threshold for better initial render
This commit is contained in:
2026-01-12 13:27:14 -06:00
parent 97bef535a3
commit 99f9b5e303
5 changed files with 91 additions and 11 deletions
+27
View File
@@ -204,3 +204,30 @@ body {
.os-scrollbar-handle { .os-scrollbar-handle {
border-radius: 4px; border-radius: 4px;
} }
/* View Transitions API - page transition animations */
@keyframes page-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes page-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
::view-transition-old(root) {
animation: page-fade-out 120ms ease-out;
}
::view-transition-new(root) {
animation: page-fade-in 150ms ease-in 50ms;
}
+13
View File
@@ -3,6 +3,17 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
/* Hide native scrollbar immediately to prevent layout shift */
html,
body {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
&::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
}
</style>
<script> <script>
(function () { (function () {
const stored = localStorage.getItem("theme"); const stored = localStorage.getItem("theme");
@@ -13,6 +24,8 @@
if (isDark) { if (isDark) {
document.documentElement.classList.add("dark"); document.documentElement.classList.add("dark");
} }
// Set body background immediately to prevent flash
document.body.style.backgroundColor = isDark ? "#000000" : "#ffffff";
})(); })();
</script> </script>
%sveltekit.head% %sveltekit.head%
+10 -7
View File
@@ -1,31 +1,34 @@
<script lang="ts"> <script lang="ts">
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import Dots from "./Dots.svelte";
import ThemeToggle from "./ThemeToggle.svelte"; import ThemeToggle from "./ThemeToggle.svelte";
let { let {
class: className = "", class: className = "",
backgroundClass = "",
bgColor = "", bgColor = "",
showThemeToggle = true, showThemeToggle = true,
children, children,
}: { }: {
class?: string; class?: string;
backgroundClass?: string;
bgColor?: string; bgColor?: string;
showThemeToggle?: boolean; showThemeToggle?: boolean;
children?: Snippet; children?: Snippet;
} = $props(); } = $props();
</script> </script>
<div <!--
Background: Public pages get their background from root +layout.svelte for persistence.
Admin/internal pages can use bgColor prop to set their own background.
-->
{#if bgColor}
<div
class={cn( class={cn(
"pointer-events-none fixed inset-0 -z-20 bg-white dark:bg-black transition-colors duration-300", "pointer-events-none fixed inset-0 -z-20 transition-colors duration-300",
bgColor, bgColor,
)} )}
></div> ></div>
<Dots class={[backgroundClass]} /> {/if}
<main <main
class={cn( class={cn(
"relative min-h-screen text-zinc-900 dark:text-zinc-50 transition-colors duration-300", "relative min-h-screen text-zinc-900 dark:text-zinc-50 transition-colors duration-300",
+37
View File
@@ -7,6 +7,9 @@
import { OverlayScrollbars } from "overlayscrollbars"; import { OverlayScrollbars } from "overlayscrollbars";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { themeStore } from "$lib/stores/theme.svelte"; import { themeStore } from "$lib/stores/theme.svelte";
import { page } from "$app/stores";
import { onNavigate } from "$app/navigation";
import Dots from "$lib/components/Dots.svelte";
let { children, data } = $props(); let { children, data } = $props();
@@ -20,6 +23,31 @@
const metadata = $derived(data?.metadata ?? defaultMetadata); const metadata = $derived(data?.metadata ?? defaultMetadata);
// Check if current route is admin (admin has its own layout/background)
const isAdminRoute = $derived($page.url.pathname.startsWith("/admin"));
// Check if current route is internal (OG preview, etc.)
const isInternalRoute = $derived($page.url.pathname.startsWith("/internal"));
// Show global background for public pages only
const showGlobalBackground = $derived(!isAdminRoute && !isInternalRoute);
// Use View Transitions API for smooth page transitions (Chrome 111+, Safari 18+)
onNavigate((navigation) => {
// Skip transitions for same-page navigations or if API not supported
if (
!document.startViewTransition ||
navigation.from?.url.pathname === navigation.to?.url.pathname
) {
return;
}
return new Promise((resolve) => {
document.startViewTransition(async () => {
resolve();
await navigation.complete;
});
});
});
onMount(() => { onMount(() => {
// Initialize theme store // Initialize theme store
themeStore.init(); themeStore.init();
@@ -63,4 +91,13 @@
<meta name="twitter:image" content={metadata.ogImage} /> <meta name="twitter:image" content={metadata.ogImage} />
</svelte:head> </svelte:head>
<!-- Persistent background layer - only for public routes -->
{#if showGlobalBackground}
<div
class="pointer-events-none fixed inset-0 -z-20 bg-white dark:bg-black transition-colors duration-300"
></div>
<Dots />
{/if}
<!-- Page content - transitions handled by View Transitions API -->
{@render children()} {@render children()}
+1 -1
View File
@@ -4,7 +4,7 @@ import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
inlineStyleThreshold: 1000, inlineStyleThreshold: 2000,
kit: { kit: {
adapter: adapter({ adapter: adapter({
out: "build", out: "build",