├── .changeset ├── README.md └── config.json ├── .github └── workflows │ ├── publish-any-commit.yml │ └── release.yml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── README.md ├── eslint.config.mjs ├── package.json ├── packages └── core │ ├── core.tsx │ ├── events.ts │ ├── helpers.ts │ ├── hooks.ts │ ├── index.ts │ ├── package.json │ └── scheduler.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── static ├── JOYCO.png └── banner.png ├── templates ├── demo │ ├── .env.example │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── app │ │ ├── app.css │ │ ├── components │ │ │ ├── footer.tsx │ │ │ ├── header.tsx │ │ │ └── paragraph.tsx │ │ ├── hooks │ │ │ ├── use-device-detect.ts │ │ │ └── use-hydrated.ts │ │ ├── lib │ │ │ ├── gsap │ │ │ │ └── index.ts │ │ │ └── utils │ │ │ │ ├── breakpoints.ts │ │ │ │ ├── cn.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── image.ts │ │ │ │ ├── index.ts │ │ │ │ ├── links.ts │ │ │ │ └── meta.ts │ │ ├── root.tsx │ │ ├── routes.ts │ │ └── routes │ │ │ ├── about.tsx │ │ │ ├── home.tsx │ │ │ └── prevent-transition.tsx │ ├── dev-server.js │ ├── eslint.config.mjs │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ │ ├── JOYCO.png │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── banner.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── logo.svg │ │ ├── opengraph-image.png │ │ └── site.webmanifest │ ├── react-router.config.ts │ ├── server │ │ └── app.ts │ ├── tailwind.config.ts │ ├── tsconfig.json │ ├── vercel │ │ ├── output │ │ │ ├── config.json │ │ │ └── functions │ │ │ │ └── index.func │ │ │ │ ├── .vc-config.json │ │ │ │ └── package.json │ │ └── prepare.js │ └── vite.config.ts └── react-router │ ├── .gitignore │ ├── README.md │ ├── app │ ├── app.css │ ├── components │ │ ├── navigation.tsx │ │ ├── page.tsx │ │ └── transition-state.tsx │ ├── root.tsx │ ├── routes.ts │ └── routes │ │ ├── about.tsx │ │ ├── contact.tsx │ │ ├── home.tsx │ │ └── projects │ │ ├── [slug].tsx │ │ └── index.tsx │ ├── package.json │ ├── public │ └── favicon.ico │ ├── react-router.config.ts │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── vite.config.ts ├── tsconfig.json └── tsup.config.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.5/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [ 11 | "@packages/core", 12 | "@templates/react-router", 13 | "@templates/demo" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/publish-any-commit.yml: -------------------------------------------------------------------------------- 1 | name: Publish Any Commit 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v4 11 | 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: 20 15 | registry-url: 'https://registry.npmjs.org' 16 | 17 | - name: Install pnpm 18 | uses: pnpm/action-setup@v2 19 | with: 20 | version: 8 21 | run_install: false 22 | 23 | - name: Install dependencies 24 | run: pnpm install 25 | 26 | - name: Build 27 | run: pnpm build 28 | 29 | - run: pnpx pkg-pr-new publish --comment=update --template './templates/*' 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | 14 | jobs: 15 | release: 16 | name: Release 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout Repo 20 | uses: actions/checkout@v3 21 | 22 | - name: Setup Node.js 18.x 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: 18.x 26 | 27 | - uses: pnpm/action-setup@v2 28 | with: 29 | version: 8 30 | 31 | - name: Install Dependencies 32 | run: pnpm install 33 | 34 | - name: Create Release Pull Request or Publish to npm 35 | id: changesets 36 | uses: changesets/action@v1 37 | with: 38 | # This expects you to have a script called release which does a build for your packages and calls changeset publish 39 | publish: pnpm release 40 | version: pnpm version:package 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | .DS_Store -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "printWidth": 120, 6 | "tabWidth": 2 7 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @joycostudio/transitions 2 | 3 | ## 0.3.1 4 | 5 | ### Patch Changes 6 | 7 | - 29e5112: set history scrollrestoration to manual by default 8 | 9 | ## 0.3.0 10 | 11 | ### Minor Changes 12 | 13 | - 4b172bb: add createTransition and scheduler functionality 14 | 15 | ## 0.2.0 16 | 17 | ### Minor Changes 18 | 19 | - d879ace: Add usePreservedRouteLoaderData hook to preserve route loader data during transitions 20 | 21 | ## 0.1.0 22 | 23 | ### Minor Changes 24 | 25 | - 1c987f9: BREAKING CHANGE > We got rid of the weird callback object api for transition events in favor of providing the "from" and "to" values on navigation. 26 | 27 | ### Patch Changes 28 | 29 | - 1c987f9: Introducing preventTransition callback, you can skip the transition pipeline and changing keys at transition route level in favor of nested RouteTransitions. 30 | 31 | ## 0.0.9 32 | 33 | ### Patch Changes 34 | 35 | - 0c34bce: Introduce DocumentTransitionState component and lock links example 36 | 37 | ## 0.0.8 38 | 39 | ### Patch Changes 40 | 41 | - fa84b62: fix double render over the same path 42 | 43 | ## 0.0.7 44 | 45 | ### Patch Changes 46 | 47 | - d1ccfd1: fix module resolution 48 | 49 | ## 0.0.6 50 | 51 | ### Patch Changes 52 | 53 | - fcbf82f: Fix quick navigation to different path and then back to the same, add navigationId to the recipe 54 | 55 | ## 0.0.5 56 | 57 | ### Patch Changes 58 | 59 | - a0ae8e5: Introduce useTransitionState 60 | 61 | ## 0.0.4 62 | 63 | ### Patch Changes 64 | 65 | - 574c84f: add loader hooks 66 | 67 | ## 0.0.3 68 | 69 | ### Patch Changes 70 | 71 | - 595cb3e: simplify how nodes are handled and add test template 72 | 73 | ## 0.0.2 74 | 75 | ### Patch Changes 76 | 77 | - 3292176: Fix typing stuff 78 | 79 | ## 0.0.1 80 | 81 | ### Patch Changes 82 | 83 | - 307c272: The very first version 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JOYCO Logo  JOYCO Transitions 2 | 3 | ![banner.png](./static/banner.png) 4 | 5 | Plug & Play page transitions for React Router. See it in action in our [demo page](https://transitions.joyco.studio). 6 | 7 | ```bash 8 | pnpm add @joycostudio/transitions 9 | ``` 10 | 11 | ## 📖 Documentation 12 | 13 | ### Core Components 14 | 15 | #### `RouteTransitionManager` 16 | 17 | The main component responsible for managing route transitions. It wraps your route content and handles all transition states. It NEEDS to have anchor a ref to some element so it can be preserved on unmount. If you don't want to mess the inner children layout with a wrapper just do `
{myUltraDelicateChildren}
`. 18 | 19 | ```tsx 20 | { 26 | // Your enter animation 27 | }, 28 | }} 29 | onExit={{ 30 | default: async (node) => { 31 | // Your exit animation 32 | }, 33 | }} 34 | > 35 | {(nodeRef) =>
{/* Your route content */}
} 36 |
37 | ``` 38 | 39 | Props: 40 | 41 | - `children`: Function that receives a ref to be attached to your route content 42 | - `pathname`: Current route pathname 43 | - `mode`: Transition mode ('out-in' | 'in-out') 44 | - `onEnter`: Record of enter animations by route or default 45 | - `onExit`: Record of exit animations by route or default 46 | - `onEntering`: Optional callbacks when entering starts 47 | - `onEntered`: Optional callbacks when entering completes 48 | - `onExiting`: Optional callbacks when exiting starts 49 | - `onExited`: Optional callbacks when exiting completes 50 | - `appear`: Whether to animate on first render 51 | - `routes`: Array of route configurations 52 | 53 | #### `DocumentTransitionState` 54 | 55 | A utility component that adds a `data-transition-state` attribute to the document root, useful for controlling UI elements during transitions. 56 | 57 | ```tsx 58 | 59 | ``` 60 | 61 | #### ✨ TIP | Lock links while transitioning 62 | 63 | If you use the `` component. It will attach a `data-transition-state` to the document's root. You can use it to disable all the links while the page is transitioning to make the experience feel more controlled. 64 | 65 | ```css 66 | /* Disable links during transitions */ 67 | html:not([data-transition-state='idle']) a { 68 | pointer-events: none; 69 | } 70 | ``` 71 | 72 | ### Hooks 73 | 74 | #### `usePreservedLoaderData()` 75 | 76 | Returns a frozen version of the loader data to prevent data changes during transitions. 77 | 78 | ```tsx 79 | const data = usePreservedLoaderData() 80 | ``` 81 | 82 | #### `useTransitionState()` 83 | 84 | Returns the current transition state and helper flags. 85 | 86 | ```tsx 87 | const { 88 | state, // 'entering' | 'exiting' | 'idle' 89 | isEntering, // boolean 90 | isExiting, // boolean 91 | isIdle, // boolean 92 | } = useTransitionState() 93 | ``` 94 | 95 |
96 | 97 | ## 🤖 Automatic Workflows 98 | 99 | This template comes with two GitHub Actions workflows (currently disabled for convenience): 100 | 101 | 1. **Release Workflow** (`.github/workflows/release.yml`): Automates the release process using Changesets. When enabled, it will automatically create release pull requests and publish to npm when changes are pushed to the main branch. 102 | 103 | 2. **Publish Any Commit** (`.github/workflows/publish-any-commit.yml`): A utility workflow that can build and publish packages for any commit or pull request. 104 | 105 |
106 | 107 | ## 🦋 Version Management 108 | 109 | This library uses [Changesets](https://github.com/changesets/changesets) to manage versions and publish releases. Here's how to use it: 110 | 111 | ### Adding a changeset 112 | 113 | When you make changes that need to be released: 114 | 115 | ```bash 116 | pnpm changeset 117 | ``` 118 | 119 | This will prompt you to: 120 | 121 | 1. Select which packages you want to include in the changeset 122 | 2. Choose whether it's a major/minor/patch bump 123 | 3. Provide a summary of the changes 124 | 125 | ### Creating a release 126 | 127 | To create a new version and update the changelog: 128 | 129 | ```bash 130 | # 1. Create new versions of packages 131 | pnpm version:package 132 | 133 | # 2. Release (builds and publishes to npm) 134 | pnpm release 135 | ``` 136 | 137 | Remember to commit all changes after creating a release. 138 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals' 2 | import pluginJs from '@eslint/js' 3 | import tseslint from 'typescript-eslint' 4 | import pluginReact from 'eslint-plugin-react' 5 | import reactCompiler from 'eslint-plugin-react-compiler' 6 | import prettier from 'eslint-config-prettier' 7 | import eslintPluginPrettier from 'eslint-plugin-prettier' 8 | 9 | export default tseslint.config([ 10 | { files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] }, 11 | { ignores: ['dist', '**/.*'] }, 12 | { languageOptions: { globals: { ...globals.browser, ...globals.node } } }, 13 | pluginJs.configs.recommended, 14 | tseslint.configs.recommended, 15 | pluginReact.configs.flat['jsx-runtime'], 16 | { 17 | plugins: { 18 | 'react-compiler': reactCompiler, 19 | prettier: eslintPluginPrettier, 20 | }, 21 | rules: { 22 | 'react-compiler/react-compiler': 'error', 23 | 'prettier/prettier': 'warn', 24 | }, 25 | }, 26 | prettier, 27 | ]) 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@joycostudio/transitions", 3 | "publishConfig": { 4 | "registry": "https://registry.npmjs.org", 5 | "access": "public" 6 | }, 7 | "version": "0.3.1", 8 | "description": "Transitions for the ladies and gentlemen", 9 | "main": "dist/index.js", 10 | "module": "dist/index.mjs", 11 | "types": "dist/index.d.ts", 12 | "files": [ 13 | "dist" 14 | ], 15 | "exports": { 16 | ".": { 17 | "types": "./dist/index.d.ts", 18 | "default": "./dist/index.mjs" 19 | } 20 | }, 21 | "scripts": { 22 | "build": "tsup", 23 | "dev": "concurrently \"tsup --watch\" \"cd templates/demo && pnpm dev\"", 24 | "typecheck": "tsc --noEmit", 25 | "version:package": "pnpm changeset version", 26 | "release": "pnpm build && pnpm changeset publish", 27 | "lint": "eslint -c ./eslint.config.mjs . --fix --no-cache" 28 | }, 29 | "author": "joyco.studio", 30 | "license": "ISC", 31 | "devDependencies": { 32 | "@changesets/cli": "^2.27.11", 33 | "@eslint/js": "^9.18.0", 34 | "@react-router/dev": "^7.1.3", 35 | "@types/node": "^20.11.24", 36 | "@types/react": "^18.2.61", 37 | "@types/react-transition-group": "^4.4.12", 38 | "@typescript-eslint/eslint-plugin": "^8.21.0", 39 | "@typescript-eslint/parser": "^8.21.0", 40 | "concurrently": "^9.1.2", 41 | "eslint": "^9.18.0", 42 | "eslint-config-prettier": "^10.0.1", 43 | "eslint-plugin-prettier": "^5.2.3", 44 | "eslint-plugin-react": "^7.37.4", 45 | "eslint-plugin-react-compiler": "19.0.0-beta-decd7b8-20250118", 46 | "globals": "^15.14.0", 47 | "prettier": "^3.4.2", 48 | "react": "^19.0.0", 49 | "tsup": "^8.0.2", 50 | "typescript": "^5.7.3", 51 | "typescript-eslint": "^8.21.0" 52 | }, 53 | "peerDependencies": { 54 | "react": ">=16.8.0", 55 | "react-router": ">=7" 56 | }, 57 | "dependencies": { 58 | "directed": "^0.1.6", 59 | "nanoid": "^5.0.9", 60 | "react-transition-group": "^4.4.5", 61 | "tiny-emitter": "^2.1.0" 62 | }, 63 | "packageManager": "pnpm@7.33.5+sha512.4e499f22fffe5845aa8f9463e1386b2d72c3134e0ebef9409360ad844fef0290e82b479b32eb2ec0f30e56607e1820c22167829fd62656d41a8fd0cc4a0f4267" 64 | } -------------------------------------------------------------------------------- /packages/core/core.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-compiler/react-compiler */ 2 | import { createRef, useEffect, useLayoutEffect, useMemo, useRef } from 'react' 3 | import { SwitchTransition, Transition } from 'react-transition-group' 4 | import { TinyEmitter } from 'tiny-emitter' 5 | import { RouteConfigEntry } from '@react-router/dev/routes' 6 | import { matchPath } from 'react-router' 7 | import { useTransitionState } from './hooks' 8 | import { getRoutesFlatMap, nanoid, nodeRefWarning } from './helpers' 9 | import { defaultTransitionEvents } from './events' 10 | import { 11 | createTransitionScheduler, 12 | TransitionRunnable, 13 | TransitionSchedulerOptions, 14 | useEntrance, 15 | useExit, 16 | } from './scheduler' 17 | 18 | type RouteTransitionManagerProps = { 19 | children: (nodeRef: React.RefObject) => React.ReactNode 20 | pathname: string 21 | mode?: 'out-in' | 'in-out' 22 | onEnter: (node: HTMLElement, from: string | undefined, to: string) => Promise 23 | onExit: (node: HTMLElement, from: string | undefined, to: string) => Promise 24 | onEntering?: (node: HTMLElement, from: string | undefined, to: string) => void 25 | onEntered?: (node: HTMLElement, from: string | undefined, to: string) => void 26 | onExiting?: (node: HTMLElement, from: string | undefined, to: string) => void 27 | onExited?: (node: HTMLElement, from: string | undefined, to: string) => void 28 | preventTransition?: (from: string | undefined, to: string) => boolean 29 | appear?: boolean 30 | routes: RouteConfigEntry[] 31 | events?: TinyEmitter 32 | } 33 | 34 | export const RouteTransitionManager = ({ 35 | routes, 36 | onEnter, 37 | onEntering, 38 | onEntered, 39 | onExit, 40 | onExiting, 41 | onExited, 42 | preventTransition, 43 | children, 44 | pathname, 45 | mode = 'out-in', 46 | events = defaultTransitionEvents, 47 | appear = false, 48 | }: RouteTransitionManagerProps) => { 49 | const callbackTimePrevPathnameRef = useRef() 50 | const renderTimePrevPathnameRef = useRef() 51 | const pathnameRef = useRef(pathname) 52 | const transitions = useRef | undefined>>({}) 53 | const prevKeyRef = useRef() 54 | const preventTransitionRef = useRef(preventTransition) 55 | const routeNodeRefs = getRoutesFlatMap(routes) 56 | 57 | useEffect(() => { 58 | return () => { 59 | callbackTimePrevPathnameRef.current = pathname 60 | } 61 | }, [pathname]) 62 | 63 | useLayoutEffect(() => { 64 | const prevValue = window.history.scrollRestoration 65 | window.history.scrollRestoration = 'manual' 66 | 67 | return () => { 68 | window.history.scrollRestoration = prevValue 69 | } 70 | }, []) 71 | 72 | const currentMatch = useMemo(() => routeNodeRefs.find((route) => matchPath(route.path, pathname)), [pathname]) 73 | const nodeRef = currentMatch?.nodeRef ?? createRef() 74 | 75 | pathnameRef.current = pathname 76 | preventTransitionRef.current = preventTransition 77 | 78 | /** 79 | * Key changes on every pathname change. BUT 👇 80 | * 81 | * If preventTransition returns true, the key will not change. And will use the previous key. Why would you want to do this? 82 | * If you have nested , the parent manager will prevent the child manager from preserve it's exiting child on the DOM 83 | * if the parent manager changes it's key. So you have to decide if you want to prevent the transition on the parent manager or not. 84 | */ 85 | const key = useMemo(() => { 86 | let nextKey 87 | 88 | if (preventTransitionRef.current?.(renderTimePrevPathnameRef.current, pathname)) { 89 | nextKey = prevKeyRef.current 90 | } 91 | 92 | return nextKey ?? nanoid() 93 | }, [pathname]) 94 | 95 | prevKeyRef.current = key 96 | /** 97 | * Why this here and in useLayoutEffect? 98 | * 99 | * We need this at render time to get the previous pathname on the memoized key function. 100 | * But we also need to set it again in the cleanup funtion to get the right value into the transition event callbacks eg: onEnter(node, prevPathname, pathname). 101 | * Otherwise onEnter will get the updated pathname and not the previous one. 102 | */ 103 | renderTimePrevPathnameRef.current = pathname 104 | 105 | return ( 106 | 107 | } 111 | addEndListener={(done) => { 112 | transitions.current[pathname]?.then(done) 113 | }} 114 | /* ENTER EVENTS */ 115 | onEnter={() => { 116 | if (!nodeRef?.current) { 117 | nodeRefWarning(pathname) 118 | return 119 | } 120 | events.emit('enter', pathname) 121 | transitions.current[pathname] = onEnter?.(nodeRef?.current, callbackTimePrevPathnameRef.current, pathname) 122 | }} 123 | onEntering={() => { 124 | if (!nodeRef?.current) { 125 | nodeRefWarning(pathname) 126 | return 127 | } 128 | events.emit('entering', pathname) 129 | onEntering?.(nodeRef?.current, callbackTimePrevPathnameRef.current, pathname) 130 | }} 131 | onEntered={() => { 132 | if (!nodeRef?.current) { 133 | nodeRefWarning(pathname) 134 | return 135 | } 136 | events.emit('entered', pathname) 137 | onEntered?.(nodeRef?.current, callbackTimePrevPathnameRef.current, pathname) 138 | }} 139 | /* EXIT EVENTS */ 140 | onExit={() => { 141 | if (!nodeRef?.current) { 142 | nodeRefWarning(pathname) 143 | return 144 | } 145 | events.emit('exit', pathname) 146 | transitions.current[pathname] = onExit?.(nodeRef?.current, pathname, pathnameRef.current) 147 | }} 148 | onExiting={() => { 149 | if (!nodeRef?.current) { 150 | nodeRefWarning(pathname) 151 | return 152 | } 153 | events.emit('exiting', pathname) 154 | onExiting?.(nodeRef?.current, pathname, pathnameRef.current) 155 | }} 156 | onExited={() => { 157 | if (!nodeRef?.current) { 158 | nodeRefWarning(pathname) 159 | return 160 | } 161 | events.emit('exited', pathname) 162 | onExited?.(nodeRef?.current, pathname, pathnameRef.current) 163 | }} 164 | > 165 | {/* @ts-expect-error - Internal use only, I don't want to type this navigationHash.current */} 166 | {children(nodeRef, key)} 167 | 168 | 169 | ) 170 | } 171 | 172 | type DocumentTransitionStateProps = { 173 | events?: TinyEmitter 174 | } 175 | 176 | export const DocumentTransitionState = ({ events = defaultTransitionEvents }: DocumentTransitionStateProps) => { 177 | const { state } = useTransitionState(events) 178 | 179 | useEffect(() => { 180 | document.documentElement.setAttribute('data-transition-state', state) 181 | }, [state]) 182 | 183 | return <> 184 | } 185 | 186 | /* Handy transition manager factory */ 187 | export const createTransition = () => { 188 | const events = new TinyEmitter() 189 | const scheduler = createTransitionScheduler() 190 | 191 | return { 192 | events, 193 | scheduler, 194 | RouteTransitionManager: (props: Omit) => ( 195 | 196 | ), 197 | DocumentTransitionState: (props: Omit) => ( 198 | 199 | ), 200 | useTransitionState: () => useTransitionState(events), 201 | useEntrance: (fn: TransitionRunnable, options?: TransitionSchedulerOptions) => useEntrance(scheduler, fn, options), 202 | useExit: (fn: TransitionRunnable, options?: TransitionSchedulerOptions) => useExit(scheduler, fn, options), 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /packages/core/events.ts: -------------------------------------------------------------------------------- 1 | import { TinyEmitter } from 'tiny-emitter' 2 | 3 | /* Default events emitter */ 4 | export const defaultTransitionEvents = new TinyEmitter() 5 | -------------------------------------------------------------------------------- /packages/core/helpers.ts: -------------------------------------------------------------------------------- 1 | import { RouteConfigEntry } from '@react-router/dev/routes' 2 | import { customAlphabet } from 'nanoid' 3 | import { createRef } from 'react' 4 | 5 | export const nodeRefWarning = (pathname: string) => { 6 | console.warn(`${pathname} - nodeRef is null`) 7 | } 8 | 9 | export const getRoutesFlatMap = (routes: RouteConfigEntry[]) => { 10 | /* Traverse routes and their .children */ 11 | const routeNodeRefs: { path: string; nodeRef: React.RefObject }[] = [] 12 | 13 | const traverseRoutes = (_routes: RouteConfigEntry[]) => { 14 | for (const route of _routes) { 15 | routeNodeRefs.push({ 16 | path: route.path ?? '/', 17 | nodeRef: createRef(), 18 | }) 19 | 20 | if (route.children) { 21 | traverseRoutes(route.children) 22 | } 23 | } 24 | } 25 | 26 | traverseRoutes(routes) 27 | 28 | return routeNodeRefs 29 | } 30 | 31 | export const nanoid = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', 5) 32 | -------------------------------------------------------------------------------- /packages/core/hooks.ts: -------------------------------------------------------------------------------- 1 | import { DependencyList, EffectCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' 2 | import { useLoaderData, useRouteLoaderData } from 'react-router' 3 | import { TinyEmitter } from 'tiny-emitter' 4 | import { defaultTransitionEvents } from './events' 5 | 6 | export type SerializeFrom = ReturnType> 7 | 8 | /** 9 | * Returns a frozen version of the loader data to prevent data change while the transition is happening 10 | */ 11 | export function usePreservedLoaderData(): SerializeFrom { 12 | const loaderData = useLoaderData() 13 | const loaderDataRef = useRef>(loaderData) 14 | 15 | useIsomorphicLayoutEffect(() => { 16 | loaderDataRef.current = loaderData 17 | }, []) 18 | 19 | // eslint-disable-next-line react-compiler/react-compiler 20 | return loaderDataRef.current 21 | } 22 | 23 | export function usePreservedRouteLoaderData(routeId: string): SerializeFrom | undefined { 24 | const routeLoaderData = useRouteLoaderData(routeId) 25 | const routeLoaderDataRef = useRef | undefined>(routeLoaderData) 26 | 27 | useIsomorphicLayoutEffect(() => { 28 | routeLoaderDataRef.current = routeLoaderData 29 | }, []) 30 | 31 | // eslint-disable-next-line react-compiler/react-compiler 32 | return routeLoaderDataRef.current 33 | } 34 | 35 | function useIsomorphicLayoutEffect(effect: EffectCallback, deps?: DependencyList) { 36 | return useLayoutEffect(effect, deps) 37 | } 38 | 39 | /** 40 | * Returns the current transition state. 41 | * These are `entering`, `exiting`, and `idle`. 42 | */ 43 | export const useTransitionState = (events: TinyEmitter = defaultTransitionEvents) => { 44 | const [state, setState] = useState<'entering' | 'exiting' | 'idle'>('idle') 45 | 46 | useEffect(() => { 47 | const onEnter = () => setState('entering') 48 | const onExit = () => setState('exiting') 49 | const onIdle = () => setState('idle') 50 | 51 | events.on('entering', onEnter) 52 | events.on('exiting', onExit) 53 | events.on('entered', onIdle) 54 | 55 | return () => { 56 | events.off('entering', onEnter) 57 | events.off('exiting', onExit) 58 | events.off('entered', onIdle) 59 | } 60 | }, []) 61 | 62 | return { state, isEntering: state === 'entering', isExiting: state === 'exiting', isIdle: state === 'idle' } 63 | } 64 | -------------------------------------------------------------------------------- /packages/core/index.ts: -------------------------------------------------------------------------------- 1 | import { version } from '../../package.json' 2 | 3 | export const VERSION = version 4 | export * from './core' 5 | export { defaultTransitionEvents as transitionEvents } from './events' 6 | export * from './hooks' 7 | export * from './scheduler' 8 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@packages/core", 3 | "type": "module", 4 | "main": "./index.ts", 5 | "private": true, 6 | "changeset": { 7 | "ignore": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/scheduler.ts: -------------------------------------------------------------------------------- 1 | import { Schedule, type Runnable as DirectedRunnable, type SingleOptionsObject } from 'directed' 2 | import { useLayoutEffect } from 'react' 3 | 4 | export type TransitionContext = { 5 | node: HTMLElement 6 | from: string | undefined 7 | to: string 8 | } 9 | 10 | export type TransitionScheduler = { 11 | enterSchedule: Schedule 12 | exitSchedule: Schedule 13 | hasScheduledEnter: () => boolean 14 | hasScheduledExit: () => boolean 15 | enter: (context: TransitionContext) => Promise 16 | exit: (context: TransitionContext) => Promise 17 | } 18 | 19 | export type TransitionSchedulerOptions = Omit, 'tag'> & { 20 | tag?: string 21 | } 22 | 23 | export type Runnable = 24 | | DirectedRunnable 25 | | (DirectedRunnable & { 26 | tag: string 27 | }) 28 | 29 | export type TransitionRunnable = (node: HTMLElement, from: string | undefined, to: string) => void | Promise 30 | 31 | export const createTransitionScheduler = (): TransitionScheduler => { 32 | const enterSchedule = new Schedule() 33 | const exitSchedule = new Schedule() 34 | 35 | return { 36 | enterSchedule, 37 | exitSchedule, 38 | hasScheduledEnter: () => { 39 | enterSchedule.build() 40 | return enterSchedule.dag.sorted.length > 0 41 | }, 42 | hasScheduledExit: () => { 43 | exitSchedule.build() 44 | return exitSchedule.dag.sorted.length > 0 45 | }, 46 | enter: (context: TransitionContext) => run(enterSchedule, context), 47 | exit: (context: TransitionContext) => run(exitSchedule, context), 48 | } 49 | } 50 | 51 | const _flush = (scheduler: Schedule) => { 52 | scheduler.dag.sorted.forEach((node) => { 53 | scheduler.remove(node) 54 | }) 55 | } 56 | 57 | const _run = async (schedule: Schedule, context: TransitionContext) => { 58 | const sorted = schedule.dag.sorted as Runnable[] /* Overwrite type */ 59 | 60 | const grouped: (Runnable | Runnable[])[] = [] 61 | const groupIdxMap: Record = {} 62 | 63 | for (let i = 0; i < sorted.length; i++) { 64 | const runnable = sorted[i] 65 | 66 | if ('tag' in runnable) { 67 | const tag = runnable.tag 68 | 69 | if (groupIdxMap[tag] === undefined) { 70 | grouped.push([runnable]) 71 | groupIdxMap[tag] = grouped.length - 1 72 | } else { 73 | const group = grouped[groupIdxMap[tag]] 74 | 75 | if (Array.isArray(group)) { 76 | group.push(runnable) 77 | } 78 | } 79 | } else { 80 | grouped.push(runnable) 81 | } 82 | } 83 | 84 | for (let i = 0; i < grouped.length; i++) { 85 | const runnable = grouped[i] 86 | 87 | if (Array.isArray(runnable)) { 88 | const promises: Promise[] = [] 89 | 90 | for (let j = 0; j < runnable.length; j++) { 91 | const result = runnable[j](context) 92 | 93 | if (result instanceof Promise) { 94 | promises.push(result) 95 | } 96 | } 97 | 98 | await Promise.all(promises) 99 | } else { 100 | const result = runnable(context) 101 | 102 | if (result instanceof Promise) { 103 | await result 104 | } 105 | } 106 | } 107 | } 108 | 109 | const run = (scheduler: Schedule, context: TransitionContext) => { 110 | scheduler.build() 111 | 112 | return _run(scheduler, context).then(() => _flush(scheduler)) 113 | } 114 | 115 | const addTagIfNotExists = (scheduler: Schedule, tag: string) => { 116 | if (scheduler.hasTag(tag)) { 117 | return 118 | } 119 | 120 | scheduler.createTag(tag) 121 | } 122 | 123 | export const useEntrance = ( 124 | scheduler: TransitionScheduler, 125 | fn: TransitionRunnable, 126 | options?: TransitionSchedulerOptions 127 | ) => { 128 | useLayoutEffect(() => { 129 | const runnable = (context: TransitionContext) => fn(context.node, context.from, context.to) 130 | 131 | if (options?.tag) { 132 | addTagIfNotExists(scheduler.enterSchedule, options.tag) 133 | runnable.tag = options.tag 134 | } 135 | 136 | scheduler.enterSchedule.add(runnable, options) 137 | 138 | return () => { 139 | scheduler.enterSchedule.remove(runnable) 140 | } 141 | }, [scheduler, options]) 142 | } 143 | 144 | export const useExit = ( 145 | scheduler: TransitionScheduler, 146 | fn: TransitionRunnable, 147 | options?: TransitionSchedulerOptions 148 | ) => { 149 | useLayoutEffect(() => { 150 | const runnable = (context: TransitionContext) => fn(context.node, context.from, context.to) 151 | 152 | if (options?.tag) { 153 | addTagIfNotExists(scheduler.exitSchedule, options.tag) 154 | runnable.tag = options.tag 155 | } 156 | 157 | scheduler.exitSchedule.add(runnable, options) 158 | 159 | return () => { 160 | scheduler.exitSchedule.remove(runnable) 161 | } 162 | }, [scheduler, options]) 163 | } 164 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'templates/*' 4 | - '.' -------------------------------------------------------------------------------- /static/JOYCO.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joyco-studio/transitions/3b7ebcd6cbc65b887ffb124e48748dbeea5889f1/static/JOYCO.png -------------------------------------------------------------------------------- /static/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joyco-studio/transitions/3b7ebcd6cbc65b887ffb124e48748dbeea5889f1/static/banner.png -------------------------------------------------------------------------------- /templates/demo/.env.example: -------------------------------------------------------------------------------- 1 | VITE_SITE_URL=localhost:3000 2 | -------------------------------------------------------------------------------- /templates/demo/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules/ 3 | 4 | # React Router 5 | /.react-router/ 6 | /build/ 7 | 8 | # Vercel 9 | /.vercel/ 10 | 11 | # Environment 12 | .env 13 | .env.local 14 | .env.development 15 | .env.test 16 | .env.production 17 | !.env.example 18 | -------------------------------------------------------------------------------- /templates/demo/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "printWidth": 120, 6 | "tabWidth": 2 7 | } -------------------------------------------------------------------------------- /templates/demo/README.md: -------------------------------------------------------------------------------- 1 | # JOYCO Logo  JOYCO RRv7 2 | 3 | ![banner.png](./public/banner.png) 4 | 5 | The JOYCO `React Router v7` + `React 19` + `React Compiler` ready template to power your next project. 6 | 7 | ## Features 8 | 9 | - 🚀 Quick Setup 10 | - ⚛ React 19 + React Compiler Ready 11 | - 🤓 Preconfigured Eslint + Prettier 12 | - 🪄 Page Transitions 13 | - 🦸‍♂️ GSAP Setup 14 | - 🖌️ Tailwind Setup 15 | - ▲ Vercel Compatible 16 | - 🔎 Bundle Analyzer 17 | -------------------------------------------------------------------------------- /templates/demo/app/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --header-height: 48px; 7 | 8 | @screen md { 9 | --header-height: 72px; 10 | } 11 | } 12 | 13 | html, 14 | body { 15 | font-family: 'Barlow Condensed', sans-serif; 16 | font-display: swap; 17 | text-rendering: geometricprecision; 18 | text-size-adjust: 100%; 19 | -webkit-font-smoothing: antialiased; 20 | -moz-font-smoothing: antialiased; 21 | -moz-osx-font-smoothing: grayscale; 22 | 23 | @apply text-primary bg-background; 24 | 25 | @media (prefers-color-scheme: dark) { 26 | color-scheme: dark; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /templates/demo/app/components/footer.tsx: -------------------------------------------------------------------------------- 1 | import type { loader } from '@/root' 2 | import { usePreservedLoaderData } from '@joycostudio/transitions' 3 | import { Link } from 'react-router' 4 | 5 | export default function Footer() { 6 | const { rebelLog } = usePreservedLoaderData() 7 | 8 | return ( 9 |
10 | 15 | {rebelLog} 16 | 17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /templates/demo/app/components/header.tsx: -------------------------------------------------------------------------------- 1 | import type { loader } from '@/root' 2 | import routes from '@/routes' 3 | import { usePreservedLoaderData } from '@joycostudio/transitions' 4 | import { Link } from 'react-router' 5 | 6 | export const Header = () => { 7 | const { mediaLinks } = usePreservedLoaderData() 8 | 9 | return ( 10 |
11 |
12 | 13 | Rebels logo 14 | 15 | 24 |
25 | 26 |
    27 | {mediaLinks.map((link, index) => ( 28 |
  • 29 | 30 | {link.label} 31 | 32 |
  • 33 | ))} 34 |
35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /templates/demo/app/components/paragraph.tsx: -------------------------------------------------------------------------------- 1 | import cn from '@/lib/utils/cn' 2 | 3 | export const Paragraph = ({ children, className }: { children: React.ReactNode; className?: string }) => { 4 | return ( 5 |

11 | {children} 12 |

13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /templates/demo/app/hooks/use-device-detect.ts: -------------------------------------------------------------------------------- 1 | import * as ReactDeviceDetect from 'react-device-detect' 2 | 3 | import { useHydrated } from './use-hydrated' 4 | 5 | type DD = { 6 | isMobile?: boolean 7 | isTablet?: boolean 8 | isDesktop?: boolean 9 | isMobileSafari?: boolean 10 | isMobileOnly?: boolean 11 | isSafari?: boolean 12 | isChrome?: boolean 13 | isFirefox?: boolean 14 | isMacOs?: boolean 15 | isWindows?: boolean 16 | isIOS?: boolean 17 | isAndroid?: boolean 18 | isBrowser?: boolean 19 | isTouch?: boolean 20 | } 21 | 22 | function getDD() { 23 | const isTouchDevice = 24 | 'ontouchstart' in window || 25 | navigator.maxTouchPoints > 0 || 26 | // @ts-expect-error - this is a legacy property 27 | navigator.msMaxTouchPoints > 0 28 | 29 | const isIpadPro = ReactDeviceDetect.isDesktop && ReactDeviceDetect.isSafari && isTouchDevice 30 | 31 | return { 32 | isDesktop: ReactDeviceDetect.isDesktop && !isIpadPro, 33 | isMobile: ReactDeviceDetect.isMobile || isIpadPro, 34 | isMobileOnly: ReactDeviceDetect.isMobileOnly, 35 | isMobileSafari: ReactDeviceDetect.isMobileSafari, 36 | isTablet: ReactDeviceDetect.isTablet || isIpadPro, 37 | isChrome: ReactDeviceDetect.isChrome, 38 | isFirefox: ReactDeviceDetect.isFirefox, 39 | isSafari: ReactDeviceDetect.isSafari, 40 | isMacOs: ReactDeviceDetect.isMacOs, 41 | isWindows: ReactDeviceDetect.isWindows, 42 | isIOS: ReactDeviceDetect.isIOS, 43 | isAndroid: ReactDeviceDetect.isAndroid, 44 | isBrowser: ReactDeviceDetect.isBrowser, 45 | isTouch: isTouchDevice, 46 | } 47 | } 48 | 49 | export const useDeviceDetect = (): DD => { 50 | const isHydrated = useHydrated() 51 | 52 | if (!isHydrated) { 53 | return {} 54 | } 55 | 56 | return getDD() 57 | } 58 | -------------------------------------------------------------------------------- /templates/demo/app/hooks/use-hydrated.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | let hydrated = false 4 | 5 | export const useHydrated = () => { 6 | const [, setIsHydrated] = useState(false); 7 | 8 | useEffect(() => { 9 | if (!hydrated) { 10 | hydrated = true; 11 | setIsHydrated(true); 12 | } 13 | }, []); 14 | 15 | return hydrated; 16 | }; 17 | -------------------------------------------------------------------------------- /templates/demo/app/lib/gsap/index.ts: -------------------------------------------------------------------------------- 1 | import gsap from 'gsap' 2 | 3 | import { useGSAP } from '@gsap/react' 4 | import { isClient } from '@/utils/constants' 5 | 6 | if (isClient) { 7 | gsap.registerPlugin(useGSAP) 8 | } 9 | 10 | export { gsap } 11 | 12 | export const promisifyGsap = (tl: GSAPTimeline) => { 13 | return new Promise((resolve) => { 14 | tl.then(() => resolve()) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /templates/demo/app/lib/utils/breakpoints.ts: -------------------------------------------------------------------------------- 1 | export type BreakpointMin = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' 2 | export type BreakpointMax = 'max-sm' | 'max-md' | 'max-lg' | 'max-xl' | 'max-2xl' | 'max-3xl' 3 | export type Breakpoint = BreakpointMin | BreakpointMax 4 | 5 | export const breakpoints = { 6 | sm: { min: 640, max: 767 }, 7 | md: { min: 768, max: 1023 }, 8 | lg: { min: 1024, max: 1279 }, 9 | xl: { min: 1280, max: 1535 }, 10 | '2xl': { min: 1536, max: 1919 }, 11 | '3xl': { min: 1920, max: Infinity }, 12 | } 13 | 14 | export const query: Record = { 15 | sm: `(min-width: ${breakpoints.sm.min}px)`, 16 | md: `(min-width: ${breakpoints.md.min}px)`, 17 | lg: `(min-width: ${breakpoints.lg.min}px)`, 18 | xl: `(min-width: ${breakpoints.xl.min}px)`, 19 | '2xl': `(min-width: ${breakpoints['2xl'].min}px)`, 20 | '3xl': `(min-width: ${breakpoints['3xl'].min}px)`, 21 | 'max-sm': `(max-width: ${breakpoints.sm.max}px)`, 22 | 'max-md': `(max-width: ${breakpoints.md.max}px)`, 23 | 'max-lg': `(max-width: ${breakpoints.lg.max}px)`, 24 | 'max-xl': `(max-width: ${breakpoints.xl.max}px)`, 25 | 'max-2xl': `(max-width: ${breakpoints['2xl'].max}px)`, 26 | 'max-3xl': `(max-width: ${breakpoints['3xl'].max}px)`, 27 | } 28 | 29 | export const getCurrBreakpoint = (max?: boolean): Breakpoint | 'base' => { 30 | const breakpointEntries = Object.entries(breakpoints).reverse() 31 | for (const [breakpoint, { min: minValue, max: maxValue }] of breakpointEntries) 32 | if (max) { 33 | if (window.innerWidth <= maxValue) return `max-${breakpoint}` as BreakpointMax 34 | } else if (window.innerWidth >= minValue) return breakpoint as BreakpointMin 35 | 36 | return 'sm' 37 | } 38 | -------------------------------------------------------------------------------- /templates/demo/app/lib/utils/cn.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export default function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /templates/demo/app/lib/utils/constants.ts: -------------------------------------------------------------------------------- 1 | import { prependProtocol } from '../utils' 2 | 3 | export const isServer = typeof window === 'undefined' 4 | 5 | export const isClient = typeof window !== 'undefined' 6 | 7 | export const isDevelopment = import.meta.env.NODE_ENV === 'development' 8 | 9 | const base_url = 10 | import.meta.env.VITE_VERCEL_PROJECT_PRODUCTION_URL || import.meta.env.VITE_VERCEL_URL || import.meta.env.VITE_SITE_URL 11 | 12 | if (!base_url) { 13 | throw new Error('VITE_SITE_URL is not set') 14 | } 15 | 16 | export const SITE_URL = prependProtocol(base_url) 17 | export const WATERMARK = ` 18 | .;5####57.. 19 | .5#########;. 20 | ;########### 21 | ;###########. 22 | .;#######N5. 23 | .;;;.. .;75557.. .;;;. 24 | .5######; .;######5. 25 | #########; ;######### 26 | ##########.. ..########## 27 | ;##########; ;##########; 28 | .7##########5;. .;5#########N7 29 | .7############7;.. .;7#N##########7. 30 | ;###############5577777755#############N#;. 31 | .7####################################7. 32 | ..;5#N############################5;. 33 | .;7########################7;.. 34 | .;;755##########557;;... 35 | 36 | Made by joyco.studio ` 37 | -------------------------------------------------------------------------------- /templates/demo/app/lib/utils/image.ts: -------------------------------------------------------------------------------- 1 | import { query } from './breakpoints' 2 | 3 | import type { Breakpoint } from './breakpoints' 4 | 5 | export type GetImageSizesArg = (Partial> & { default?: string }) | string 6 | 7 | export const getImageSizes = (sizes: GetImageSizesArg) => { 8 | if (!sizes) return '' 9 | 10 | if (typeof sizes === 'string') return sizes 11 | 12 | return Object.entries(sizes ?? {}) 13 | .map(([breakpoint, size]) => { 14 | if (breakpoint === 'default') return size 15 | return `${query[breakpoint as Breakpoint]} ${size}` 16 | }) 17 | .join(', ') 18 | } 19 | -------------------------------------------------------------------------------- /templates/demo/app/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | export const noop = () => { } 2 | 3 | export const prependProtocol = (url: string) => { 4 | if (url.startsWith('http://') || url.startsWith('https://')) { 5 | return url 6 | } 7 | return `https://${url}` 8 | } 9 | -------------------------------------------------------------------------------- /templates/demo/app/lib/utils/links.ts: -------------------------------------------------------------------------------- 1 | import type { LinkDescriptors } from 'react-router/route-module' 2 | 3 | type CrossOrigin = 'anonymous' | 'use-credentials' | undefined 4 | type As = 'script' | 'style' | 'font' | 'image' | 'fetch' | 'worker' | 'document' | 'audio' | 'video' 5 | 6 | type LinksConfig = { 7 | stylesheets: string[] 8 | /** 9 | * Use https://realfavicongenerator.net/ to generate a complete favicon set 10 | */ 11 | favicon: { 12 | '32x32': string 13 | '16x16': string 14 | 'apple-touch-icon'?: string 15 | } 16 | manifest?: string 17 | preconnect?: { href: string; crossOrigin?: CrossOrigin }[] 18 | preload?: { 19 | href: string 20 | as?: As 21 | type?: string 22 | crossOrigin?: CrossOrigin 23 | }[] 24 | } 25 | 26 | /** 27 | * Generate head tags for Remix. 28 | * 29 | * @param links - Links configuration 30 | * @param extra - Extra links 31 | * @returns Remix links 32 | */ 33 | export const generateLinks = (links: LinksConfig, extra: LinkDescriptors = []): LinkDescriptors => { 34 | const _links: LinkDescriptors = [] 35 | 36 | if (links.stylesheets) { 37 | _links.push(...links.stylesheets.map((stylesheet) => ({ rel: 'stylesheet', href: stylesheet }))) 38 | } 39 | 40 | if (links.favicon) { 41 | _links.push( 42 | { rel: 'icon', type: 'image/png', sizes: '32x32', href: links.favicon['32x32'] }, 43 | { rel: 'icon', type: 'image/png', sizes: '16x16', href: links.favicon['16x16'] } 44 | ) 45 | 46 | if (links.favicon['apple-touch-icon']) { 47 | _links.push({ rel: 'apple-touch-icon', href: links.favicon['apple-touch-icon'] }) 48 | } 49 | } 50 | 51 | if (links.manifest) { 52 | _links.push({ rel: 'manifest', href: links.manifest }) 53 | } 54 | 55 | if (links.preconnect) { 56 | _links.push(...links.preconnect.map(({ href, crossOrigin }) => ({ rel: 'preconnect', href, crossOrigin }))) 57 | } 58 | 59 | if (links.preload) { 60 | _links.push( 61 | ...links.preload.map(({ href, as, type, crossOrigin }) => ({ rel: 'preload', href, as, type, crossOrigin })) 62 | ) 63 | } 64 | 65 | return [..._links, ...extra] 66 | } 67 | -------------------------------------------------------------------------------- /templates/demo/app/lib/utils/meta.ts: -------------------------------------------------------------------------------- 1 | import type { MetaDescriptor } from 'react-router' 2 | 3 | type MetaImage = { 4 | url: string 5 | /** 6 | * Recommended: 1200px 7 | */ 8 | width: number 9 | /** 10 | * Recommended: 630px 11 | */ 12 | height: number 13 | type: 'image/png' | 'image/jpeg' | 'image/jpg' | 'image/webp' 14 | } 15 | 16 | type MetaConfigBase = { 17 | title: string 18 | description: string 19 | url: string 20 | siteName: string 21 | image: MetaImage 22 | twitter?: { 23 | card?: 'summary' | 'summary_large_image' 24 | title?: string 25 | description?: string 26 | creator?: string 27 | site?: string 28 | image?: MetaImage 29 | } 30 | } 31 | 32 | type MetaConfig = 33 | | (MetaConfigBase & { 34 | strict?: true 35 | }) 36 | | (Partial & { 37 | strict?: false 38 | }) 39 | 40 | /** 41 | * Generate meta tags for Remix. It also runs dedupe and purge for duplicate and empty meta tags. 42 | * 43 | * @param structuredMeta - Meta configuration 44 | * @param extra - Extra meta tags 45 | * @returns Remix meta tags 46 | */ 47 | export const generateMeta = (structuredMeta: MetaConfig, extra?: MetaDescriptor[]): MetaDescriptor[] => { 48 | const _meta: MetaDescriptor[] = [] 49 | 50 | const dedupeAndPurge = (meta: MetaDescriptor[]) => { 51 | const deduped = new Map() 52 | meta.forEach((m) => { 53 | if ('name' in m && m.content !== undefined) { 54 | deduped.set(m.name as string, m) 55 | } else if ('property' in m && m.content !== undefined) { 56 | deduped.set(m.property as string, m) 57 | } else { 58 | deduped.set(Object.keys(m)[0] as string, m) 59 | } 60 | }) 61 | return Array.from(deduped.values()) 62 | } 63 | 64 | const { title, description, url, siteName, twitter, image } = structuredMeta 65 | 66 | /* base */ 67 | _meta.push({ title }, { name: 'description', content: description }) 68 | 69 | /* og */ 70 | _meta.push( 71 | { property: 'og:title', content: title }, 72 | { property: 'og:description', content: description }, 73 | { property: 'og:url', content: url }, 74 | { property: 'og:site_name', content: siteName }, 75 | { property: 'og:image', content: structuredMeta.image?.url }, 76 | { property: 'og:image:width', content: structuredMeta.image?.width.toString() }, 77 | { property: 'og:image:height', content: structuredMeta.image?.height.toString() }, 78 | { property: 'og:image:type', content: structuredMeta.image?.type } 79 | ) 80 | 81 | /* twitter */ 82 | _meta.push( 83 | { name: 'twitter:card', content: twitter?.card || 'summary_large_image' }, 84 | { name: 'twitter:title', content: twitter?.title || title }, 85 | { name: 'twitter:description', content: twitter?.description || description }, 86 | { name: 'twitter:creator', content: twitter?.creator }, 87 | { name: 'twitter:site', content: twitter?.site } 88 | ) 89 | _meta.push( 90 | { name: 'twitter:image', content: twitter?.image?.url || image?.url }, 91 | { name: 'twitter:image:width', content: twitter?.image?.width?.toString() || image?.width?.toString() }, 92 | { name: 'twitter:image:height', content: twitter?.image?.height?.toString() || image?.height?.toString() }, 93 | { name: 'twitter:image:type', content: twitter?.image?.type || image?.type } 94 | ) 95 | 96 | return dedupeAndPurge([..._meta, ...(extra || [])]) 97 | } 98 | 99 | export const mergeMeta = (parentMeta: MetaDescriptor[], metaTags: MetaDescriptor[]) => { 100 | const merged = new Map() 101 | 102 | const getMetaKey = (meta: MetaDescriptor) => { 103 | if ('name' in meta) return `name:${meta.name}` 104 | if ('property' in meta) return `property:${meta.property}` 105 | return Object.keys(meta)[0] 106 | } 107 | 108 | parentMeta.forEach((meta) => { 109 | merged.set(getMetaKey(meta), meta) 110 | }) 111 | 112 | metaTags.forEach((meta) => { 113 | merged.set(getMetaKey(meta), meta) 114 | }) 115 | 116 | return Array.from(merged.values()) 117 | } 118 | -------------------------------------------------------------------------------- /templates/demo/app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | isRouteErrorResponse, 3 | Links, 4 | Meta, 5 | Scripts, 6 | ScrollRestoration, 7 | useLocation, 8 | useOutlet, 9 | type MetaFunction, 10 | } from 'react-router' 11 | 12 | import type { Route } from './+types/root' 13 | import stylesheet from './app.css?url' 14 | import { RouteTransitionManager } from '@joycostudio/transitions' 15 | import routes from './routes' 16 | import { promisifyGsap } from './lib/gsap' 17 | import gsap from 'gsap' 18 | import { Header } from './components/header' 19 | import Footer from './components/footer' 20 | import { SITE_URL, WATERMARK } from './lib/utils/constants' 21 | import { generateMeta } from './lib/utils/meta' 22 | import { generateLinks } from './lib/utils/links' 23 | import { nanoid } from 'nanoid' 24 | 25 | const split = (node: HTMLElement) => { 26 | const text = node.textContent || '' 27 | node.textContent = '' 28 | const charSpans = Array.from(text).map((char) => { 29 | const span = document.createElement('span') 30 | // span.style.display = 'inline-block' 31 | span.textContent = char 32 | node.appendChild(span) 33 | return span 34 | }) 35 | return charSpans 36 | } 37 | 38 | export const links: Route.LinksFunction = () => 39 | generateLinks({ 40 | stylesheets: [stylesheet, 'https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700&display=swap'], 41 | favicon: { 42 | '32x32': '/favicon-32x32.png', 43 | '16x16': '/favicon-16x16.png', 44 | 'apple-touch-icon': '/apple-touch-icon.png', 45 | }, 46 | manifest: '/site.webmanifest', 47 | preconnect: [ 48 | { href: 'https://fonts.googleapis.com' }, 49 | { href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' }, 50 | ], 51 | preload: [ 52 | { 53 | href: 'https://fonts.gstatic.com/s/barlowcondensed/v12/HTxwL3I-JCGChYJ8VI-L6OO_au7B46r2z3bWuYMBYro.woff2', 54 | as: 'font', 55 | type: 'font/woff2', 56 | crossOrigin: 'anonymous', 57 | }, 58 | ], 59 | }) 60 | 61 | export const loader = () => { 62 | const mediaLinks = [ 63 | { label: 'x', link: 'https://x.com/joyco_studio' }, 64 | { label: 'github', link: 'https://github.com/joyco-studio/transitions' }, 65 | ] 66 | return { rebelLog: WATERMARK, mediaLinks } 67 | } 68 | 69 | export const meta: MetaFunction = () => { 70 | const meta = generateMeta({ 71 | strict: true, 72 | title: 'JOYCO | Transitions', 73 | description: 'Plug & Play page transitions for React Router.', 74 | url: SITE_URL, 75 | siteName: 'JOYCO | Transitions', 76 | image: { url: `${SITE_URL}/opengraph-image.png`, width: 1200, height: 630, type: 'image/png' }, 77 | }) 78 | 79 | return meta 80 | } 81 | 82 | export function Layout({ children }: { children: React.ReactNode }) { 83 | return ( 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 |
93 | {children} 94 |