├── .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 | --------------------------------------------------------------------------------