Skip to content

Add view transitions example #196

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/_internal/demos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
211 changes: 211 additions & 0 deletions app/view-transitions/_ui/transitions.tsx
Original file line number Diff line number Diff line change
@@ -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 <ViewTransition> to set transition classes by type.
*
* @example
* <TransitionLink href="/products/1" type="transition-backwards">
* View Product
* </TransitionLink>
*/
export function TransitionLink({ type, ...props }: TransitionLinkProps) {
const router = useRouter();

const handleNavigate: TransitionLinkProps['onNavigate'] = (event) => {
event.preventDefault();

startTransition(() => {
addTransitionType(type);
router.push(props.href);
});
};

return <Link onNavigate={handleNavigate} {...props} />;
}

/**
* Button variant of TransitionLink with directional chevrons.
*
* @example
* <TransitionButtonLink type="transition-backwards" href="/products">
* Back to Products
* </TransitionButtonLink>
*/
export function TransitionButtonLink({
type,
children,
className,
...props
}: TransitionLinkProps) {
return (
<TransitionLink
type={type}
className={clsx(
'flex w-fit items-center gap-1 rounded-md bg-gray-700 px-3 py-1 text-sm font-medium text-gray-100 hover:bg-gray-500 hover:text-white',
className,
type === 'transition-backwards' && 'pl-1.5',
type === 'transition-forwards' && 'pr-1.5',
)}
{...props}
>
{type === 'transition-backwards' && (
<ChevronLeftIcon className="size-5 opacity-40" />
)}
{children}
{type === 'transition-forwards' && (
<ChevronRightIcon className="size-5 opacity-40" />
)}
</TransitionLink>
);
}

/**
* Wrapper for horizontal page transitions with type-safe props.
*
* @example
* <HorizontalTransition
* enter={{ default: 'none', 'transition-to-detail': 'animate-slide-from-right' }}
* exit={{ default: 'none', 'transition-to-detail': 'animate-slide-to-left' }}
* >
* <PageContent />
* </HorizontalTransition>
*/
export function HorizontalTransition({
children,
enter,
exit,
}: {
children: React.ReactNode;
enter: TransitionMap;
exit: TransitionMap;
}) {
return (
<ViewTransition enter={enter} exit={exit}>
{children}
</ViewTransition>
);
}

/**
* Wrapper for shared element transitions between views.
* Enables morphing of elements that persistacross transitions.
*
* @example
* <SharedTransition name="product-image" share="animate-morph">
* <ProductImage src={image} alt={name} />
* </SharedTransition>
*/
export function SharedTransition({
name,
children,
share,
}: {
name: TransitionId;
children: React.ReactNode;
share?: ViewTransitionClass;
}) {
return (
<ViewTransition name={name} share={share}>
{children}
</ViewTransition>
);
}

/**
* 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<Record<Exclude<TransitionType, 'default'>, AnimationType>>;

/**
* Type for transition class names or transition maps
* <ViewTransition> 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<React.ComponentProps<typeof Link>, 'href'> & {
type: TransitionType;
/**
* Target URL for navigation
* Overwite Next's href that can also be an object
*/
href: string;
};
42 changes: 42 additions & 0 deletions app/view-transitions/layout.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 (
<>
<Boundary label="Demo" kind="solid" animateRerendering={false}>
<Mdx source={readme} collapsed={undefined} />
</Boundary>
<Boundary
label="layout.tsx"
kind="solid"
animateRerendering={false}
className="flex flex-col gap-9"
>
{children}
</Boundary>
</>
);
}
75 changes: 75 additions & 0 deletions app/view-transitions/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<HorizontalTransition
enter={{
default: 'none',
'transition-to-list': 'animate-slide-from-left',
'transition-to-detail': 'animate-slide-from-right',
}}
exit={{
default: 'none',
'transition-to-list': 'animate-slide-to-right',
'transition-to-detail': 'animate-slide-to-left',
}}
>
<Boundary label="page.tsx" animateRerendering={true}>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-xl font-medium text-gray-300">
<SharedTransition name="navigation-title" share="animate-morph">
<h1>Shop</h1>
</SharedTransition>
<span className="font-mono tracking-tighter text-gray-600">
({products.length})
</span>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{products.map((product) => (
<TransitionLink
key={product.id}
href={`/view-transitions/posts/${product.id}`}
type="transition-to-detail"
className="group flex flex-col gap-2.5"
>
<SharedTransition
name={`product-${product.id}`}
share={{
default: 'auto',
'transition-to-list': 'animate-morph',
'transition-to-detail': 'animate-morph',
}}
>
<div className="overflow-hidden rounded-md bg-gray-900/50 p-8 group-hover:bg-gray-900">
<Image
className="brightness-150"
src={`/shop/${product.image}`}
alt={product.name}
quality={90}
width={400}
height={400}
/>
</div>
</SharedTransition>

<div className="flex flex-col gap-2">
<div className="h-2 w-4/5 rounded-full bg-gray-800" />
<div className="h-2 w-1/3 rounded-full bg-gray-800" />
</div>
</TransitionLink>
))}
</div>
</div>
</Boundary>
</HorizontalTransition>
);
}
Loading