├── .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 Transitions
2 |
3 | 
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 RRv7
2 |
3 | 
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 |

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 |
95 |
96 |
97 |
98 |
99 | )
100 | }
101 |
102 | export default function App() {
103 | const element = useOutlet()
104 |
105 | const location = useLocation()
106 |
107 | return (
108 | {
113 | if (to === '/prevent-transition') {
114 | return true
115 | }
116 |
117 | return false
118 | }}
119 | onEnter={(node, from, to) => {
120 | console.log('[onEnter]', { from, to })
121 |
122 | return promisifyGsap(
123 | gsap
124 | .timeline({
125 | onComplete: () => {
126 | gsap.set(node, { clearProps: 'all' })
127 | },
128 | })
129 | .fromTo(node, { opacity: 0 }, { opacity: 1, duration: 1 })
130 | )
131 | }}
132 | onExit={(node, from, to) => {
133 | console.log('[onExit]', { from, to })
134 |
135 | const animateElements = node.querySelectorAll('[data-animate]')
136 |
137 | const groupedChunks: { [key: string]: HTMLSpanElement[] } = {}
138 |
139 | animateElements.forEach((element) => {
140 | let dataAnimate = element.getAttribute('data-animate')
141 | const dataSplit = element.getAttribute('data-split') === 'true'
142 |
143 | if (dataAnimate === 'true') {
144 | dataAnimate = nanoid(10)
145 | }
146 |
147 | if (dataAnimate) {
148 | if (!groupedChunks[dataAnimate]) {
149 | groupedChunks[dataAnimate] = []
150 | }
151 |
152 | if (dataSplit) {
153 | groupedChunks[dataAnimate].push(...split(element))
154 | } else {
155 | groupedChunks[dataAnimate].push(element)
156 | }
157 | }
158 | })
159 |
160 | const tl = gsap.timeline()
161 | const factor = 0.5
162 |
163 | Object.values(groupedChunks).forEach((chunks, idx) => {
164 | tl.fromTo(
165 | chunks,
166 | { opacity: 1 },
167 | {
168 | opacity: 0,
169 | duration: 0.7 * factor,
170 | ease: 'sine.out',
171 | stagger: {
172 | each: 0.1 * factor,
173 | ease: 'none',
174 | },
175 | },
176 | idx * 0.5
177 | )
178 | })
179 |
180 | tl.fromTo(node, { opacity: 1 }, { opacity: 0, duration: 0.5 }, `>-=${0.2 * factor}`)
181 |
182 | return promisifyGsap(tl)
183 | }}
184 | >
185 | {(ref) => (
186 |
192 | {element}
193 |
194 | )}
195 |
196 | )
197 | }
198 |
199 | export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
200 | let message = 'Oops!'
201 | let details = 'An unexpected error occurred.'
202 | let stack: string | undefined
203 |
204 | if (isRouteErrorResponse(error)) {
205 | message = error.status === 404 ? '404' : 'Error'
206 | details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details
207 | } else if (import.meta.env.DEV && error && error instanceof Error) {
208 | details = error.message
209 | stack = error.stack
210 | }
211 |
212 | return (
213 |
214 | {message}
215 | {details}
216 | {stack && (
217 |
218 | {stack}
219 |
220 | )}
221 |
222 | )
223 | }
224 |
--------------------------------------------------------------------------------
/templates/demo/app/routes.ts:
--------------------------------------------------------------------------------
1 | import { type RouteConfig, index, route } from '@react-router/dev/routes'
2 |
3 | export default [
4 | index('routes/home.tsx'),
5 | route('/about', 'routes/about.tsx'),
6 | route('/prevent-transition', 'routes/prevent-transition.tsx'),
7 | ] satisfies RouteConfig
8 |
--------------------------------------------------------------------------------
/templates/demo/app/routes/about.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router'
2 | import cn from '@/lib/utils/cn'
3 |
4 | const Paragraph = ({ children, className }: { children: React.ReactNode; className?: string }) => {
5 | return (
6 |
12 | {children}
13 |
14 | )
15 | }
16 |
17 | export default function About() {
18 | return (
19 |
20 |
21 |
22 | ABOUT
23 |
24 |
25 |
26 | Hey!
27 | {' '}
28 |
29 | - You are not supposed to be here.
30 | {' '}
31 |
32 | Go back to the homepage!
33 |
34 |
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/templates/demo/app/routes/home.tsx:
--------------------------------------------------------------------------------
1 | import { Paragraph } from '@/components/paragraph'
2 | import { Link } from 'react-router'
3 |
4 | export default function Home() {
5 | return (
6 |
7 |
8 |
9 | HOMEPAGE
10 |
11 |
12 |
13 | What are you waiting for? -
14 | {' '}
15 |
16 | Change the page now!
17 |
18 |
19 |
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/templates/demo/app/routes/prevent-transition.tsx:
--------------------------------------------------------------------------------
1 | import { Paragraph } from '@/components/paragraph'
2 | import { useNavigate } from 'react-router'
3 |
4 | export default function PreventTransition() {
5 | const navigate = useNavigate()
6 |
7 | return (
8 |
9 |
10 |
11 | PREVENT TRANSITION PAGE
12 |
13 |
14 |
15 | You can prevent transitions too!
16 | {' '}
17 |
20 |
21 |
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/templates/demo/dev-server.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import express from 'express'
3 |
4 | const PORT = Number.parseInt(process.env.PORT || '3000')
5 |
6 | const app = express()
7 | app.disable('x-powered-by')
8 |
9 | console.log('Starting development server')
10 | const viteDevServer = await import('vite').then((vite) =>
11 | vite.createServer({
12 | server: { middlewareMode: true },
13 | })
14 | )
15 | app.use(viteDevServer.middlewares)
16 | app.use(async (req, res, next) => {
17 | try {
18 | const source = await viteDevServer.ssrLoadModule('./server/app.ts')
19 | return await source.default(req, res, next)
20 | } catch (error) {
21 | if (typeof error === 'object' && error instanceof Error) {
22 | viteDevServer.ssrFixStacktrace(error)
23 | }
24 | next(error)
25 | }
26 | })
27 |
28 | app.listen(PORT, () => {
29 | console.log(`Server is running on http://localhost:${PORT}`)
30 | })
31 |
--------------------------------------------------------------------------------
/templates/demo/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 | {
11 | ignores: ['**/.*/', '**/vercel/', '**/node_modules/', '**/build/'],
12 | },
13 | {
14 | files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'],
15 | },
16 | { languageOptions: { globals: { ...globals.browser, ...globals.node } } },
17 | pluginJs.configs.recommended,
18 | tseslint.configs.recommended,
19 | pluginReact.configs.flat['jsx-runtime'],
20 | {
21 | plugins: {
22 | 'react-compiler': reactCompiler,
23 | prettier: eslintPluginPrettier,
24 | },
25 | rules: {
26 | 'react-compiler/react-compiler': 'warn',
27 | '@typescript-eslint/no-unused-vars': 'warn',
28 | 'prettier/prettier': 'warn',
29 | 'no-console': 'warn',
30 | },
31 | },
32 | prettier,
33 | ])
34 |
--------------------------------------------------------------------------------
/templates/demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@templates/demo",
3 | "private": true,
4 | "type": "module",
5 | "scripts": {
6 | "build": "cd ../.. && pnpm build && cd templates/demo && react-router build && node vercel/prepare.js",
7 | "dev": "cross-env NODE_ENV=development node ./dev-server.js",
8 | "typecheck": "react-router typegen && tsc",
9 | "lint": "eslint -c ./eslint.config.mjs . --fix --no-cache"
10 | },
11 | "dependencies": {
12 | "@gsap/react": "^2.1.2",
13 | "@joycostudio/transitions": "workspace:^",
14 | "@react-router/express": "^7.1.1",
15 | "@react-router/node": "^7.1.1",
16 | "@vercel/node": "^3.2.25",
17 | "clsx": "^2.1.1",
18 | "express": "^4.21.1",
19 | "gsap": "^3.12.7",
20 | "isbot": "^5.1.17",
21 | "nanoid": "^5.0.9",
22 | "react": "^19.0.0",
23 | "react-device-detect": "^2.2.3",
24 | "react-dom": "^19.0.0",
25 | "react-router": "^7.1.3",
26 | "tailwind-merge": "^2.6.0"
27 | },
28 | "devDependencies": {
29 | "@eslint/js": "^9.18.0",
30 | "@react-router/dev": "^7.1.1",
31 | "@types/express": "^5.0.0",
32 | "@types/node": "^20",
33 | "@types/react": "^19.0.1",
34 | "@types/react-dom": "^19.0.1",
35 | "autoprefixer": "^10.4.20",
36 | "babel-plugin-react-compiler": "19.0.0-beta-e552027-20250112",
37 | "cross-env": "^7.0.3",
38 | "eslint": "^9.18.0",
39 | "eslint-config-prettier": "^10.0.1",
40 | "eslint-plugin-prettier": "^5.2.1",
41 | "eslint-plugin-react": "^7.37.4",
42 | "eslint-plugin-react-compiler": "19.0.0-beta-e552027-20250112",
43 | "globals": "^15.14.0",
44 | "postcss": "^8.4.49",
45 | "prettier": "^3.4.2",
46 | "tailwindcss": "^3.4.16",
47 | "typescript": "^5.7.2",
48 | "typescript-eslint": "^8.21.0",
49 | "vite": "^5.4.11",
50 | "vite-plugin-babel": "^1.3.0",
51 | "vite-tsconfig-paths": "^5.1.4"
52 | }
53 | }
--------------------------------------------------------------------------------
/templates/demo/public/JOYCO.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joyco-studio/transitions/3b7ebcd6cbc65b887ffb124e48748dbeea5889f1/templates/demo/public/JOYCO.png
--------------------------------------------------------------------------------
/templates/demo/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joyco-studio/transitions/3b7ebcd6cbc65b887ffb124e48748dbeea5889f1/templates/demo/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/templates/demo/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joyco-studio/transitions/3b7ebcd6cbc65b887ffb124e48748dbeea5889f1/templates/demo/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/templates/demo/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joyco-studio/transitions/3b7ebcd6cbc65b887ffb124e48748dbeea5889f1/templates/demo/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/templates/demo/public/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joyco-studio/transitions/3b7ebcd6cbc65b887ffb124e48748dbeea5889f1/templates/demo/public/banner.png
--------------------------------------------------------------------------------
/templates/demo/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joyco-studio/transitions/3b7ebcd6cbc65b887ffb124e48748dbeea5889f1/templates/demo/public/favicon-16x16.png
--------------------------------------------------------------------------------
/templates/demo/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joyco-studio/transitions/3b7ebcd6cbc65b887ffb124e48748dbeea5889f1/templates/demo/public/favicon-32x32.png
--------------------------------------------------------------------------------
/templates/demo/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joyco-studio/transitions/3b7ebcd6cbc65b887ffb124e48748dbeea5889f1/templates/demo/public/favicon.ico
--------------------------------------------------------------------------------
/templates/demo/public/logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/templates/demo/public/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joyco-studio/transitions/3b7ebcd6cbc65b887ffb124e48748dbeea5889f1/templates/demo/public/opengraph-image.png
--------------------------------------------------------------------------------
/templates/demo/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
--------------------------------------------------------------------------------
/templates/demo/react-router.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from '@react-router/dev/config'
2 |
3 | export default {
4 | // Config options...
5 | // Server-side render by default, to enable SPA mode set this to `false`
6 | ssr: true,
7 | } satisfies Config
8 |
--------------------------------------------------------------------------------
/templates/demo/server/app.ts:
--------------------------------------------------------------------------------
1 | import { createRequestHandler } from '@react-router/express'
2 | import express from 'express'
3 | import 'react-router'
4 |
5 | declare module 'react-router' {
6 | export interface AppLoadContext {
7 | VALUE_FROM_VERCEL: string
8 | }
9 | }
10 |
11 | const app = express()
12 |
13 | app.use(
14 | createRequestHandler({
15 | // @ts-expect-error - virtual module provided by React Router at build time
16 | build: () => import('virtual:react-router/server-build'),
17 | getLoadContext() {
18 | return {
19 | VALUE_FROM_VERCEL: 'Hello from Vercel',
20 | }
21 | },
22 | })
23 | )
24 |
25 | export default app
26 |
--------------------------------------------------------------------------------
/templates/demo/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss'
2 |
3 | export default {
4 | content: ['./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}'],
5 | theme: {
6 | extend: {
7 | colors: {
8 | primary: '#FAFAFA',
9 | background: '#181617',
10 | accent: '#0344DC',
11 | },
12 | fontFamily: {
13 | sans: [
14 | '"Barlow Condensed"',
15 | 'ui-sans-serif',
16 | 'system-ui',
17 | 'sans-serif',
18 | '"Apple Color Emoji"',
19 | '"Segoe UI Emoji"',
20 | '"Segoe UI Symbol"',
21 | '"Noto Color Emoji"',
22 | ],
23 | },
24 | spacing: {
25 | header: 'var(--header-height)',
26 | },
27 | keyframes: {
28 | 'move-stripes': {
29 | '0%': { backgroundPosition: '0 0' },
30 | '100%': { backgroundPosition: '-80px 0' },
31 | },
32 | },
33 | animation: {
34 | 'move-stripes': 'move-stripes 2s linear infinite',
35 | },
36 | },
37 | },
38 | plugins: [],
39 | } satisfies Config
40 |
--------------------------------------------------------------------------------
/templates/demo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
5 | "types": ["vite/client"],
6 | "target": "ES2022",
7 | "module": "ES2022",
8 | "moduleResolution": "bundler",
9 | "jsx": "react-jsx",
10 | "rootDirs": [".", "./.react-router/types"],
11 | "baseUrl": ".",
12 | "paths": {
13 | "@/*": ["./app/*"],
14 | "@/components/*": ["./app/components/*"],
15 | "@/lib/*": ["./app/lib/*"],
16 | "@/hooks/*": ["./app/hooks/*"],
17 | "@/utils/*": ["app/lib/utils/*"]
18 | },
19 | "esModuleInterop": true,
20 | "verbatimModuleSyntax": true,
21 | "noEmit": true,
22 | "resolveJsonModule": true,
23 | "skipLibCheck": true,
24 | "strict": true
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/templates/demo/vercel/output/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 3,
3 | "routes": [
4 | {
5 | "src": "/(.*\\.(png|jpg|jpeg|gif|webp|ico|css|js|woff|woff2|ttf|svg|webmanifest|xml|txt))",
6 | "dest": "/$1"
7 | },
8 | {
9 | "src": "/(.*)",
10 | "dest": "/"
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/templates/demo/vercel/output/functions/index.func/.vc-config.json:
--------------------------------------------------------------------------------
1 | {
2 | "runtime": "nodejs20.x",
3 | "handler": "index.js",
4 | "launcherType": "Nodejs",
5 | "shouldAddHelpers": true
6 | }
7 |
--------------------------------------------------------------------------------
/templates/demo/vercel/output/functions/index.func/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module"
3 | }
4 |
--------------------------------------------------------------------------------
/templates/demo/vercel/prepare.js:
--------------------------------------------------------------------------------
1 | import * as fsp from 'node:fs/promises'
2 |
3 | await fsp.rm('.vercel', { recursive: true }).catch(() => {})
4 | await fsp.mkdir('.vercel/output/static', { recursive: true })
5 |
6 | await fsp.cp('vercel/output/', '.vercel/output', { recursive: true })
7 | await fsp.cp('build/client/', '.vercel/output/static', { recursive: true })
8 | await fsp.cp('build/server/', '.vercel/output/functions/index.func', {
9 | recursive: true,
10 | })
11 |
--------------------------------------------------------------------------------
/templates/demo/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { reactRouter } from '@react-router/dev/vite'
2 | import autoprefixer from 'autoprefixer'
3 | import tailwindcss from 'tailwindcss'
4 | import { defineConfig } from 'vite'
5 | import tsconfigPaths from 'vite-tsconfig-paths'
6 | import babel from 'vite-plugin-babel'
7 |
8 | const ReactCompilerConfig = {}
9 |
10 | export default defineConfig(({ isSsrBuild, command }) => ({
11 | server: {
12 | port: 3000,
13 | },
14 | build: {
15 | rollupOptions: isSsrBuild
16 | ? {
17 | input: './server/app.ts',
18 | }
19 | : undefined,
20 | },
21 | css: {
22 | postcss: {
23 | plugins: [tailwindcss, autoprefixer],
24 | },
25 | },
26 | ssr: {
27 | // add here libraries such as basehub, tempus, lenis
28 | noExternal: command === 'build' ? true : ['gsap'],
29 | },
30 | plugins: [
31 | reactRouter(),
32 | babel({
33 | filter: /\.[jt]sx?$/,
34 | babelConfig: {
35 | presets: ['@babel/preset-typescript'], // if you use TypeScript
36 | plugins: [['babel-plugin-react-compiler', ReactCompilerConfig]],
37 | },
38 | }),
39 | tsconfigPaths(),
40 | ],
41 | }))
42 |
--------------------------------------------------------------------------------
/templates/react-router/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /node_modules/
3 |
4 | # React Router
5 | /.react-router/
6 | /build/
7 |
--------------------------------------------------------------------------------
/templates/react-router/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to React Router!
2 |
3 | A modern, production-ready template for building full-stack React applications using React Router.
4 |
5 | [](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
6 |
7 | ## Features
8 |
9 | - 🚀 Server-side rendering
10 | - ⚡️ Hot Module Replacement (HMR)
11 | - 📦 Asset bundling and optimization
12 | - 🔄 Data loading and mutations
13 | - 🔒 TypeScript by default
14 | - 🎉 TailwindCSS for styling
15 | - 📖 [React Router docs](https://reactrouter.com/)
16 |
17 | ## Getting Started
18 |
19 | ### Installation
20 |
21 | Install the dependencies:
22 |
23 | ```bash
24 | npm install
25 | ```
26 |
27 | ### Development
28 |
29 | Start the development server with HMR:
30 |
31 | ```bash
32 | npm run dev
33 | ```
34 |
35 | Your application will be available at `http://localhost:5173`.
36 |
37 | ## Building for Production
38 |
39 | Create a production build:
40 |
41 | ```bash
42 | npm run build
43 | ```
44 |
45 | ## Deployment
46 |
47 | ### Docker Deployment
48 |
49 | This template includes three Dockerfiles optimized for different package managers:
50 |
51 | - `Dockerfile` - for npm
52 | - `Dockerfile.pnpm` - for pnpm
53 | - `Dockerfile.bun` - for bun
54 |
55 | To build and run using Docker:
56 |
57 | ```bash
58 | # For npm
59 | docker build -t my-app .
60 |
61 | # For pnpm
62 | docker build -f Dockerfile.pnpm -t my-app .
63 |
64 | # For bun
65 | docker build -f Dockerfile.bun -t my-app .
66 |
67 | # Run the container
68 | docker run -p 3000:3000 my-app
69 | ```
70 |
71 | The containerized application can be deployed to any platform that supports Docker, including:
72 |
73 | - AWS ECS
74 | - Google Cloud Run
75 | - Azure Container Apps
76 | - Digital Ocean App Platform
77 | - Fly.io
78 | - Railway
79 |
80 | ### DIY Deployment
81 |
82 | If you're familiar with deploying Node applications, the built-in app server is production-ready.
83 |
84 | Make sure to deploy the output of `npm run build`
85 |
86 | ```
87 | ├── package.json
88 | ├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
89 | ├── build/
90 | │ ├── client/ # Static assets
91 | │ └── server/ # Server-side code
92 | ```
93 |
94 | ## Styling
95 |
96 | This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
97 |
98 | ---
99 |
100 | Built with ❤️ using React Router.
101 |
--------------------------------------------------------------------------------
/templates/react-router/app/app.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html,
6 | body {
7 | @apply bg-white dark:bg-gray-950;
8 |
9 | @media (prefers-color-scheme: dark) {
10 | color-scheme: dark;
11 | }
12 | }
13 |
14 | .fade-in {
15 | animation: fade-in 1000ms ease-out forwards;
16 | }
17 |
18 | .fade-out {
19 | animation: fade-out 1000ms ease-out forwards;
20 | }
21 |
22 | /* Disable links during transitions */
23 | html:not([data-transition-state='idle']) a {
24 | pointer-events: none;
25 | color: red !important;
26 | }
27 |
28 | @keyframes fade-in {
29 | from {
30 | opacity: 0;
31 | }
32 | to {
33 | opacity: 1;
34 | }
35 | }
36 |
37 | @keyframes fade-out {
38 | from {
39 | opacity: 1;
40 | }
41 | to {
42 | opacity: 0;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/templates/react-router/app/components/navigation.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router'
2 |
3 | export const Navigation = () => {
4 | return (
5 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/templates/react-router/app/components/page.tsx:
--------------------------------------------------------------------------------
1 | export const Page = ({ name, children }: { name: string; children?: React.ReactNode }) => {
2 | return (
3 |
4 | /{name.toLowerCase()}
5 | {children}
6 |
7 | )
8 | }
9 |
--------------------------------------------------------------------------------
/templates/react-router/app/components/transition-state.tsx:
--------------------------------------------------------------------------------
1 | import { useTransitionState } from '@joycostudio/transitions'
2 |
3 | export const TransitionState = () => {
4 | const { state, isEntering, isExiting, isIdle } = useTransitionState()
5 | return (
6 |
7 |
State: {state}
8 |
Is Entering: {isEntering ? 'true' : 'false'}
9 |
Is Exiting: {isExiting ? 'true' : 'false'}
10 |
Is Idle: {isIdle ? 'true' : 'false'}
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/templates/react-router/app/root.tsx:
--------------------------------------------------------------------------------
1 | import { isRouteErrorResponse, Links, Meta, Scripts, ScrollRestoration, useLocation, useOutlet } from 'react-router'
2 |
3 | import type { Route } from './+types/root'
4 | import stylesheet from './app.css?url'
5 | import { DocumentTransitionState, RouteTransitionManager } from '@joycostudio/transitions'
6 | import routesConfig from './routes'
7 | import { Navigation } from '~/components/navigation'
8 | import { TransitionState } from './components/transition-state'
9 |
10 | export const links: Route.LinksFunction = () => [
11 | { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
12 | {
13 | rel: 'preconnect',
14 | href: 'https://fonts.gstatic.com',
15 | crossOrigin: 'anonymous',
16 | },
17 | {
18 | rel: 'stylesheet',
19 | href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap',
20 | },
21 | { rel: 'stylesheet', href: stylesheet },
22 | ]
23 |
24 | export function Layout({ children }: { children: React.ReactNode }) {
25 | return (
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | {children}
35 |
36 |
37 |
38 |
39 |
40 | )
41 | }
42 |
43 | export default function App() {
44 | const element = useOutlet()
45 |
46 | const location = useLocation()
47 |
48 | return (
49 | {
55 | return new Promise((resolve) => {
56 | console.log('enter', node, location.pathname)
57 | node.classList.add('fade-in')
58 | node.addEventListener('animationend', () => resolve(), { once: true })
59 | })
60 | },
61 | }}
62 | onExit={{
63 | default: (node) => {
64 | return new Promise((resolve) => {
65 | console.log('exit', node, location.pathname)
66 | node.classList.add('fade-out')
67 | node.addEventListener('animationend', () => resolve(), { once: true })
68 | })
69 | },
70 | }}
71 | >
72 | {/* @ts-expect-error - this args error is expected, just for internal usage */}
73 | {(nodeRef, _navigationHash) => (
74 | <>
75 |
76 |
77 |
83 | {element as React.ReactNode}
84 |
90 |
91 | >
92 | )}
93 |
94 | )
95 | }
96 |
97 | export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
98 | let message = 'Oops!'
99 | let details = 'An unexpected error occurred.'
100 | let stack: string | undefined
101 |
102 | if (isRouteErrorResponse(error)) {
103 | message = error.status === 404 ? '404' : 'Error'
104 | details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details
105 | } else if (import.meta.env.DEV && error && error instanceof Error) {
106 | details = error.message
107 | stack = error.stack
108 | }
109 |
110 | return (
111 |
112 | {message}
113 | {details}
114 | {stack && (
115 |
116 | {stack}
117 |
118 | )}
119 |
120 | )
121 | }
122 |
--------------------------------------------------------------------------------
/templates/react-router/app/routes.ts:
--------------------------------------------------------------------------------
1 | import { type RouteConfig, index, route } from '@react-router/dev/routes'
2 |
3 | export default [
4 | index('routes/home.tsx'),
5 | route('/about', 'routes/about.tsx'),
6 | route('/contact', 'routes/contact.tsx'),
7 | route('/projects', 'routes/projects/index.tsx'),
8 | route('/projects/:slug', 'routes/projects/[slug].tsx'),
9 | ] satisfies RouteConfig
10 |
--------------------------------------------------------------------------------
/templates/react-router/app/routes/about.tsx:
--------------------------------------------------------------------------------
1 | import type { MetaFunction } from 'react-router'
2 | import { Page } from '~/components/page'
3 |
4 | export const meta: MetaFunction = () => {
5 | return [{ title: 'About' }]
6 | }
7 |
8 | export default function About() {
9 | return
10 | }
11 |
--------------------------------------------------------------------------------
/templates/react-router/app/routes/contact.tsx:
--------------------------------------------------------------------------------
1 | import type { MetaFunction } from 'react-router'
2 | import { Page } from '~/components/page'
3 |
4 | export const meta: MetaFunction = () => {
5 | return [{ title: 'Contact' }]
6 | }
7 |
8 | export default function Contact() {
9 | return
10 | }
11 |
--------------------------------------------------------------------------------
/templates/react-router/app/routes/home.tsx:
--------------------------------------------------------------------------------
1 | import { Page } from '~/components/page'
2 | import type { MetaFunction } from 'react-router'
3 |
4 | export const meta: MetaFunction = () => {
5 | return [{ title: 'Home' }]
6 | }
7 |
8 | export default function Home() {
9 | return
10 | }
11 |
--------------------------------------------------------------------------------
/templates/react-router/app/routes/projects/[slug].tsx:
--------------------------------------------------------------------------------
1 | import { useParams, type MetaFunction } from 'react-router'
2 | import { Page } from '~/components/page'
3 |
4 | export const meta: MetaFunction = ({ params }) => {
5 | return [{ title: `Project ${params.slug}` }]
6 | }
7 |
8 | export default function Project() {
9 | const params = useParams()
10 | return
11 | }
12 |
--------------------------------------------------------------------------------
/templates/react-router/app/routes/projects/index.tsx:
--------------------------------------------------------------------------------
1 | import { Link, type MetaFunction } from 'react-router'
2 | import { Page } from '~/components/page'
3 |
4 | export const meta: MetaFunction = () => {
5 | return [{ title: `Projects` }]
6 | }
7 |
8 | export default function Projects() {
9 | return (
10 |
11 |
12 |
13 | Project 1: Portfolio Website
14 |
15 |
16 | Project 2: E-commerce Platform
17 |
18 |
19 | Project 3: Weather App
20 |
21 |
22 | Project 4: Task Management System
23 |
24 |
25 | Project 5: Social Media Dashboard
26 |
27 |
28 | Project ?: Not Found
29 |
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/templates/react-router/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@templates/react-router",
3 | "private": true,
4 | "type": "module",
5 | "engines": {
6 | "node": ">=20"
7 | },
8 | "scripts": {
9 | "build": "react-router build",
10 | "dev": "react-router dev",
11 | "start": "react-router-serve ./build/server/index.js",
12 | "typecheck": "react-router typegen && tsc"
13 | },
14 | "dependencies": {
15 | "@joycostudio/transitions": "workspace:^",
16 | "@react-router/node": "^7.1.3",
17 | "@react-router/serve": "^7.1.3",
18 | "isbot": "^5.1.17",
19 | "react": "^19.0.0",
20 | "react-dom": "^19.0.0",
21 | "react-router": "^7.1.3"
22 | },
23 | "devDependencies": {
24 | "@react-router/dev": "^7.1.3",
25 | "@types/node": "^20",
26 | "@types/react": "^19.0.1",
27 | "@types/react-dom": "^19.0.1",
28 | "autoprefixer": "^10.4.20",
29 | "postcss": "^8.4.49",
30 | "tailwindcss": "^3.4.16",
31 | "typescript": "^5.7.2",
32 | "vite": "^5.4.11",
33 | "vite-tsconfig-paths": "^5.1.4"
34 | },
35 | "changeset": {
36 | "ignore": true
37 | }
38 | }
--------------------------------------------------------------------------------
/templates/react-router/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joyco-studio/transitions/3b7ebcd6cbc65b887ffb124e48748dbeea5889f1/templates/react-router/public/favicon.ico
--------------------------------------------------------------------------------
/templates/react-router/react-router.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from '@react-router/dev/config'
2 |
3 | export default {
4 | // Config options...
5 | // Server-side render by default, to enable SPA mode set this to `false`
6 | ssr: true,
7 | } satisfies Config
8 |
--------------------------------------------------------------------------------
/templates/react-router/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss'
2 |
3 | export default {
4 | content: ['./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}'],
5 | theme: {
6 | extend: {
7 | fontFamily: {
8 | sans: [
9 | '"Inter"',
10 | 'ui-sans-serif',
11 | 'system-ui',
12 | 'sans-serif',
13 | '"Apple Color Emoji"',
14 | '"Segoe UI Emoji"',
15 | '"Segoe UI Symbol"',
16 | '"Noto Color Emoji"',
17 | ],
18 | },
19 | },
20 | },
21 | plugins: [],
22 | } satisfies Config
23 |
--------------------------------------------------------------------------------
/templates/react-router/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "**/*",
4 | "**/.server/**/*",
5 | "**/.client/**/*",
6 | ".react-router/types/**/*"
7 | ],
8 | "compilerOptions": {
9 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
10 | "types": ["node", "vite/client"],
11 | "target": "ES2022",
12 | "module": "ES2022",
13 | "moduleResolution": "bundler",
14 | "jsx": "react-jsx",
15 | "rootDirs": [".", "./.react-router/types"],
16 | "baseUrl": ".",
17 | "paths": {
18 | "~/*": ["./app/*"]
19 | },
20 | "esModuleInterop": true,
21 | "verbatimModuleSyntax": true,
22 | "noEmit": true,
23 | "resolveJsonModule": true,
24 | "skipLibCheck": true,
25 | "strict": true
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/templates/react-router/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { reactRouter } from '@react-router/dev/vite'
2 | import autoprefixer from 'autoprefixer'
3 | import tailwindcss from 'tailwindcss'
4 | import { defineConfig } from 'vite'
5 | import tsconfigPaths from 'vite-tsconfig-paths'
6 |
7 | export default defineConfig(({ command }) => {
8 | return {
9 | ssr: {
10 | noExternal: command === 'build' ? true : undefined,
11 | },
12 | css: {
13 | postcss: {
14 | plugins: [tailwindcss, autoprefixer],
15 | },
16 | },
17 | plugins: [reactRouter(), tsconfigPaths()],
18 | server: {
19 | port: 3000,
20 | },
21 | }
22 | })
23 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "module": "ESNext",
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "moduleResolution": "bundler",
7 | "jsx": "react-jsx",
8 | "strict": true,
9 | "declaration": true,
10 | "sourceMap": true,
11 | "skipLibCheck": true,
12 | "esModuleInterop": true,
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "noUnusedLocals": true,
16 | "noUnusedParameters": true,
17 | "noImplicitReturns": true
18 | },
19 | "include": ["packages/**/*"],
20 | "exclude": ["node_modules", "dist"]
21 | }
22 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsup'
2 |
3 | export default defineConfig({
4 | entry: ['packages/core/index.ts'],
5 | format: ['cjs', 'esm'],
6 | dts: true,
7 | splitting: false,
8 | sourcemap: true,
9 | clean: true,
10 | treeshake: true,
11 | minify: false,
12 | external: ['react', '@joycostudio/transitions'],
13 | })
14 |
--------------------------------------------------------------------------------