diff --git a/app/_internal/demos.ts b/app/_internal/demos.ts index 727ff9d9..cb40ff03 100644 --- a/app/_internal/demos.ts +++ b/app/_internal/demos.ts @@ -113,6 +113,12 @@ export const navigation = [ // name: 'Client Component Hooks', // description: 'Preview the routing hooks available in Client Components', // }, + { + slug: 'view-transitions', + name: 'View Transitions', + description: + 'Use animations to help users understand the relationship between the two views', + }, { slug: 'context', name: 'Client Context', diff --git a/app/view-transitions/_ui/transitions.tsx b/app/view-transitions/_ui/transitions.tsx new file mode 100644 index 00000000..67b7e777 --- /dev/null +++ b/app/view-transitions/_ui/transitions.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid'; +import { + unstable_addTransitionType as addTransitionType, + startTransition, + unstable_ViewTransition as ViewTransition, +} from 'react'; +import clsx from 'clsx'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +/** + * Extended Link component that adds a type to a navigation transition. + * Allows to set transition classes by type. + * + * @example + * + * View Product + * + */ +export function TransitionLink({ type, ...props }: TransitionLinkProps) { + const router = useRouter(); + + const handleNavigate: TransitionLinkProps['onNavigate'] = (event) => { + event.preventDefault(); + + startTransition(() => { + addTransitionType(type); + router.push(props.href); + }); + }; + + return ; +} + +/** + * Button variant of TransitionLink with directional chevrons. + * + * @example + * + * Back to Products + * + */ +export function TransitionButtonLink({ + type, + children, + className, + ...props +}: TransitionLinkProps) { + return ( + + {type === 'transition-backwards' && ( + + )} + {children} + {type === 'transition-forwards' && ( + + )} + + ); +} + +/** + * Wrapper for horizontal page transitions with type-safe props. + * + * @example + * + * + * + */ +export function HorizontalTransition({ + children, + enter, + exit, +}: { + children: React.ReactNode; + enter: TransitionMap; + exit: TransitionMap; +}) { + return ( + + {children} + + ); +} + +/** + * Wrapper for shared element transitions between views. + * Enables morphing of elements that persistacross transitions. + * + * @example + * + * + * + */ +export function SharedTransition({ + name, + children, + share, +}: { + name: TransitionId; + children: React.ReactNode; + share?: ViewTransitionClass; +}) { + return ( + + {children} + + ); +} + +/** + * Type-safe transition options to prevent ID clashes and props + * in TransitionLinks and ViewTransitions from drifting apart. + */ + +/** + * Available transition IDs for shared elements + * @internal + */ +const transitionIds = [ + 'navigation-icon', + 'navigation-title', + 'navigation-pagination', +] as const; + +/** + * Available transition types for navigation + * @internal + */ +const transitionTypes = [ + 'default', + 'transition-to-detail', + 'transition-to-list', + 'transition-backwards', + 'transition-forwards', +] as const; + +/** + * Available animation types for transitions + * @internal + */ +const animationTypes = [ + 'auto', + 'none', + 'animate-slide-from-left', + 'animate-slide-from-right', + 'animate-slide-to-left', + 'animate-slide-to-right', + 'animate-morph', +] as const; + +/** + * Type for transition types with their corresponding animations + */ +type TransitionType = (typeof transitionTypes)[number]; + +/** + * Type for available animation classes + */ +type AnimationType = (typeof animationTypes)[number]; + +/** + * Mapping of transition types to their animation classes + * @example + * const transitions: TransitionMap = { + * default: 'none', + * 'transition-to-detail': 'animate-slide-from-right', + * 'transition-to-list': 'animate-slide-from-left' + * } + */ +type TransitionMap = { + default: AnimationType; +} & Partial, AnimationType>>; + +/** + * Type for transition class names or transition maps + * props accept both. + */ +type ViewTransitionClass = AnimationType | TransitionMap; + +/** + * Type for transition element IDs + */ +type TransitionId = (typeof transitionIds)[number] | `product-${string}`; + +/** + * Props for TransitionLink component + * Extends Next.js Link props with transition type + */ +type TransitionLinkProps = Omit, 'href'> & { + type: TransitionType; + /** + * Target URL for navigation + * Overwite Next's href that can also be an object + */ + href: string; +}; diff --git a/app/view-transitions/layout.tsx b/app/view-transitions/layout.tsx new file mode 100644 index 00000000..d15fcac4 --- /dev/null +++ b/app/view-transitions/layout.tsx @@ -0,0 +1,42 @@ +'use cache'; + +import { getDemoMeta } from '#/app/_internal/demos'; +import { Boundary } from '#/ui/boundary'; +import { Mdx } from '#/ui/codehike'; +import { type Metadata } from 'next'; +import React from 'react'; +import readme from './readme.mdx'; + +export async function generateMetadata(): Promise { + const demo = getDemoMeta('view-transitions'); + + return { + title: demo.name, + openGraph: { + title: demo.name, + images: [`/api/og?title=${demo.name}`], + }, + }; +} + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> + + + + + {children} + + + ); +} diff --git a/app/view-transitions/page.tsx b/app/view-transitions/page.tsx new file mode 100644 index 00000000..23ee834d --- /dev/null +++ b/app/view-transitions/page.tsx @@ -0,0 +1,75 @@ +import { db } from '#/app/_internal/data'; +import { + TransitionLink, + HorizontalTransition, + SharedTransition, +} from '#/app/view-transitions/_ui/transitions'; +import { Boundary } from '#/ui/boundary'; +import Image from 'next/image'; + +export default async function Page() { + const products = db.product.findMany(); + + return ( + + +
+
+ +

Shop

+
+ + ({products.length}) + +
+
+ {products.map((product) => ( + + +
+ {product.name} +
+
+ +
+
+
+
+ + ))} +
+
+ + + ); +} diff --git a/app/view-transitions/posts/[id]/page.tsx b/app/view-transitions/posts/[id]/page.tsx new file mode 100644 index 00000000..555c9990 --- /dev/null +++ b/app/view-transitions/posts/[id]/page.tsx @@ -0,0 +1,139 @@ +import { db } from '#/app/_internal/data'; +import { getDemoMeta } from '#/app/_internal/demos'; +import { + HorizontalTransition, + SharedTransition, + TransitionButtonLink, + TransitionLink, +} from '#/app/view-transitions/_ui/transitions'; +import { Boundary } from '#/ui/boundary'; +import { SkeletonText } from '#/ui/new/skeleton'; +import { ChevronLeftIcon } from '@heroicons/react/24/solid'; +import Image from 'next/image'; +import { notFound } from 'next/navigation'; + +export default async function Page({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const { data, prev, next } = db.product.find({ + where: { id }, + }); + if (!data) { + notFound(); + } + + const demo = getDemoMeta('view-transitions'); + const prevProduct = `/${demo.slug}/posts/${prev}`; + const nextProduct = `/${demo.slug}/posts/${next}`; + + return ( + + +
+ + + + + + +
Shop
+
+
+ +
+ + + + + +
+ + +
+ + Previous + + + Next + +
+
+
+
+
+ ); +} + +function ProductDetails({ id: seed }: { id: string }) { + return ( +
+ + + +
+ ); +} + +function ProductImage({ src, alt }: { src: string; alt: string }) { + return ( +
+ {alt} +
+ ); +} diff --git a/app/view-transitions/readme.mdx b/app/view-transitions/readme.mdx new file mode 100644 index 00000000..6c004f3b --- /dev/null +++ b/app/view-transitions/readme.mdx @@ -0,0 +1,5 @@ +import { getDemoMeta } from '#/app/_internal/demos'; + +export const demo = getDemoMeta('view-transitions'); + +# {demo.name} diff --git a/package.json b/package.json index 060e3c78..eb90c9ec 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,14 @@ "@heroicons/react": "2.2.0", "@mdx-js/loader": "3.1.0", "@mdx-js/react": "3.1.0", - "@next/mdx": "15.4.0-canary.55", + "@next/mdx": "15.4.0-canary.70", "@types/mdx": "2.0.13", "clsx": "2.1.1", "codehike": "1.0.7", "date-fns": "4.1.0", "dinero.js": "2.0.0-alpha.10", "ms": "3.0.0-canary.1", - "next": "15.4.0-canary.55", + "next": "15.4.0-canary.70", "react": "19.1.0", "react-dom": "19.1.0", "recma-codehike": "0.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f85dec6..b18b4271 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: 3.1.0 version: 3.1.0(@types/react@19.1.2)(react@19.1.0) '@next/mdx': - specifier: 15.4.0-canary.55 - version: 15.4.0-canary.55(@mdx-js/loader@3.1.0(acorn@8.14.1))(@mdx-js/react@3.1.0(@types/react@19.1.2)(react@19.1.0)) + specifier: 15.4.0-canary.70 + version: 15.4.0-canary.70(@mdx-js/loader@3.1.0(acorn@8.14.1))(@mdx-js/react@3.1.0(@types/react@19.1.2)(react@19.1.0)) '@types/mdx': specifier: 2.0.13 version: 2.0.13 @@ -39,8 +39,8 @@ importers: specifier: 3.0.0-canary.1 version: 3.0.0-canary.1 next: - specifier: 15.4.0-canary.55 - version: 15.4.0-canary.55(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: 15.4.0-canary.70 + version: 15.4.0-canary.70(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: 19.1.0 version: 19.1.0 @@ -265,11 +265,11 @@ packages: '@types/react': '>=16' react: '>=16' - '@next/env@15.4.0-canary.55': - resolution: {integrity: sha512-2lef+h3bAalhYbSWB/uqDI3mJVFyKS7xv83tmuRC0/cq8XB8OeVe0SQiPSFCkL7s0cjda3gM/kK5MLvVrbCprg==} + '@next/env@15.4.0-canary.70': + resolution: {integrity: sha512-hYJLeeXqPD3HLIYjsWkbrWqZJkk0mttXiQDpr3qdCjA6ZSazdAhciRo7RPdRW5Qd6K1Hh0jJoJoKLAGDmVv7nA==} - '@next/mdx@15.4.0-canary.55': - resolution: {integrity: sha512-hdTnvwTFbAJBmS6zBPe1cu17dWL/6PlULfiUcfBo2RLhUBKNPZP00T2PwvzmPpmuXSfl/IBdkEEjIU2WUiqoXA==} + '@next/mdx@15.4.0-canary.70': + resolution: {integrity: sha512-WvmDCccGtIQ1QllIgeZZsfRTQMIeDjMFnUUnoaJ0USCDNUlEwrtuCt0Uj8jueJleisl1vpVpSytH5GW6II/WFA==} peerDependencies: '@mdx-js/loader': '>=0.15.0' '@mdx-js/react': '>=0.15.0' @@ -279,50 +279,50 @@ packages: '@mdx-js/react': optional: true - '@next/swc-darwin-arm64@15.4.0-canary.55': - resolution: {integrity: sha512-sYlacW4wjLtWaItnFHM+YmrnrQmJIff5J1D67UaTcMV2vnGZ6dNmsSYu9+jkvmTtCnzf8TzXG+wOXFGmVWJDhw==} + '@next/swc-darwin-arm64@15.4.0-canary.70': + resolution: {integrity: sha512-xIZIS5wmy1T7Ixhuw+ytD68rE0eYPuA5mEmBXZiGuZpNvR5S11S8SLJTgWeSqZlgUrR4Kc9DF8ZaQAW4ZN/YCA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.4.0-canary.55': - resolution: {integrity: sha512-+sbOPdfZOK8mfOtxGFuN5QO2Ua9DU7D3x6lfOhjGgzhUlLhv72a7iOcZZfK/LtYCsmTRto+C1mV9k8dgUp+4gw==} + '@next/swc-darwin-x64@15.4.0-canary.70': + resolution: {integrity: sha512-rBwT3tz0n5bkopQ9u5M0T3wzWCgRdHVuRabcCgHe96bEUpgEDd1pt2PEAkw6hi8B7kIjOQBAkggXC2tjyosUbA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.4.0-canary.55': - resolution: {integrity: sha512-QmOP7Ebq9bQ1fIQ6x2cNFYeSLg3u1zQqiDSQzYA7+SsO1n9HWfryTN3zeQS4H5/maTHHzuIeS+Vfwh3pU8IbOA==} + '@next/swc-linux-arm64-gnu@15.4.0-canary.70': + resolution: {integrity: sha512-YaFo7cGlYp2BuQBfNiXK1TtPGcr0L65247uBkADCPxMk2dQrz59CCU/xPZ2zgdvgF5tR1MWmvcPmi47b00vjAg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.4.0-canary.55': - resolution: {integrity: sha512-MRlxLpMeIJdsqAiy4Ch95XGXs4xFrisKaVBamiF4AOdDnyl/ii7g4H9w81iXpnVwxTYXyBq2P7LOu1lfu3Hxeg==} + '@next/swc-linux-arm64-musl@15.4.0-canary.70': + resolution: {integrity: sha512-EZV6HmzFPbItxbZ2blfrI2Q7pAc0voaFgbhArpAicLezQtsuQZiUGZ99bPrQ4V+e1kYqxdZBrXs7IVISDAeAxA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.4.0-canary.55': - resolution: {integrity: sha512-/+U3j/HbC8MzsKh7pqWDXX9Nk7V7afPUXrAQPex113x7AmMEsMN3DO9HwzWjG8rlaxCCKj3RP3OXP62GshMb6g==} + '@next/swc-linux-x64-gnu@15.4.0-canary.70': + resolution: {integrity: sha512-ZlY3EPt/mOhrPjqjPmm6o3RxyvuE+1Fd6HRvCTLVGBuX97v9grVvRvoahdz3EuMzWQfO4zxa4Esr0CJWCrEuAw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.4.0-canary.55': - resolution: {integrity: sha512-30r7S6I+l5vuW9gM6eOafE5vFXRGXhlmfyTwngVoToC1zgCfn/yxUDSKWcQQCXgO3JSOt7DqlUqoRUb9aKa4WA==} + '@next/swc-linux-x64-musl@15.4.0-canary.70': + resolution: {integrity: sha512-/IBf3ySAin+7GqnAo6mpaqpru8mpNNKx5pi17bDIjrwpwaBOOwJ5W3wh45Vid/osfE7bmky8a/5IzQ7Blx0GpA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.4.0-canary.55': - resolution: {integrity: sha512-TOxlef/xiRRIOT75L4jZRZJVdcYxU63h7skH/+iO9SjCuoYZe36i9BAMzzF5h2Jw2E7eJFBqNnMXJio0VcbyXw==} + '@next/swc-win32-arm64-msvc@15.4.0-canary.70': + resolution: {integrity: sha512-kDydLMTiqANfG4mw5Zjx3yD5byQ2ibMlOmsm1Ha4IJEALQoizdjII9PmO8syBBEdZZgBYDESBzZr2zKFq1Ky4Q==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.4.0-canary.55': - resolution: {integrity: sha512-4QvnziyAGUNbzi7WkoGHVVbJshDpbSPZLkDIoKhxteoQ2KBz7Fh5N2gcPIm+J5MzvQl9gKZwBpOAaOll8vr6jQ==} + '@next/swc-win32-x64-msvc@15.4.0-canary.70': + resolution: {integrity: sha512-kHAgKLoNf/8mJ1YlzkV+DYxuLUizusWBbdu6vV73/0BrhdE6fcwmxiR6xIYaGefPZVsoS4wVBB1JHD3yaOG/rw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -870,8 +870,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - next@15.4.0-canary.55: - resolution: {integrity: sha512-7Vw2LRnZ1508gKkdqCHD+e7SErm6Rih4aXt1tMsi+46gJ5gyefgk5OhqMOAy1r03XEyXRA4FvThyIpHMJs0B9Q==} + next@15.4.0-canary.70: + resolution: {integrity: sha512-N8eAj+XJ/Gx1t16AIOyrXuGB9MYm09mbpkutUDWrJCL5Q9FmmYmlNiZIa0pr8zxSWxPQerOFh8GC5a6vlIPO4A==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -1312,37 +1312,37 @@ snapshots: '@types/react': 19.1.2 react: 19.1.0 - '@next/env@15.4.0-canary.55': {} + '@next/env@15.4.0-canary.70': {} - '@next/mdx@15.4.0-canary.55(@mdx-js/loader@3.1.0(acorn@8.14.1))(@mdx-js/react@3.1.0(@types/react@19.1.2)(react@19.1.0))': + '@next/mdx@15.4.0-canary.70(@mdx-js/loader@3.1.0(acorn@8.14.1))(@mdx-js/react@3.1.0(@types/react@19.1.2)(react@19.1.0))': dependencies: source-map: 0.7.4 optionalDependencies: '@mdx-js/loader': 3.1.0(acorn@8.14.1) '@mdx-js/react': 3.1.0(@types/react@19.1.2)(react@19.1.0) - '@next/swc-darwin-arm64@15.4.0-canary.55': + '@next/swc-darwin-arm64@15.4.0-canary.70': optional: true - '@next/swc-darwin-x64@15.4.0-canary.55': + '@next/swc-darwin-x64@15.4.0-canary.70': optional: true - '@next/swc-linux-arm64-gnu@15.4.0-canary.55': + '@next/swc-linux-arm64-gnu@15.4.0-canary.70': optional: true - '@next/swc-linux-arm64-musl@15.4.0-canary.55': + '@next/swc-linux-arm64-musl@15.4.0-canary.70': optional: true - '@next/swc-linux-x64-gnu@15.4.0-canary.55': + '@next/swc-linux-x64-gnu@15.4.0-canary.70': optional: true - '@next/swc-linux-x64-musl@15.4.0-canary.55': + '@next/swc-linux-x64-musl@15.4.0-canary.70': optional: true - '@next/swc-win32-arm64-msvc@15.4.0-canary.55': + '@next/swc-win32-arm64-msvc@15.4.0-canary.70': optional: true - '@next/swc-win32-x64-msvc@15.4.0-canary.55': + '@next/swc-win32-x64-msvc@15.4.0-canary.70': optional: true '@swc/helpers@0.5.15': @@ -2061,9 +2061,9 @@ snapshots: nanoid@3.3.11: {} - next@15.4.0-canary.55(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@15.4.0-canary.70(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - '@next/env': 15.4.0-canary.55 + '@next/env': 15.4.0-canary.70 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001713 postcss: 8.4.31 @@ -2071,14 +2071,14 @@ snapshots: react-dom: 19.1.0(react@19.1.0) styled-jsx: 5.1.6(react@19.1.0) optionalDependencies: - '@next/swc-darwin-arm64': 15.4.0-canary.55 - '@next/swc-darwin-x64': 15.4.0-canary.55 - '@next/swc-linux-arm64-gnu': 15.4.0-canary.55 - '@next/swc-linux-arm64-musl': 15.4.0-canary.55 - '@next/swc-linux-x64-gnu': 15.4.0-canary.55 - '@next/swc-linux-x64-musl': 15.4.0-canary.55 - '@next/swc-win32-arm64-msvc': 15.4.0-canary.55 - '@next/swc-win32-x64-msvc': 15.4.0-canary.55 + '@next/swc-darwin-arm64': 15.4.0-canary.70 + '@next/swc-darwin-x64': 15.4.0-canary.70 + '@next/swc-linux-arm64-gnu': 15.4.0-canary.70 + '@next/swc-linux-arm64-musl': 15.4.0-canary.70 + '@next/swc-linux-x64-gnu': 15.4.0-canary.70 + '@next/swc-linux-x64-musl': 15.4.0-canary.70 + '@next/swc-win32-arm64-msvc': 15.4.0-canary.70 + '@next/swc-win32-x64-msvc': 15.4.0-canary.70 sharp: 0.34.1 transitivePeerDependencies: - '@babel/core' diff --git a/styles/globals.css b/styles/globals.css index a0312e5d..bcf8151c 100755 --- a/styles/globals.css +++ b/styles/globals.css @@ -30,6 +30,10 @@ --animate-shimmer: shimmer 1.5s infinite; + --duration-enter: 210ms; + --duration-move: 400ms; + --duration-exit: 150ms; + @keyframes shimmer { 0% { opacity: 0; @@ -128,3 +132,63 @@ opacity: 1; } } + +@keyframes slide { + from { + translate: var(--slide-offset); + } + to { + translate: 0; + } +} + +::view-transition-new(.animate-slide-from-left) { + --slide-offset: -60px; + animation: + var(--duration-enter) ease-out var(--duration-exit) both fade, + var(--duration-move) ease-in-out both slide; +} +::view-transition-old(.animate-slide-to-right) { + --slide-offset: 60px; + animation: + var(--duration-exit) ease-in both fade reverse, + var(--duration-move) ease-in-out both slide reverse; +} + +::view-transition-new(.animate-slide-from-right) { + --slide-offset: 60px; + animation: + var(--duration-enter) ease-out var(--duration-exit) both fade, + var(--duration-move) ease-in-out both slide; +} + +::view-transition-old(.animate-slide-to-left) { + --slide-offset: -60px; + animation: + var(--duration-exit) ease-in both fade reverse, + var(--duration-move) ease-in-out both slide reverse; +} + +::view-transition-group(.animate-morph) { + animation-duration: var(--duration-move); +} + +/* + * Using view-transition-image-pair and not view-transition-group to preserve + * the default animation which includes automatic position and scale morphing + */ +::view-transition-image-pair(.animate-morph) { + animation-name: via-blur; +} + +/* + * Blur animation for view transitions with shared elements. + * Fast-moving elements can be visually jarring as the eye tries + * to track them. This creates a poor man's motion blur we can + * use to make these transitions smoother. + */ +@keyframes via-blur { + 30% { + filter: blur(3px); + } +}