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:
+7
-5
@@ -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’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’m a developer based in the{" "}
|
||||
<Link
|
||||
href="https://www.youtube-nocookie.com/embed/rLwbzGyC6t4?hl=en&fs=1&showinfo=1&rel=0&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
@@ -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
@@ -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
@@ -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>
|
||||
. © 2007 G4 Media, Inc.
|
||||
</p>
|
||||
</>
|
||||
</FadeTransition>
|
||||
);
|
||||
|
||||
export default Page;
|
||||
|
||||
+3
-2
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user