1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2026-06-05 19:15:30 -04:00

refactor: overhaul view transitions with granular per-page animation components

- Replace single `<ViewTransition>` 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 `<ViewTransition name=... share="text-morph">` 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
This commit is contained in:
2026-04-25 10:50:31 -04:00
parent ad90539df4
commit b2416ff0db
16 changed files with 1091 additions and 671 deletions
+7 -5
View File
@@ -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 = () => (
<>
<h1 className="text-lg font-medium">
<FadeTransition>
<h1 className="text-lg font-medium tracking-tight">
Hi there! I&rsquo;m Jake.{" "}
<span className="motion-safe:animate-wave ml-0.5 inline-block origin-[65%_80%] text-[1.2rem]">
👋
@@ -58,7 +60,7 @@ const Page = () => (
</h1>
<div className="markdown">
<p className="text-sm leading-normal">
<p className="text-sm leading-normal tracking-[-0.0125em]">
I&rsquo;m a developer based in the{" "}
<Link
href="https://www.youtube-nocookie.com/embed/rLwbzGyC6t4?hl=en&amp;fs=1&amp;showinfo=1&amp;rel=0&amp;iv_load_policy=3"
@@ -89,7 +91,7 @@ const Page = () => (
decoding="async"
className="ring-border size-6 shrink-0 rounded-[26%] ring-1"
/>
<span className="text-primary text-sm font-medium group-hover:underline group-hover:underline-offset-4">
<span className="text-primary text-sm font-medium tracking-[-0.0125em] group-hover:underline group-hover:underline-offset-4">
{project.name}
</span>
</div>
@@ -105,7 +107,7 @@ const Page = () => (
))}
</ul>
</section>
</>
</FadeTransition>
);
export default Page;
+125 -19
View File
@@ -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;
}
}
}
+1 -4
View File
@@ -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 }>) => (
<Providers>
<div className="mx-auto w-full max-w-[720px] px-5">
<Header />
<main className="mt-4 w-full">
<ViewTransition>{children}</ViewTransition>
</main>
<main className="mt-4 w-full">{children}</main>
<Footer />
</div>
<Toaster position="bottom-center" hotkey={[]} />
+3 -2
View File
@@ -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 = () => (
<>
<FadeTransition>
<JsonLd<VideoObject>
item={{
"@context": "https://schema.org",
@@ -65,7 +66,7 @@ const Page = () => (
</a>
. &copy; 2007 G4 Media, Inc.
</p>
</>
</FadeTransition>
);
export default Page;
+3 -2
View File
@@ -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 = () => (
<>
<FadeTransition>
<Video
src="https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/not-found-SAtLyNyc7gVhveYxr6o1ITd9CSXo5X.mp4"
autoPlay
@@ -33,7 +34,7 @@ const Page = () => (
Go home?
</Button>
</div>
</>
</FadeTransition>
);
export default Page;
+26 -18
View File
@@ -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 (
<>
<DirectionalTransition>
<JsonLd<BlogPosting>
item={{
"@context": "https://schema.org",
@@ -109,7 +110,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
}}
/>
<div className="text-foreground/70 flex flex-wrap justify-items-start gap-4 text-[13px] tracking-wide">
<div className="text-foreground/70 flex flex-wrap justify-items-start space-y-2.5 space-x-4 text-[13px] tracking-wide">
<Link
href={`/${POSTS_DIR}/${frontmatter?.slug}`}
className={
@@ -166,18 +167,17 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
</div>
</div>
<h1
className="my-5 text-3xl font-medium tracking-tight"
style={{ viewTransitionName: `note-title-${frontmatter?.slug}` }}
>
<Link
href={`/${POSTS_DIR}/${frontmatter?.slug}`}
dangerouslySetInnerHTML={{
__html: frontmatter.htmlTitle || frontmatter.title,
}}
className="text-foreground hover:no-underline"
/>
</h1>
<ViewTransition name={`note-title-${frontmatter.slug}`} share="text-morph" default="none">
<h1 className="my-5 text-2xl font-medium tracking-tight">
<Link
href={`/${POSTS_DIR}/${frontmatter.slug}`}
dangerouslySetInnerHTML={{
__html: frontmatter.htmlTitle || frontmatter.title,
}}
className="text-foreground hover:no-underline"
/>
</h1>
</ViewTransition>
<article className="markdown">
<MDXContent />
@@ -190,13 +190,21 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
<p className="text-center text-lg font-medium">Comments are closed.</p>
</div>
) : (
<Suspense fallback={<CommentsSkeleton />}>
<Comments slug={`${POSTS_DIR}/${frontmatter?.slug}`} />
<Suspense
fallback={
<ViewTransition exit="slide-down">
<CommentsSkeleton />
</ViewTransition>
}
>
<ViewTransition enter="slide-up" default="none">
<Comments slug={`${POSTS_DIR}/${frontmatter.slug}`} />
</ViewTransition>
</Suspense>
)}
</div>
</section>
</>
</DirectionalTransition>
);
};
+14 -10
View File
@@ -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(
<section className="my-8 first-of-type:mt-0 last-of-type:mb-0" key={year}>
<h2 id={year} className="mt-0 mb-4 text-2xl font-semibold tracking-tight">
<h2 id={year} className="mt-0 mb-4 text-xl font-semibold tracking-tight">
{year}
</h2>
<ul className="space-y-4">
{yearPosts.map(({ slug, dateISO, dateTitle, dateDisplay, title, htmlTitle }) => (
<li className="flex text-base leading-relaxed" key={slug}>
<li className="flex text-sm leading-relaxed" key={slug}>
<span className="text-muted-foreground w-18 shrink-0 md:w-22">
<time dateTime={dateISO} title={dateTitle} suppressHydrationWarning>
{dateDisplay}
</time>
</span>
<div className="space-x-2">
<Link
href={`/${POSTS_DIR}/${slug}`}
dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
className="mr-2.5 underline-offset-4 hover:underline"
style={{ viewTransitionName: `note-title-${slug}` }}
/>
<ViewTransition name={`note-title-${slug}`} share="text-morph" default="none">
<Link
href={`/${POSTS_DIR}/${slug}`}
transitionTypes={["nav-forward"]}
dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
className="mr-2.5 underline-offset-4 hover:underline"
/>
</ViewTransition>
<PostStats slug={`${POSTS_DIR}/${slug}`} />
</div>
@@ -89,12 +93,12 @@ const PostsList = async () => {
};
const Page = async () => (
<>
<DirectionalTransition>
<PageTitle canonical="/notes">Notes</PageTitle>
<PostStatsProvider>
<PostsList />
</PostStatsProvider>
</>
</DirectionalTransition>
);
export default Page;
+5 -4
View File
@@ -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 (
<>
<FadeTransition>
<PageTitle canonical="/projects">Projects</PageTitle>
<h2 className="my-3.5 text-xl font-medium">
<h2 className="my-3.5 text-lg font-medium">
<a
href={`https://github.com/${process.env.NEXT_PUBLIC_GITHUB_USERNAME}`}
target="_blank"
@@ -56,7 +57,7 @@ const Page = async () => {
)}
</Suspense>
<h2 className="my-3.5 text-xl font-medium">
<h2 className="my-3.5 text-lg font-medium">
<a
href={`https://github.com/${process.env.NEXT_PUBLIC_GITHUB_USERNAME}?tab=repositories&sort=stargazers`}
target="_blank"
@@ -171,7 +172,7 @@ const Page = async () => {
<ExternalLinkIcon className="inline-block size-3.5 shrink-0" aria-hidden="true" />
</Button>
</p>
</>
</FadeTransition>
);
};