feat: setup smart page transitions, fix laggy theme-aware element transitions

This commit is contained in:
2026-01-29 14:59:47 -06:00
parent 9e403e5043
commit 36bcc27d7f
7 changed files with 129 additions and 34 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en" class="no-transition"> <html lang="en">
<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" />
@@ -0,0 +1,47 @@
<script lang="ts">
import { navigationStore } from "$lib/stores/navigation.svelte";
import type { Snippet } from "svelte";
import { cubicOut } from "svelte/easing";
import type { TransitionConfig } from "svelte/transition";
let { key, children }: { key: string; children: Snippet } = $props();
const DURATION = 250;
const OFFSET = 40;
function inTransition(_node: HTMLElement): TransitionConfig {
const dir = navigationStore.direction;
if (dir === "fade") {
return { duration: DURATION, easing: cubicOut, css: (t: number) => `opacity: ${t}` };
}
const x = dir === "right" ? OFFSET : -OFFSET;
return {
duration: DURATION,
easing: cubicOut,
css: (t: number) => `opacity: ${t}; transform: translateX(${(1 - t) * x}px)`,
};
}
function outTransition(_node: HTMLElement): TransitionConfig {
const dir = navigationStore.direction;
// Outgoing element is positioned absolutely so incoming flows normally
const base = "position: absolute; top: 0; left: 0; width: 100%";
if (dir === "fade") {
return { duration: DURATION, easing: cubicOut, css: (t: number) => `${base}; opacity: ${t}` };
}
const x = dir === "right" ? -OFFSET : OFFSET;
return {
duration: DURATION,
easing: cubicOut,
css: (t: number) => `${base}; opacity: ${t}; transform: translateX(${(1 - t) * x}px)`,
};
}
</script>
<div class="relative overflow-hidden">
{#key key}
<div in:inTransition out:outTransition class="w-full">
{@render children()}
</div>
{/key}
</div>
+16 -12
View File
@@ -1,7 +1,19 @@
<script lang="ts"> <script lang="ts">
import { Select } from "bits-ui"; import { Select } from "bits-ui";
import { ChevronUp, ChevronDown } from "@lucide/svelte"; import { ChevronUp, ChevronDown } from "@lucide/svelte";
import { fly } from "svelte/transition"; import type { Action } from "svelte/action";
const slideIn: Action<HTMLElement, number> = (node, direction) => {
if (direction !== 0) {
node.animate(
[
{ transform: `translateX(${direction * 20}px)`, opacity: 0 },
{ transform: "translateX(0)", opacity: 1 },
],
{ duration: 200, easing: "ease-out" }
);
}
};
let { let {
totalCount, totalCount,
@@ -21,17 +33,8 @@ const start = $derived(offset + 1);
const end = $derived(Math.min(offset + limit, totalCount)); const end = $derived(Math.min(offset + limit, totalCount));
// Track direction for slide animation // Track direction for slide animation
let prevPage = $state(1);
let direction = $state(0); let direction = $state(0);
$effect(() => {
const page = currentPage;
if (page !== prevPage) {
direction = page > prevPage ? 1 : -1;
prevPage = page;
}
});
// 5 page slots: current-2, current-1, current, current+1, current+2 // 5 page slots: current-2, current-1, current, current+1, current+2
const pageSlots = $derived([-2, -1, 0, 1, 2].map((delta) => currentPage + delta)); const pageSlots = $derived([-2, -1, 0, 1, 2].map((delta) => currentPage + delta));
@@ -40,6 +43,7 @@ function isSlotVisible(page: number): boolean {
} }
function goToPage(page: number) { function goToPage(page: number) {
direction = page > currentPage ? 1 : -1;
onPageChange((page - 1) * limit); onPageChange((page - 1) * limit);
} }
@@ -86,7 +90,7 @@ const selectValue = $derived(String(currentPage));
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
aria-label="Page {currentPage} of {totalPages}, click to select page" aria-label="Page {currentPage} of {totalPages}, click to select page"
> >
<span in:fly={{ x: direction * 20, duration: 200 }}>{currentPage}</span> <span use:slideIn={direction}>{currentPage}</span>
<ChevronUp class="size-3 text-muted-foreground" /> <ChevronUp class="size-3 text-muted-foreground" />
</Select.Trigger> </Select.Trigger>
<Select.Portal> <Select.Portal>
@@ -140,7 +144,7 @@ const selectValue = $derived(String(currentPage));
aria-hidden={!isSlotVisible(page)} aria-hidden={!isSlotVisible(page)}
tabindex={isSlotVisible(page) ? 0 : -1} tabindex={isSlotVisible(page) ? 0 : -1}
disabled={!isSlotVisible(page)} disabled={!isSlotVisible(page)}
in:fly={{ x: direction * 20, duration: 200 }} use:slideIn={direction}
> >
{page} {page}
</button> </button>
+45
View File
@@ -0,0 +1,45 @@
import { beforeNavigate } from "$app/navigation";
export type NavDirection = "left" | "right" | "fade";
/** Admin sidebar order — indexes determine slide direction for same-depth siblings */
const ADMIN_NAV_ORDER = ["/admin", "/admin/scrape-jobs", "/admin/audit-log", "/admin/users"];
function getDepth(path: string): number {
return path.replace(/\/$/, "").split("/").filter(Boolean).length;
}
function getAdminIndex(path: string): number {
return ADMIN_NAV_ORDER.indexOf(path);
}
function computeDirection(from: string, to: string): NavDirection {
const fromDepth = getDepth(from);
const toDepth = getDepth(to);
if (toDepth > fromDepth) return "right";
if (toDepth < fromDepth) return "left";
// Same depth — use admin sidebar ordering if both are admin routes
const fromIdx = getAdminIndex(from);
const toIdx = getAdminIndex(to);
if (fromIdx >= 0 && toIdx >= 0) {
return toIdx > fromIdx ? "right" : "left";
}
return "fade";
}
class NavigationStore {
direction: NavDirection = $state("fade");
}
export const navigationStore = new NavigationStore();
/** Call once from root layout to start tracking navigation direction */
export function initNavigation() {
beforeNavigate(({ from, to }) => {
if (!from?.url || !to?.url) return;
navigationStore.direction = computeDirection(from.url.pathname, to.url.pathname);
});
}
+10 -7
View File
@@ -1,14 +1,19 @@
<script lang="ts"> <script lang="ts">
import "overlayscrollbars/overlayscrollbars.css"; import "overlayscrollbars/overlayscrollbars.css";
import "./layout.css"; import "./layout.css";
import { onMount } from "svelte"; import { page } from "$app/state";
import { Tooltip } from "bits-ui"; import PageTransition from "$lib/components/PageTransition.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte"; import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import { themeStore } from "$lib/stores/theme.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte"; import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import { initNavigation } from "$lib/stores/navigation.svelte";
import { themeStore } from "$lib/stores/theme.svelte";
import { Tooltip } from "bits-ui";
import { onMount } from "svelte";
let { children } = $props(); let { children } = $props();
initNavigation();
useOverlayScrollbars(() => document.body, { useOverlayScrollbars(() => document.body, {
scrollbars: { scrollbars: {
autoHide: "leave", autoHide: "leave",
@@ -18,10 +23,6 @@ useOverlayScrollbars(() => document.body, {
onMount(() => { onMount(() => {
themeStore.init(); themeStore.init();
requestAnimationFrame(() => {
document.documentElement.classList.remove("no-transition");
});
}); });
</script> </script>
@@ -30,5 +31,7 @@ onMount(() => {
<ThemeToggle /> <ThemeToggle />
</div> </div>
<PageTransition key={page.url.pathname}>
{@render children()} {@render children()}
</PageTransition>
</Tooltip.Provider> </Tooltip.Provider>
+6 -2
View File
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state";
import { authStore } from "$lib/auth.svelte"; import { authStore } from "$lib/auth.svelte";
import { LayoutDashboard, Users, ClipboardList, FileText, LogOut } from "@lucide/svelte"; import PageTransition from "$lib/components/PageTransition.svelte";
import { ClipboardList, FileText, LayoutDashboard, LogOut, Users } from "@lucide/svelte";
import { onMount } from "svelte";
let { children } = $props(); let { children } = $props();
@@ -68,7 +70,9 @@ const navItems = [
</div> </div>
</aside> </aside>
<main class="flex-1 overflow-auto p-6"> <main class="flex-1 overflow-auto p-6">
<PageTransition key={page.url.pathname}>
{@render children()} {@render children()}
</PageTransition>
</main> </main>
</div> </div>
{/if} {/if}
+1 -9
View File
@@ -57,11 +57,8 @@
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif; --font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
} }
* {
border-color: var(--border);
}
body { body {
border-color: var(--border);
background-color: var(--background); background-color: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: var(--font-sans); font-family: var(--font-sans);
@@ -129,11 +126,6 @@ input[type="checkbox"]:checked::before {
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
} }
html:not(.no-transition) body,
html:not(.no-transition) body * {
transition: background-color 300ms, color 300ms, border-color 300ms, fill 300ms;
}
/* View Transitions API - disable default cross-fade so JS can animate clip-path */ /* View Transitions API - disable default cross-fade so JS can animate clip-path */
::view-transition-old(root), ::view-transition-old(root),
::view-transition-new(root) { ::view-transition-new(root) {