From b2416ff0db9f277629950f15f183d507a10e31e5 Mon Sep 17 00:00:00 2001
From: Jake Jarvis
Date: Sat, 25 Apr 2026 10:50:31 -0400
Subject: [PATCH] refactor: overhaul view transitions with granular per-page
animation components
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Replace single `` wrapper in layout with `FadeTransition` and `DirectionalTransition` components applied per page
- Add `components/page-transition.tsx` with reusable transition wrappers
- Expand view transition CSS with named classes: fade, slide, nav-forward/back, morph, text-morph, scale — all driven by CSS custom property durations
- Use React `` for shared note title element between list and detail views
- Wrap comments suspense boundary with enter/exit slide transitions
- Add `persistent-nav` and `persistent-footer` view-transition-name groups to keep chrome static during navigation
- Fix reduced-motion override to target delay and duration instead of `animation: none`
- Add tracking-tight and letter-spacing tweaks to home page typography
---
app/(home)/page.tsx | 12 +-
app/globals.css | 144 +++-
app/layout.tsx | 5 +-
app/leo/page.tsx | 5 +-
app/not-found.tsx | 5 +-
app/notes/[slug]/page.tsx | 44 +-
app/notes/page.tsx | 24 +-
app/projects/page.tsx | 9 +-
components/layout/footer.tsx | 5 +-
components/layout/header.tsx | 299 +++++++-
components/layout/menu.tsx | 11 +-
components/layout/page-title.tsx | 2 +-
components/page-transition.tsx | 29 +
components/post-stats.tsx | 11 +-
package.json | 32 +-
pnpm-lock.yaml | 1125 +++++++++++++++---------------
16 files changed, 1091 insertions(+), 671 deletions(-)
create mode 100644 components/page-transition.tsx
diff --git a/app/(home)/page.tsx b/app/(home)/page.tsx
index b58bfe6b..37a8f68a 100644
--- a/app/(home)/page.tsx
+++ b/app/(home)/page.tsx
@@ -2,6 +2,8 @@ import { ArrowUpRight } from "lucide-react";
import Image, { type StaticImageData } from "next/image";
import Link from "next/link";
+import { FadeTransition } from "@/components/page-transition";
+
import domainstackIcon from "./icons/domainstack.png";
import snoozleIcon from "./icons/snoozle.png";
import sofaIcon from "./icons/sofa.png";
@@ -49,8 +51,8 @@ const projects: readonly Project[] = [
] as const;
const Page = () => (
- <>
-
+
+
Hi there! I’m Jake.{" "}
👋
@@ -58,7 +60,7 @@ const Page = () => (
-
+
I’m a developer based in the{" "}
(
decoding="async"
className="ring-border size-6 shrink-0 rounded-[26%] ring-1"
/>
-
+
{project.name}
@@ -105,7 +107,7 @@ const Page = () => (
))}
- >
+
);
export default Page;
diff --git a/app/globals.css b/app/globals.css
index 688965cd..e1f6add1 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -62,6 +62,9 @@
:root {
--radius: 0.625rem;
+ --duration-exit: 150ms;
+ --duration-enter: 210ms;
+ --duration-move: 400ms;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
@@ -104,6 +107,12 @@
--selection-foreground: oklch(1 0 0);
}
+@keyframes via-blur {
+ 30% {
+ filter: blur(3px);
+ }
+}
+
.dark {
--background: oklch(0 0 0);
--foreground: oklch(0.985 0 0);
@@ -202,34 +211,131 @@
cursor: pointer;
}
- /* View Transitions - uses tw-animate-css's `enter` and `exit` keyframes */
- @media (prefers-reduced-motion: reduce) {
- ::view-transition-group(*),
- ::view-transition-old(*),
- ::view-transition-new(*) {
- animation: none !important;
- }
- }
-
/* Disable the default cross-fade on root (header/footer/chrome stay static) */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
- /* Main content: fade + slide */
- main {
- view-transition-name: main-content;
- }
- ::view-transition-old(main-content) {
+ ::view-transition-old(.fade-out) {
+ --tw-exit-blur: 3px;
--tw-exit-opacity: 0;
- --tw-exit-translate-y: -8px;
- animation: 150ms ease-in forwards exit;
+ animation: var(--duration-exit) ease-in forwards exit;
}
- ::view-transition-new(main-content) {
+ ::view-transition-new(.fade-in) {
+ --tw-enter-blur: 3px;
--tw-enter-opacity: 0;
- --tw-enter-translate-y: 12px;
- animation: 200ms ease-out forwards enter;
+ animation: var(--duration-enter) ease-out var(--duration-exit) both enter;
+ }
+
+ ::view-transition-old(.slide-down) {
+ --tw-exit-opacity: 0;
+ --tw-exit-translate-y: 10px;
+ animation: var(--duration-exit) ease-out both exit;
+ }
+ ::view-transition-new(.slide-up) {
+ --tw-enter-opacity: 0;
+ --tw-enter-translate-y: 10px;
+ animation: var(--duration-enter) ease-in var(--duration-exit) both enter;
+ }
+
+ ::view-transition-new(.slide-from-right) {
+ --tw-enter-opacity: 0;
+ --tw-enter-translate-x: 60px;
+ animation: var(--duration-move) ease-in-out both enter;
+ }
+ ::view-transition-old(.slide-to-left) {
+ --tw-exit-opacity: 0;
+ --tw-exit-translate-x: -60px;
+ animation: var(--duration-exit) ease-in both exit;
+ }
+
+ ::view-transition-new(.slide-from-left) {
+ --tw-enter-opacity: 0;
+ --tw-enter-translate-x: -60px;
+ animation: var(--duration-move) ease-in-out both enter;
+ }
+ ::view-transition-old(.slide-to-right) {
+ --tw-exit-opacity: 0;
+ --tw-exit-translate-x: 60px;
+ animation: var(--duration-exit) ease-in both exit;
+ }
+
+ ::view-transition-old(.nav-forward) {
+ --tw-exit-opacity: 0;
+ --tw-exit-translate-x: -60px;
+ animation: var(--duration-exit) ease-in both exit;
+ }
+ ::view-transition-new(.nav-forward) {
+ --tw-enter-opacity: 0;
+ --tw-enter-translate-x: 60px;
+ animation: var(--duration-move) ease-in-out both enter;
+ }
+
+ ::view-transition-old(.nav-back) {
+ --tw-exit-opacity: 0;
+ --tw-exit-translate-x: 60px;
+ animation: var(--duration-exit) ease-in both exit;
+ }
+ ::view-transition-new(.nav-back) {
+ --tw-enter-opacity: 0;
+ --tw-enter-translate-x: -60px;
+ animation: var(--duration-move) ease-in-out both enter;
+ }
+
+ ::view-transition-group(.morph) {
+ animation-duration: var(--duration-move);
+ }
+
+ ::view-transition-image-pair(.morph) {
+ animation-name: via-blur;
+ }
+
+ ::view-transition-group(.text-morph) {
+ animation-duration: var(--duration-move);
+ }
+ ::view-transition-old(.text-morph) {
+ display: none;
+ }
+ ::view-transition-new(.text-morph) {
+ animation: none;
+ object-fit: none;
+ object-position: left top;
+ }
+
+ ::view-transition-old(.scale-out) {
+ --tw-exit-opacity: 0;
+ --tw-exit-scale: 85%;
+ animation: var(--duration-exit) ease-in exit;
+ }
+ ::view-transition-new(.scale-in) {
+ --tw-enter-opacity: 0;
+ --tw-enter-scale: 85%;
+ animation: var(--duration-enter) ease-out var(--duration-exit) both enter;
+ }
+
+ ::view-transition-group(persistent-nav),
+ ::view-transition-group(persistent-footer) {
+ animation: none;
+ z-index: 100;
+ }
+
+ ::view-transition-old(persistent-nav) {
+ display: none;
+ }
+ ::view-transition-new(persistent-nav),
+ ::view-transition-old(persistent-footer),
+ ::view-transition-new(persistent-footer) {
+ animation: none;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ ::view-transition-old(*),
+ ::view-transition-new(*),
+ ::view-transition-group(*) {
+ animation-delay: 0s !important;
+ animation-duration: 0s !important;
+ }
}
}
diff --git a/app/layout.tsx b/app/layout.tsx
index cc51452d..e03647ba 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,4 +1,3 @@
-import { ViewTransition } from "react";
import { JsonLd } from "react-schemaorg";
import type { Person, WebSite } from "schema-dts";
@@ -65,9 +64,7 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => (
-
- {children}
-
+ {children}
diff --git a/app/leo/page.tsx b/app/leo/page.tsx
index f4e1b906..44f0c7d3 100644
--- a/app/leo/page.tsx
+++ b/app/leo/page.tsx
@@ -2,6 +2,7 @@ import { JsonLd } from "react-schemaorg";
import type { VideoObject } from "schema-dts";
import { PageTitle } from "@/components/layout/page-title";
+import { FadeTransition } from "@/components/page-transition";
import { Video } from "@/components/video";
import { createMetadata } from "@/lib/metadata";
@@ -22,7 +23,7 @@ export const metadata = createMetadata({
});
const Page = () => (
- <>
+
item={{
"@context": "https://schema.org",
@@ -65,7 +66,7 @@ const Page = () => (
. © 2007 G4 Media, Inc.
- >
+
);
export default Page;
diff --git a/app/not-found.tsx b/app/not-found.tsx
index bbd82c0b..f6c668f9 100644
--- a/app/not-found.tsx
+++ b/app/not-found.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
+import { FadeTransition } from "@/components/page-transition";
import { Button } from "@/components/ui/button";
import { Video } from "@/components/video";
@@ -14,7 +15,7 @@ export const metadata: Metadata = {
};
const Page = () => (
- <>
+
);
export default Page;
diff --git a/app/notes/[slug]/page.tsx b/app/notes/[slug]/page.tsx
index d4d1c3f7..a6b89c37 100644
--- a/app/notes/[slug]/page.tsx
+++ b/app/notes/[slug]/page.tsx
@@ -8,13 +8,14 @@ import {
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
-import { Suspense } from "react";
+import { Suspense, ViewTransition } from "react";
import { JsonLd } from "react-schemaorg";
import type { BlogPosting } from "schema-dts";
import { CommentCount } from "@/components/comment-count";
import { Comments } from "@/components/comments/comments";
import { CommentsSkeleton } from "@/components/comments/comments-skeleton";
+import { DirectionalTransition } from "@/components/page-transition";
import { ViewCounter } from "@/components/view-counter";
import authorConfig from "@/lib/config/author";
import siteConfig from "@/lib/config/site";
@@ -83,7 +84,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
const { default: MDXContent } = await import(`../../../${POSTS_DIR}/${slug}/index.mdx`);
return (
- <>
+
item={{
"@context": "https://schema.org",
@@ -109,7 +110,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
}}
/>
-
-
-
-
+
+
+
+
+
@@ -190,13 +190,21 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
Comments are closed.
) : (
- }>
-
+
+
+
+ }
+ >
+
+
+
)}
- >
+
);
};
diff --git a/app/notes/page.tsx b/app/notes/page.tsx
index 711487c6..f675e7c9 100644
--- a/app/notes/page.tsx
+++ b/app/notes/page.tsx
@@ -1,6 +1,8 @@
import Link from "next/link";
+import { ViewTransition } from "react";
import { PageTitle } from "@/components/layout/page-title";
+import { DirectionalTransition } from "@/components/page-transition";
import { PostStats, PostStatsProvider } from "@/components/post-stats";
import authorConfig from "@/lib/config/author";
import { createMetadata } from "@/lib/metadata";
@@ -56,24 +58,26 @@ const PostsList = async () => {
Object.entries(postsByYear).forEach(([year, yearPosts]) => {
sections.push(
-
+
{year}
{yearPosts.map(({ slug, dateISO, dateTitle, dateDisplay, title, htmlTitle }) => (
- -
+
-
@@ -89,12 +93,12 @@ const PostsList = async () => {
};
const Page = async () => (
- <>
+
Notes
- >
+
);
export default Page;
diff --git a/app/projects/page.tsx b/app/projects/page.tsx
index 1407e16b..64cc8ab3 100644
--- a/app/projects/page.tsx
+++ b/app/projects/page.tsx
@@ -4,6 +4,7 @@ import { Suspense } from "react";
import { ActivityCalendar } from "@/components/activity-calendar";
import { PageTitle } from "@/components/layout/page-title";
+import { FadeTransition } from "@/components/page-transition";
import { RelativeTime } from "@/components/relative-time";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -30,10 +31,10 @@ const Page = async () => {
const [contributions, repos] = await Promise.all([getContributions(), getRepos()]);
return (
- <>
+
Projects
-
);
};
diff --git a/components/layout/footer.tsx b/components/layout/footer.tsx
index 5b4a2498..b2d96e19 100644
--- a/components/layout/footer.tsx
+++ b/components/layout/footer.tsx
@@ -3,7 +3,10 @@ import Link from "next/link";
import siteConfig from "@/lib/config/site";
const Footer = () => (
-