├── .eslintrc.cjs
├── .github
└── workflows
│ ├── pages.yaml
│ └── publish.yaml
├── .gitignore
├── .npmignore
├── LICENSE
├── README.md
├── examples
├── index.html
├── react
│ ├── App.css
│ ├── delayed.jsx
│ ├── immediate.jsx
│ ├── mood-picker.jsx
│ └── react-simple.jsx
└── vite.config.js
├── package-lock.json
├── package.json
├── src
├── next-vt.tsx
├── react-vt.ts
└── types.d.ts
└── tsconfig.json
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | env: {
4 | browser: true,
5 | es2020: true,
6 | },
7 | extends: [
8 | 'eslint:recommended',
9 | 'plugin:@typescript-eslint/recommended',
10 | 'plugin:react/recommended',
11 | 'plugin:react/jsx-runtime',
12 | 'plugin:react-hooks/recommended',
13 | ],
14 | parserOptions: {
15 | ecmaVersion: 'latest',
16 | sourceType: 'module',
17 | },
18 | settings: {
19 | react: { version: '18.2' },
20 | },
21 | plugins: [
22 | 'react-refresh',
23 | '@typescript-eslint',
24 | ],
25 | rules: {
26 | 'react-refresh/only-export-components': 0,
27 | '@typescript-eslint/no-unused-vars': 0,
28 | 'no-async-promise-executor': 0,
29 | },
30 | };
31 |
--------------------------------------------------------------------------------
/.github/workflows/pages.yaml:
--------------------------------------------------------------------------------
1 | name: Deploy examples to GitHub Pages
2 |
3 | on:
4 | push:
5 | branches: ['main']
6 |
7 | workflow_dispatch:
8 |
9 | permissions:
10 | contents: read
11 | pages: write
12 | id-token: write
13 |
14 | concurrency:
15 | group: 'pages'
16 | cancel-in-progress: true
17 |
18 | jobs:
19 | deploy:
20 | environment:
21 | name: github-pages
22 | url: ${{ steps.deployment.outputs.page_url }}
23 | runs-on: ubuntu-latest
24 | steps:
25 | - name: Checkout
26 | uses: actions/checkout@v3
27 | - name: Setup Node
28 | uses: actions/setup-node@v3
29 | with:
30 | node-version: '18.x'
31 | - name: Install dependencies
32 | run: npm ci
33 | - name: Build
34 | run: |
35 | npm run build
36 | npm run build:examples
37 | - name: Setup Pages
38 | uses: actions/configure-pages@v3
39 | - name: Upload artifact
40 | uses: actions/upload-pages-artifact@v1
41 | with:
42 | path: 'examples/dist'
43 | - name: Deploy to GitHub Pages
44 | id: deployment
45 | uses: actions/deploy-pages@v1
46 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yaml:
--------------------------------------------------------------------------------
1 | name: Publish to NPM
2 | on:
3 | release:
4 | types: [created]
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - name: Checkout
10 | uses: actions/checkout@v3
11 | - name: Setup Node
12 | uses: actions/setup-node@v3
13 | with:
14 | node-version: '18.x'
15 | registry-url: 'https://registry.npmjs.org'
16 | - name: Install dependencies and build 🔧
17 | run: npm ci && npm run build
18 | - name: Publish package on NPM 📦
19 | run: npm publish
20 | env:
21 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 | out/
26 | docs/
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist-ssr
12 | *.local
13 |
14 | # Editor directories and files
15 | .vscode/*
16 | !.vscode/extensions.json
17 | .idea
18 | .DS_Store
19 | *.suo
20 | *.ntvs*
21 | *.njsproj
22 | *.sln
23 | *.sw?
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Noam Rosenthal
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Using CSS same-document view-transitions with React
2 |
3 | ## Installation
4 |
5 | ```sh
6 | npm install use-view-transitions
7 | ```
8 |
9 | ## Overview
10 |
11 | [CSS view transitions](https://drafts.csswg.org/css-view-transitions-1/) are a new feature that allows for expressive
12 | animations between states.
13 |
14 | The main syntax for view-transitions looks something like this:
15 |
16 | ```js
17 | document.startViewTransition(() => updateDOMAndReturnPromise())
18 | ```
19 |
20 | There are several ways to use this API in a [React](https://react.dev/) or [NextJS](https://nextjs.org/) single-page app.
21 |
22 | ## React
23 |
24 | ### Using [`flushSync`](https://react.dev/reference/react-dom/flushSync)
25 |
26 | This is the most basic way, and it doesn't require too much magic.
27 |
28 | ```js
29 | document.startViewTransition(() =>
30 | ReactDOM.flushSync(() => {
31 | // set the "after" state here, synchronously.
32 | }),
33 | )
34 | ```
35 |
36 | This approach should work for the common cases, but some apps don't work well
37 | with synchronous updates. For example, some components might rely on some fast
38 | asynchronous work to display correctly. When using [`flushSync`](https://react.dev/reference/react-dom/flushSync),
39 | any asynchronous work would be rendered after the transition is complete.
40 |
41 | ### The `useViewTransition` hook
42 |
43 | This hook would work for asynchronous operations, and would work without `flushSync` out of the box.
44 | By default, it works like [React.startTransition](https://react.dev/reference/react/startTransition),
45 | but would execute the CSS view-transition without a `flushSync`:
46 |
47 | ```jsx
48 | import { useViewTransition } from 'use-view-transitions/react'
49 |
50 | const { startViewTransition } = useViewTransition()
51 | const [value, increment] = useReducer((x) => x + 1, 0)
52 | return (
53 | <>
54 |
57 | {value}
58 | >
59 | )
60 | ```
61 |
62 | Using `useViewTrantision` together with the `` component, you can suspend
63 | capturing the new state until ready.
64 |
65 | ```jsx
66 | import {
67 | useViewTransition,
68 | SuspendViewTransition,
69 | } from 'use-view-transitions/react'
70 | const { startViewTransition } = useViewTransition()
71 | const [isLoading, setLoading] = useState(false)
72 |
73 | // Don't use this code for real, it's simulation for something that loads asynchronously.
74 | useEffect(() => {
75 | if (isLoading) {
76 | setTimeout(() => {
77 | setLoading(false)
78 | }, 100)
79 | }
80 | }, [isLoading])
81 |
82 | return (
83 | <>
84 |
93 | {
94 | // This would suspend capturing the "new" state of the transition until loaded.
95 | isLoading ? : null
96 | }
97 | >
98 | )
99 | ```
100 |
101 | Note that like in the `flushSync` case, unrelated changes wrapped in `React.startTransition` would only
102 | execute as part of capturing the new state.
103 |
104 | ## NextJS
105 |
106 | Since NextJS is based on React, using the above technique would work in most cases.
107 | However, some state changes and event handlers happen within NextJS itself, e.g. navigating
108 | to routes based on link clicks.
109 |
110 | ### Before AppRouter (pages directory)
111 |
112 | For that, we offer the `useNextRouterViewTransitions()` hook:
113 |
114 | ```jsx
115 | // _app.js
116 |
117 | import { useNextRouterViewTransitions } from 'use-view-transitions/next'
118 |
119 | useNextRouterViewTransitions()
120 |
121 | return (
122 |
123 |
124 |
125 | )
126 | ```
127 |
128 | The hook listens to NextJS's [router events](https://nextjs.org/docs/pages/api-reference/functions/use-router#routerevents)
129 | and uses the React hook internally to make sure that the old state is captured before the navigation is executed.
130 |
131 | ### With AppRouter
132 |
133 | With AppRouter, we don't have router events, so there's no good signal of when a navigation starts.
134 | We can still use `usePathname` and `useSearchParams` to understand when a navigation is completed, but we have to do something
135 | to make sure the old state is capture before we start the navigation.
136 |
137 | For that, we could either implement our own `` element, wrap the existing one, or capture clicks - which is what
138 | `AutoViewTransitionsOnClick` does. (Any of these ways is legit and has trade-offs).
139 |
140 | Example:
141 | ```jsx
142 | "use client";
143 | import {AutoViewTransitionsOnClick, EnableNextAppRouterViewTransitions} from "use-view-transitions/next";
144 |
145 | export default function RootLayout({
146 | children,
147 | }) {
148 |
149 | return (
150 |
151 | // This would make sure transition's new state is captured at the end of the route change.
152 |
153 |
154 | // This captures link clicks and injects a view transition.
155 |
156 |
157 | {children}
158 |
159 |
160 | )
161 | }
162 | ```
163 |
164 |
165 | ## Examples
166 |
167 | - [A very simply NextJS example](https://codesandbox.io/p/github/noamr/nextjs-view-transitions-simple-example/)
168 | - [NextJS Movies App with view transitions](https://github.com/noamr/next-movies/tree/vt) (see [live demo](https://next-movies-with-view-transitions.vercel.app/))
169 | - [Another simple example](https://noamr.github.io/use-view-transitions/)
170 |
--------------------------------------------------------------------------------
/examples/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | View Transitions + React
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/examples/react/App.css:
--------------------------------------------------------------------------------
1 | body {
2 | display: flex;
3 | justify-content: center;
4 | font-family: sans-serif;
5 | }
6 |
7 | .App {
8 | text-align: center;
9 | }
10 |
11 | #square {
12 | view-transition-name: square;
13 | width: 100px;
14 | height: 100px;
15 | background: red;
16 | display: flex;
17 | align-items: center;
18 | justify-content: center;
19 | }
20 |
21 | #slow {
22 | width: 100px;
23 | height: 100px;
24 | view-transition-name: slow;
25 | background: lightblue;
26 | display: flex;
27 | align-items: center;
28 | justify-content: center;
29 | overflow: hidden;
30 | text-overflow: ellipsis;
31 | color: blue;
32 | }
33 |
34 | #square.on {
35 | background: green;
36 | margin-left: auto;
37 | }
38 |
--------------------------------------------------------------------------------
/examples/react/delayed.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useReducer } from "react";
2 | import { useViewTransition, SuspendViewTransition } from "../../src/react-vt"
3 |
4 | async function delay(color) {
5 | await new Promise((resolve) => setTimeout(resolve, 500));
6 | return color
7 | }
8 |
9 | export function SquareWithDelayedTransition() {
10 | const [state, setState] = useState("idle");
11 | const [count, inc] = useReducer(x => x + 1, 0);
12 | const [color, setColor] = useState("blue");
13 | const { startViewTransition } = useViewTransition();
14 |
15 | return (
16 | <>
17 | {
21 | inc();
22 | startViewTransition(async () => {
23 | setState("computing");
24 | setColor(await delay(color === "blue" ? "green" : "blue"));
25 | setState("idle");
26 | });
27 | }}
28 | >
29 | {state === "computing" ? (
30 | <>
31 |
...
32 |
33 | >
34 | ) : (
35 | `Click ${count}`
36 | )}
37 |
38 | >
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/examples/react/immediate.jsx:
--------------------------------------------------------------------------------
1 | import { useReducer } from "react";
2 | import { useViewTransition } from "../../src/react-vt"
3 | export function SquareWithImmediateTransition() {
4 | const [on, toggle] = useReducer((x) => !x, false);
5 | const { startViewTransition } = useViewTransition();
6 | return (
7 | startViewTransition(toggle)}
11 | >
12 | Click Me!
13 |
14 | );
15 | }
--------------------------------------------------------------------------------
/examples/react/mood-picker.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { useViewTransition } from '../../src/react-vt'
3 |
4 | const moods = [
5 | { emoji: '🙂', label: 'Happy' },
6 | { emoji: '😂', label: 'LOL' },
7 | { emoji: '😎', label: 'Chill' },
8 | { emoji: '😢', label: 'Sad' },
9 | { emoji: '🦄', label: "I'm a unicorn" },
10 | ]
11 |
12 | export function MooodPicker() {
13 | const [currentMoodIndex, setCurrentMoodIndex] = useState(0)
14 | const { startViewTransition } = useViewTransition()
15 | return (
16 |
17 | {moods.map(({ emoji }, index) => (
18 |
35 | ))}
36 |
37 |
43 | {moods[currentMoodIndex].emoji}
44 |
45 |
{moods[currentMoodIndex].label}
46 |
47 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/examples/react/react-simple.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import './App.css'
4 | import { SquareWithImmediateTransition } from "./immediate";
5 | import { SquareWithDelayedTransition} from "./delayed";
6 | import "./App.css";
7 | import { MooodPicker } from './mood-picker';
8 |
9 |
10 | ReactDOM.createRoot(document.getElementById('root')).render(
11 |
12 | Immediate transition:
13 |
14 | Suspended Transition:
15 |
16 | Mood picker:
17 |
18 | ,
19 | )
20 |
--------------------------------------------------------------------------------
/examples/vite.config.js:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react'
2 | import { resolve } from 'path'
3 | import { defineConfig } from 'vite'
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [react()],
8 | base: '/use-view-transitions/',
9 | build: {
10 | rollupOptions: {
11 | input: resolve(__dirname, "index.html"),
12 | }
13 | }
14 | })
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "use-view-transitions",
3 | "version": "1.0.16",
4 | "description": "A React hook for CSS view transitions",
5 | "scripts": {
6 | "build:examples": "vite build examples",
7 | "build:packages": "npm run build:packages:cjs && npm run build:packages:esm && npm run build:types",
8 | "build:packages:cjs": "esbuild --external:react --external:react-dom --format=cjs --bundle --outdir=dist/cjs --sourcemap=external ./src/*",
9 | "build:packages:esm": "esbuild --external:react --external:react-dom --format=esm --bundle --outdir=dist/esm --sourcemap=external ./src/*",
10 | "build:types": "tsc --emitDeclarationOnly",
11 | "build": "npm run build:packages && jsdoc -R README.md src/",
12 | "build:watch": "esbuild --external:react --external:react-dom --format=cjs --bundle --outdir=dist/cjs --loader:.ts=tsx --sourcemap=inline --watch ./src/*",
13 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
14 | "preview": "vite build --watch examples & vite preview examples"
15 | },
16 | "author": "Noam Rosenthal ",
17 | "repository": "noamr/use-view-transitions",
18 | "license": "MIT",
19 | "peerDependencies": {
20 | "react": "^18.2.0",
21 | "react-dom": "^18.2.0"
22 | },
23 | "exports": {
24 | "./react": {
25 | "require": "./dist/cjs/react-vt.js",
26 | "import": "./dist/esm/react-vt.js",
27 | "types": "./dist/react-vt.d.ts"
28 | },
29 | "./next": {
30 | "require": "./dist/cjs/next-vt.js",
31 | "import": "./dist/esm/next-vt.js",
32 | "types": "./dist/next-vt.d.ts"
33 | }
34 | },
35 | "devDependencies": {
36 | "@types/node": "^20.5.0",
37 | "@types/react": "^18.2.8",
38 | "@types/react-dom": "^18.2.4",
39 | "@typescript-eslint/eslint-plugin": "^6.4.0",
40 | "@typescript-eslint/parser": "^6.4.0",
41 | "@vitejs/plugin-react": "^4.0.0",
42 | "esbuild": "^0.17.19",
43 | "eslint": "^8.42.0",
44 | "eslint-plugin-react": "^7.32.2",
45 | "eslint-plugin-react-hooks": "^4.6.0",
46 | "eslint-plugin-react-refresh": "0.4.3",
47 | "jsdoc": "^4.0.2",
48 | "react-refresh": "0.14.0",
49 | "typescript": "^5.1.6",
50 | "vite": "^4.3.9"
51 | },
52 | "files": [
53 | "dist/cjs/next-vt.js",
54 | "dist/cjs/react-vt.js",
55 | "dist/esm/next-vt.js",
56 | "dist/esm/react-vt.js",
57 | "dist/next-vt.d.ts",
58 | "dist/react-vt.d.ts"
59 | ]
60 | }
61 |
--------------------------------------------------------------------------------
/src/next-vt.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useViewTransition,
3 | SuspendViewTransition
4 | } from "./react-vt";
5 | import {
6 | useEffect,
7 | Suspense,
8 | FC,
9 | } from "react";
10 | import React from "react";
11 |
12 | export { SuspendViewTransition, useViewTransition } from "./react-vt";
13 |
14 | interface UseNextRouterViewTransitionsProps {
15 | events: {
16 | on: (event: string, handler: () => void) => void;
17 | off: (event: string, handler: () => void) => void;
18 | };
19 | }
20 |
21 | /**
22 | * Performs CSS view transitions automatically when a NextJS navigation takes place.
23 | *
24 | * Use this hook in your _app.js.
25 | *
26 | */
27 | export function useNextRouterViewTransitions({ events }: UseNextRouterViewTransitionsProps): void {
28 | const {
29 | startViewTransition,
30 | suspendViewTransitionCapture,
31 | resumeViewTransitionCapture
32 | } = useViewTransition();
33 |
34 | useEffect(() => {
35 | function beginNavigation(): void {
36 | startViewTransition();
37 | suspendViewTransitionCapture();
38 | };
39 |
40 | function endNavigation(): void {
41 | resumeViewTransitionCapture();
42 | }
43 |
44 | events.on("routeChangeStart", beginNavigation);
45 | events.on("routeChangeComplete", endNavigation);
46 | return () => {
47 | events.off("routeChangeStart", beginNavigation);
48 | events.off("routeChangeComplete", endNavigation);
49 | };
50 | }, []);
51 | }
52 |
53 | function RouterEventsNotifier(): null {
54 | return null;
55 | }
56 |
57 | /**
58 | * A React component that makes sure view-transitions
59 | * behave nicely with NextJS app router.
60 | *
61 | * Specifically, it suspends capturing the new state until the
62 | * navigation is complete.
63 | *
64 | * @type {React.FC<{}>}
65 | * @returns {React.ReactElement}
66 | */
67 | export const EnableNextAppRouterViewTransitions: FC = () => {
68 | return (
69 | }>
70 |
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/src/react-vt.ts:
--------------------------------------------------------------------------------
1 | import {
2 | useState,
3 | useReducer,
4 | useSyncExternalStore,
5 | startTransition as reactStartTransition,
6 | useEffect,
7 | useTransition,
8 | TransitionFunction,
9 | } from "react";
10 |
11 | let suspendersCount = 0;
12 | const observers = new Set<() => void>();
13 |
14 | let didCaptureNewState: ((value?: PromiseLike | void) => void ) | null = null;
15 |
16 | function suspendViewTransitionCapture(): void {
17 | suspendersCount++;
18 | }
19 |
20 | function resumeViewTransitionCapture(): void {
21 | !--suspendersCount;
22 | observers.forEach(observer => observer());
23 | }
24 |
25 | const areViewTransitionsSupported = typeof globalThis.document?.startViewTransition === "function";
26 |
27 | function useBlockRendering(blocked: boolean): void {
28 | const [, forceRender] = useReducer(x => x + 1, 0);
29 | if (blocked) {
30 | const deadline = performance.now() + 1;
31 | while (performance.now() < deadline) {
32 | // Do nothing. Busy wait to make sure react rerenders.
33 | }
34 | }
35 | useEffect(() => {
36 | if (blocked)
37 | forceRender();
38 | });
39 | }
40 |
41 | interface AutoViewTransitionsOnClickProps {
42 | match?: string;
43 | }
44 |
45 | export function AutoViewTransitionsOnClick({ match = "a[href]" }: AutoViewTransitionsOnClickProps): null {
46 | const [, startTransition] = useTransition();
47 | const { startViewTransition } = useViewTransition();
48 | useEffect(() => {
49 | if (!match || !globalThis.document)
50 | return;
51 |
52 | function captureClick(event: MouseEvent) {
53 | const target = event.target as Element;
54 | if (!target.matches(match) || !event.isTrusted)
55 | return;
56 |
57 | event.preventDefault();
58 | event.stopPropagation();
59 | startViewTransition(() => startTransition(() => (target as HTMLElement).click()));
60 | }
61 |
62 | globalThis.document.addEventListener("click", captureClick, { capture: true });
63 | return () => {
64 | globalThis.document.removeEventListener("click", captureClick)
65 | }
66 | }, []);
67 | return null;
68 | }
69 |
70 | export function SuspendViewTransition(): null {
71 | useEffect(() => {
72 | suspendViewTransitionCapture();
73 | return () => resumeViewTransitionCapture();
74 | }, []);
75 | return null;
76 | }
77 |
78 | type TransitionState = "idle" | "capturing-old" | "capturing-new" | "animating" | "skipped";
79 |
80 | interface ViewTransitionController {
81 | transitionState: TransitionState;
82 | startViewTransition: (domUpdateCallback?: () => PromiseLike) => PromiseLike | void;
83 | suspendViewTransitionCapture: typeof suspendViewTransitionCapture;
84 | resumeViewTransitionCapture: typeof resumeViewTransitionCapture;
85 | }
86 |
87 | export function useViewTransition(): { resumeViewTransitionCapture: () => void; transitionState: "idle" | "capturing-old" | "capturing-new" | "animating" | "skipped"; startViewTransition: (updateCallback?: React.TransitionFunction) => (PromiseLike | void); suspendViewTransitionCapture: () => void } {
88 | const [transitionState, setTransitionState] = useState("idle");
89 | useSyncExternalStore((onStoreChange) => {
90 | observers.add(onStoreChange);
91 | return () => {
92 | observers.delete(onStoreChange);
93 | }
94 | }, () => suspendersCount, () => 0);
95 |
96 | useEffect(() => {
97 | if (didCaptureNewState && !suspendersCount) {
98 | didCaptureNewState()
99 | didCaptureNewState = null;
100 | }
101 | });
102 |
103 | useBlockRendering(transitionState === "capturing-old");
104 |
105 | function startViewTransition(updateCallback?: TransitionFunction): PromiseLike | void {
106 | // Fallback to simply running the callback soon.
107 | if (!areViewTransitionsSupported) {
108 | if (updateCallback)
109 | reactStartTransition(updateCallback);
110 | return;
111 | }
112 |
113 | suspendViewTransitionCapture();
114 | setTransitionState("capturing-old");
115 | const transition = document.startViewTransition!(() => new Promise(async resolve => {
116 | setTransitionState("capturing-new");
117 | resumeViewTransitionCapture();
118 | if (updateCallback)
119 | await updateCallback();
120 | didCaptureNewState = resolve;
121 | }));
122 |
123 | transition.finished.then(() => {
124 | setTransitionState("idle");
125 | });
126 |
127 | transition.ready.then(() => {
128 | setTransitionState("animating");
129 | }).catch(e => {
130 | console.error(e)
131 | setTransitionState("skipped");
132 | });
133 | }
134 |
135 | return {
136 | transitionState,
137 | startViewTransition,
138 | suspendViewTransitionCapture,
139 | resumeViewTransitionCapture
140 | };
141 | }
142 |
143 | interface UseAutoViewTransitionsProps {
144 | enabled?: boolean;
145 | }
146 |
147 | export function useAutoViewTransitions({ enabled = true }: UseAutoViewTransitionsProps): void {
148 | const { transitionState, startViewTransition } = useViewTransition();
149 | const [isPending] = useTransition();
150 | useEffect(() => {
151 | if (enabled && transitionState === "idle" && isPending && areViewTransitionsSupported) {
152 | startViewTransition();
153 | }
154 | }, [transitionState, isPending, enabled]);
155 | }
156 |
--------------------------------------------------------------------------------
/src/types.d.ts:
--------------------------------------------------------------------------------
1 | interface Document {
2 | startViewTransition?: (callback: () => PromiseLike | void) => {
3 | finished: Promise
4 | ready: Promise
5 | updateCallbackDone: Promise
6 | skipTransition: () => void
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2015",
4 | "module": "commonjs",
5 | "jsx": "react",
6 | "outDir": "./dist",
7 | "strict": true,
8 | "declaration": true,
9 | "esModuleInterop": true
10 | },
11 | "include": ["src/**/*.ts", "src/**/*.tsx"],
12 | "exclude": ["node_modules"]
13 | }
14 |
--------------------------------------------------------------------------------