├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── astro.config.ts ├── dev ├── env.d.ts ├── group.tsx ├── kitchen-sink.tsx ├── layout.astro ├── pages │ ├── group.astro │ └── index.astro └── styles.css ├── netlify.toml ├── package.json ├── pnpm-lock.yaml ├── src └── index.ts ├── tailwind.config.cjs ├── test ├── index.test.tsx └── server.test.tsx ├── tsconfig.build.json ├── tsconfig.json └── vitest.config.ts /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - uses: pnpm/action-setup@v4 20 | 21 | - name: Setup Node.js environment 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 22 25 | cache: pnpm 26 | 27 | - name: Install dependencies 28 | run: pnpm install --no-frozen-lockfile 29 | 30 | - name: Build 31 | run: pnpm run build 32 | env: 33 | CI: true 34 | 35 | - name: Test 36 | run: pnpm run test 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | lib/ 4 | coverage/ 5 | types/ 6 | *.tsbuildinfo 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "printWidth": 100, 5 | "semi": true, 6 | "singleQuote": false, 7 | "useTabs": false, 8 | "arrowParens": "avoid", 9 | "bracketSpacing": true, 10 | "endOfLine": "lf", 11 | "plugins": [] 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "astro-build.astro-vscode", 5 | "bradlc.vscode-tailwindcss" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["astrojs", "outin"], 3 | "css.validate": false 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 Ryan Carniato 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Solid Transition Group 3 |

4 | 5 | # Solid Transition Group 6 | 7 | [![pnpm](https://img.shields.io/badge/maintained%20with-pnpm-cc00ff.svg?style=for-the-badge&logo=pnpm)](https://pnpm.io/) 8 | [![version](https://img.shields.io/npm/v/solid-transition-group?style=for-the-badge)](https://www.npmjs.com/package/solid-transition-group) 9 | [![downloads](https://img.shields.io/npm/dw/solid-transition-group?color=blue&style=for-the-badge)](https://www.npmjs.com/package/solid-transition-group) 10 | 11 | Components for applying animations when children elements enter or leave the DOM. Influenced by React Transition Group and Vue Transitions for the SolidJS library. 12 | 13 | ## Installation 14 | 15 | ```bash 16 | npm install solid-transition-group 17 | # or 18 | yarn add solid-transition-group 19 | # or 20 | pnpm add solid-transition-group 21 | ``` 22 | 23 | ## Transition 24 | 25 | `` serve as transition effects for single element/component. The `` only applies the transition behavior to the wrapped content inside; it doesn't render an extra DOM element, or show up in the inspected component hierarchy. 26 | 27 | All props besides `children` are optional. 28 | 29 | ### Using with CSS 30 | 31 | Usage with CSS is straightforward. Just add the `name` prop and the CSS classes will be automatically generated for you. The `name` prop is used as a prefix for the generated CSS classes. For example, if you use `name="slide-fade"`, the generated CSS classes will be `.slide-fade-enter`, `.slide-fade-enter-active`, etc. 32 | 33 | The exitting element will be removed from the DOM when the first transition ends. You can override this behavior by providing a `done` callback to the `onExit` prop. 34 | 35 | ```tsx 36 | import { Transition } from "solid-transition-group" 37 | 38 | const [isVisible, setVisible] = createSignal(true) 39 | 40 | 41 | 42 |
Hello
43 |
44 |
45 | 46 | setVisible(false) // triggers exit transition 47 | ``` 48 | 49 | Example CSS transition: 50 | 51 | ```css 52 | .slide-fade-enter-active, 53 | .slide-fade-exit-active { 54 | transition: opacity 0.3s, transform 0.3s; 55 | } 56 | .slide-fade-enter, 57 | .slide-fade-exit-to { 58 | transform: translateX(10px); 59 | opacity: 0; 60 | } 61 | .slide-fade-enter { 62 | transform: translateX(-10px); 63 | } 64 | ``` 65 | 66 | Props for customizing the CSS classes applied by ``: 67 | 68 | | Name | Description | 69 | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | 70 | | `name` | Used to automatically generate transition CSS class names. e.g. `name: 'fade'` will auto expand to `.fade-enter`, `.fade-enter-active`, etc. Defaults to `"s"`. | 71 | | `enterClass` | CSS class applied to the entering element at the start of the enter transition, and removed the frame after. Defaults to `"s-enter"`. | 72 | | `enterToClass` | CSS class applied to the entering element after the enter transition starts. Defaults to `"s-enter-to"`. | 73 | | `enterActiveClass` | CSS class applied to the entering element for the entire duration of the enter transition. Defaults to `"s-enter-active"`. | 74 | | `exitClass` | CSS class applied to the exiting element at the start of the exit transition, and removed the frame after. Defaults to `"s-exit"`. | 75 | | `exitToClass` | CSS class applied to the exiting element after the exit transition starts. Defaults to `"s-exit-to"`. | 76 | | `exitActiveClass` | CSS class applied to the exiting element for the entire duration of the exit transition. Defaults to `"s-exit-active"`. | 77 | 78 | ### Using with JavaScript 79 | 80 | You can also use JavaScript to animate the transition. The `` component provides several events that you can use to hook into the transition lifecycle. The `onEnter` and `onExit` events are called when the transition starts, and the `onBeforeEnter` and `onBeforeExit` events are called before the transition starts. The `onAfterEnter` and `onAfterExit` events are called after the transition ends. 81 | 82 | ```jsx 83 | { 85 | const a = el.animate([{ opacity: 0 }, { opacity: 1 }], { 86 | duration: 600 87 | }); 88 | a.finished.then(done); 89 | }} 90 | onExit={(el, done) => { 91 | const a = el.animate([{ opacity: 1 }, { opacity: 0 }], { 92 | duration: 600 93 | }); 94 | a.finished.then(done); 95 | }} 96 | > 97 | {show() &&
Hello
} 98 |
99 | ``` 100 | 101 | **Events** proved by `` for animating elements with JavaScript: 102 | 103 | | Name | Parameters | Description | 104 | | --------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 105 | | `onBeforeEnter` | `element: Element` | Function called before the enter transition starts. The `element` is not yet rendered. | 106 | | `onEnter` | `element: Element, done: () => void` | Function called when the enter transition starts. The `element` is rendered to the DOM. Call `done` to end the transition - removes the enter classes, and calls `onAfterEnter`. If the parameter for `done` is not provided, it will be called on `transitionend` or `animationend`. | 107 | | `onAfterEnter` | `element: Element` | Function called after the enter transition ends. The `element` is removed from the DOM. | 108 | | `onBeforeExit` | `element: Element` | Function called before the exit transition starts. The `element` is still rendered, exit classes are not yet applied. | 109 | | `onExit` | `element: Element, done: () => void` | Function called when the exit transition starts, after the exit classes are applied (`enterToClass` and `exitActiveClass`). The `element` is still rendered. Call `done` to end the transition - removes exit classes, calls `onAfterExit` and removes the element from the DOM. If the parameter for `done` is not provided, it will be called on `transitionend` or `animationend`. | 110 | | `onAfterExit` | `element: Element` | Function called after the exit transition ends. The `element` is removed from the DOM. | 111 | 112 | ### Changing Transition Mode 113 | 114 | By default, `` will apply the transition effect to both entering and exiting elements simultaneously. You can change this behavior by setting the `mode` prop to `"outin"` or `"inout"`. The `"outin"` mode will wait for the exiting element to finish before applying the transition to the entering element. The `"inout"` mode will wait for the entering element to finish before applying the transition to the exiting element. 115 | 116 | By default the transition won't be applied on initial render. You can change this behavior by setting the `appear` prop to `true`. 117 | 118 | > **Warning:** When using `appear` with SSR, the initial transition will be applied on the client-side, which might cause a flash of unstyled content. 119 | > You need to handle applying the initial transition on the server-side yourself. 120 | 121 | ## TransitionGroup 122 | 123 | ### Props 124 | 125 | - `moveClass` - CSS class applied to the moving elements for the entire duration of the move transition. Defaults to `"s-move"`. 126 | - exposes the same props as `` except `mode`. 127 | 128 | ### Usage 129 | 130 | `` serve as transition effects for multiple elements/components. 131 | 132 | `` supports moving transitions via CSS transform. When a child's position on screen has changed after an update, it will get applied a moving CSS class (auto generated from the name attribute or configured with the move-class attribute). If the CSS transform property is "transition-able" when the moving class is applied, the element will be smoothly animated to its destination using the FLIP technique. 133 | 134 | ```jsx 135 |
    136 | 137 | {item =>
  • {item.text}
  • }
    138 |
    139 |
140 | ``` 141 | 142 | ## Demo 143 | 144 | Kitchen sink demo: https://solid-transition-group.netlify.app/ 145 | 146 | Source code: https://github.com/solidjs-community/solid-transition-group/blob/main/dev/pages/kitchen-sink.tsx 147 | 148 | ## FAQ 149 | 150 | - **How to use with Portal?** - [Issue #8](https://github.com/solidjs-community/solid-transition-group/issues/8) 151 | - **How to use with Outlet?** - [Issue #29](https://github.com/solidjs-community/solid-transition-group/issues/29) 152 | - **Why elements are not connected in outin mode** - [Issue #34](https://github.com/solidjs-community/solid-transition-group/issues/34) 153 | -------------------------------------------------------------------------------- /astro.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { defineConfig } from "astro/config"; 4 | import solid from "@astrojs/solid-js"; 5 | import tailwind from "@astrojs/tailwind"; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | 10 | // https://astro.build/config 11 | export default defineConfig({ 12 | outDir: "dev/dist", 13 | srcDir: "dev", 14 | vite: { 15 | resolve: { 16 | alias: { 17 | "solid-transition-group": path.resolve(__dirname, "./src/index.ts"), 18 | }, 19 | }, 20 | }, 21 | integrations: [solid(), tailwind()], 22 | }); 23 | -------------------------------------------------------------------------------- /dev/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /dev/group.tsx: -------------------------------------------------------------------------------- 1 | import { Component, For, createSignal } from "solid-js"; 2 | import { TransitionGroup } from "solid-transition-group"; 3 | 4 | function shuffle(array: T[]): T[] { 5 | return array.sort(() => Math.random() - 0.5); 6 | } 7 | 8 | const getRandomChar = () => ({ v: String.fromCharCode(65 + Math.floor(Math.random() * 26)) }); 9 | 10 | const Group: Component = () => { 11 | const [list, setList] = createSignal<{ v: string }[]>([ 12 | { v: "S" }, 13 | { v: "O" }, 14 | { v: "L" }, 15 | { v: "I" }, 16 | { v: "D" }, 17 | { v: "-" }, 18 | { v: "T" }, 19 | { v: "R" }, 20 | { v: "A" }, 21 | { v: "N" }, 22 | { v: "S" }, 23 | { v: "I" }, 24 | { v: "T" }, 25 | { v: "I" }, 26 | { v: "O" }, 27 | { v: "N" }, 28 | { v: "-" }, 29 | { v: "G" }, 30 | { v: "R" }, 31 | { v: "O" }, 32 | { v: "U" }, 33 | { v: "P" }, 34 | ]); 35 | const randomIndex = () => Math.floor(Math.random() * list().length); 36 | 37 | return ( 38 | <> 39 |
40 | 41 | 49 | 63 |
64 |
65 | 66 | 67 | {({ v }) => ( 68 |
69 | {v} 70 |
71 | )} 72 |
73 |
74 |
75 | 76 | ); 77 | }; 78 | 79 | export default Group; 80 | -------------------------------------------------------------------------------- /dev/kitchen-sink.tsx: -------------------------------------------------------------------------------- 1 | import { Component, For, Show, createSignal } from "solid-js"; 2 | import { Transition, TransitionGroup } from "solid-transition-group"; 3 | 4 | function shuffle(array: T): T { 5 | return array.sort(() => Math.random() - 0.5); 6 | } 7 | 8 | const getRandomColor = () => `#${((Math.random() * 2 ** 24) | 0).toString(16)}`; 9 | 10 | const Group: Component = () => { 11 | const [list, setList] = createSignal([1, 2, 3, 4, 5, 6, 7, 8, 9]); 12 | let nextId = 10; 13 | const randomIndex = () => Math.floor(Math.random() * list().length); 14 | 15 | return ( 16 | <> 17 |
18 | 19 | 27 | 28 | 37 | 45 |
46 |
47 | 48 | fallback
}> 49 | {() => ( 50 |
51 | 52 | 58 | 59 |
60 | )} 61 | 62 |
63 | 64 | 65 | ); 66 | }; 67 | 68 | const SwitchCSS: Component = () => { 69 | const [page, setPage] = createSignal(1); 70 | 71 | return ( 72 | <> 73 | 74 |
75 | 76 | 77 | {i =>
{i}.
} 78 |
79 |
80 | 81 | ); 82 | }; 83 | 84 | const SwitchJS: Component = () => { 85 | const [page, setPage] = createSignal(1); 86 | 87 | function onEnter(el: Element, done: VoidFunction) { 88 | const a = el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 500, easing: "ease" }); 89 | a.finished.then(done); 90 | } 91 | function onExit(el: Element, done: VoidFunction) { 92 | const a = el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 500, easing: "ease" }); 93 | a.finished.then(done); 94 | } 95 | 96 | return ( 97 | <> 98 | 99 |
100 | 101 | 102 | {i =>
{i}.
} 103 |
104 |
105 | 106 | ); 107 | }; 108 | 109 | const Collapse: Component = () => { 110 | const [show, toggleShow] = createSignal(true); 111 | 112 | const COLLAPSED_PROPERTIES = { 113 | height: 0, 114 | marginTop: 0, 115 | marginBottom: 0, 116 | paddingTop: 0, 117 | paddingBottom: 0, 118 | borderTopWidth: 0, 119 | borderBottomWidth: 0, 120 | }; 121 | 122 | function getHeight(el: Element): string { 123 | const rect = el.getBoundingClientRect(); 124 | return `${rect.height}px`; 125 | } 126 | 127 | function onEnter(el: Element, done: VoidFunction) { 128 | const a = el.animate( 129 | [ 130 | COLLAPSED_PROPERTIES, 131 | { 132 | height: getHeight(el), 133 | }, 134 | ], 135 | { duration: 500, easing: "ease" }, 136 | ); 137 | 138 | a.finished.then(done); 139 | } 140 | 141 | function onExit(el: Element, done: VoidFunction) { 142 | const a = el.animate( 143 | [ 144 | { 145 | height: getHeight(el), 146 | }, 147 | COLLAPSED_PROPERTIES, 148 | ], 149 | { duration: 500, easing: "ease" }, 150 | ); 151 | 152 | a.finished.then(done); 153 | } 154 | 155 | return ( 156 | <> 157 | 158 |
159 | 160 | 161 |

162 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris facilisis enim libero, 163 | at lacinia diam fermentum id. Pellentesque habitant morbi tristique senectus et netus. 164 |

165 |
166 |
167 | 168 | ); 169 | }; 170 | 171 | const Example = () => { 172 | const [show, toggleShow] = createSignal(true); 173 | 174 | return ( 175 | <> 176 | 177 |
178 |
179 | Transition: 180 | 181 | {show() && ( 182 |
183 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris facilisis enim libero, 184 | at lacinia diam fermentum id. Pellentesque habitant morbi tristique senectus et netus. 185 |
186 | )} 187 |
188 |
189 | Animation: 190 | 191 | {show() && ( 192 |
193 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris facilisis enim libero, 194 | at lacinia diam fermentum id. Pellentesque habitant morbi tristique senectus et netus. 195 |
196 | )} 197 |
198 |
199 | Custom JS: 200 | { 202 | const a = el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 600 }); 203 | a.finished.then(done); 204 | }} 205 | onExit={(el, done) => { 206 | const a = el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 600 }); 207 | a.finished.then(done); 208 | }} 209 | > 210 | {show() && ( 211 |
212 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris facilisis enim libero, 213 | at lacinia diam fermentum id. Pellentesque habitant morbi tristique senectus et netus. 214 |
215 | )} 216 |
217 |
218 | 219 | Switch OutIn CSS 220 |
221 | 222 |
223 | 224 | Switch OutIn JS 225 |
226 | 227 |
228 | 229 | Collapse OutIn CSS & JS 230 |
231 | 232 |
233 | 234 | Group 235 |
236 | 237 | 238 | ); 239 | }; 240 | 241 | export default Example; 242 | -------------------------------------------------------------------------------- /dev/layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | interface Props { 3 | title: string; 4 | } 5 | const { title } = Astro.props; 6 | 7 | import "./styles.css"; 8 | --- 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 22 |
23 |

{title}

24 | 25 |
26 | 27 | 28 | -------------------------------------------------------------------------------- /dev/pages/group.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "../layout.astro"; 3 | import Exmaple from "../group"; 4 | --- 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /dev/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "../layout.astro"; 3 | import Example from "../kitchen-sink"; 4 | --- 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /dev/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #1e1e1e; 3 | color: #fff; 4 | font-family: "Roboto", sans-serif; 5 | } 6 | main { 7 | width: 100%; 8 | max-width: 600px; 9 | margin: 10vh auto; 10 | } 11 | 12 | h1 { 13 | font-size: 2rem; 14 | font-weight: 500; 15 | } 16 | h2 { 17 | font-size: 1.5rem; 18 | font-weight: 500; 19 | } 20 | h3 { 21 | font-size: 1.25rem; 22 | font-weight: 500; 23 | } 24 | h4 { 25 | font-size: 1rem; 26 | font-weight: 500; 27 | } 28 | h5 { 29 | font-size: 0.875rem; 30 | font-weight: 500; 31 | } 32 | h6 { 33 | font-size: 0.75rem; 34 | font-weight: 500; 35 | } 36 | 37 | button { 38 | @apply bg-blue-600 text-white font-medium py-1 px-2 rounded; 39 | @apply hover:bg-blue-700; 40 | @apply focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-opacity-50; 41 | @apply transition ease-in-out; 42 | } 43 | 44 | .container { 45 | position: relative; 46 | } 47 | 48 | .fade-enter-active, 49 | .fade-exit-active { 50 | transition: opacity 0.5s; 51 | } 52 | .fade-enter, 53 | .fade-exit-to { 54 | opacity: 0; 55 | } 56 | 57 | .slide-fade-enter-active { 58 | transition: all 0.3s ease; 59 | } 60 | .slide-fade-exit-active { 61 | transition: all 0.8s cubic-bezier(1, 0.5, 0.8, 1); 62 | } 63 | .slide-fade-enter, 64 | .slide-fade-exit-to { 65 | transform: translateX(10px); 66 | opacity: 0; 67 | } 68 | 69 | .bounce-enter-active { 70 | animation: bounce-in 0.5s; 71 | } 72 | .bounce-exit-active { 73 | animation: bounce-in 0.5s reverse; 74 | } 75 | @keyframes bounce-in { 76 | 0% { 77 | transform: scale(0); 78 | } 79 | 50% { 80 | transform: scale(1.5); 81 | } 82 | 100% { 83 | transform: scale(1); 84 | } 85 | } 86 | 87 | .collapse-exit-active, 88 | .collapse-enter-active { 89 | overflow: hidden; 90 | } 91 | 92 | .group-item { 93 | transition: all 0.5s; 94 | } 95 | 96 | .group-item-enter, 97 | .group-item-exit-to { 98 | opacity: 0; 99 | transform: translateY(30px); 100 | } 101 | .group-item-exit-active { 102 | position: absolute; 103 | } 104 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | base = "/" 3 | publish = "dev/dist/" 4 | command = "pnpm run build:site" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "solid-transition-group", 3 | "description": "Components to manage animations for SolidJS", 4 | "author": "Ryan Carniato", 5 | "license": "MIT", 6 | "version": "0.3.0", 7 | "homepage": "https://github.com/solidjs/solid-transition-group#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/solidjs/solid-transition-group" 11 | }, 12 | "sideEffects": false, 13 | "private": false, 14 | "type": "module", 15 | "module": "dist/index.js", 16 | "types": "dist/index.d.ts", 17 | "exports": { 18 | "import": { 19 | "types": "./dist/index.d.ts", 20 | "default": "./dist/index.js" 21 | } 22 | }, 23 | "files": [ 24 | "dist" 25 | ], 26 | "scripts": { 27 | "dev": "astro dev", 28 | "build:site": "astro build", 29 | "build": "tsc -b tsconfig.build.json", 30 | "test:client": "vitest", 31 | "test:ssr": "pnpm run test:client --mode ssr", 32 | "test": "concurrently pnpm:test:*", 33 | "format": "prettier -w **/*.{js,ts,json,css,tsx,jsx} --ignore-path .gitignore", 34 | "prepublishOnly": "pnpm build" 35 | }, 36 | "devDependencies": { 37 | "@astrojs/solid-js": "^2.2.0", 38 | "@astrojs/tailwind": "^4.0.0", 39 | "astro": "^2.10.1", 40 | "concurrently": "^9.1.2", 41 | "jsdom": "^26.0.0", 42 | "prettier": "^3.4.2", 43 | "solid-js": "^1.9.4", 44 | "tailwindcss": "^3.3.3", 45 | "typescript": "^5.7.3", 46 | "vite-plugin-solid": "^2.11.0", 47 | "vitest": "^2.1.8" 48 | }, 49 | "dependencies": { 50 | "@solid-primitives/refs": "^1.1.0", 51 | "@solid-primitives/transition-group": "^1.1.0" 52 | }, 53 | "peerDependencies": { 54 | "solid-js": "^1.6.12" 55 | }, 56 | "packageManager": "pnpm@9.15.0", 57 | "engines": { 58 | "node": ">=20.0.0", 59 | "pnpm": ">=9.0.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { createMemo, type FlowComponent, type JSX } from "solid-js"; 2 | import { createSwitchTransition, createListTransition } from "@solid-primitives/transition-group"; 3 | import { resolveFirst, resolveElements } from "@solid-primitives/refs"; 4 | 5 | function createClassnames(props: TransitionProps & TransitionGroupProps) { 6 | return createMemo(() => { 7 | const name = props.name || "s"; 8 | return { 9 | enterActive: (props.enterActiveClass || name + "-enter-active").split(" "), 10 | enter: (props.enterClass || name + "-enter").split(" "), 11 | enterTo: (props.enterToClass || name + "-enter-to").split(" "), 12 | exitActive: (props.exitActiveClass || name + "-exit-active").split(" "), 13 | exit: (props.exitClass || name + "-exit").split(" "), 14 | exitTo: (props.exitToClass || name + "-exit-to").split(" "), 15 | move: (props.moveClass || name + "-move").split(" "), 16 | }; 17 | }); 18 | } 19 | 20 | // https://github.com/solidjs-community/solid-transition-group/issues/12 21 | // for the css transition be triggered properly on firefox 22 | // we need to wait for two frames before changeing classes 23 | function nextFrame(fn: () => void) { 24 | requestAnimationFrame(() => requestAnimationFrame(fn)); 25 | } 26 | 27 | /** 28 | * Run an enter transition on an element - common for both Transition and TransitionGroup 29 | */ 30 | function enterTransition( 31 | classes: ReturnType>, 32 | events: TransitionEvents, 33 | el: Element, 34 | done?: VoidFunction, 35 | ) { 36 | const { onBeforeEnter, onEnter, onAfterEnter } = events; 37 | 38 | // before the elements are added to the DOM 39 | onBeforeEnter?.(el); 40 | 41 | el.classList.add(...classes.enter); 42 | el.classList.add(...classes.enterActive); 43 | 44 | // after the microtask the elements will be added to the DOM 45 | // and onEnter will be called in the same frame 46 | queueMicrotask(() => { 47 | // Don't animate element if it's not in the DOM 48 | // This can happen when elements are changed under Suspense 49 | if (!el.parentNode) return done?.(); 50 | 51 | onEnter?.(el, () => endTransition()); 52 | }); 53 | 54 | nextFrame(() => { 55 | el.classList.remove(...classes.enter); 56 | el.classList.add(...classes.enterTo); 57 | 58 | if (!onEnter || onEnter.length < 2) { 59 | el.addEventListener("transitionend", endTransition); 60 | el.addEventListener("animationend", endTransition); 61 | } 62 | }); 63 | 64 | function endTransition(e?: Event) { 65 | if (!e || e.target === el) { 66 | done?.(); // starts exit transition in "in-out" mode 67 | el.removeEventListener("transitionend", endTransition); 68 | el.removeEventListener("animationend", endTransition); 69 | el.classList.remove(...classes.enterActive); 70 | el.classList.remove(...classes.enterTo); 71 | onAfterEnter?.(el); 72 | } 73 | } 74 | } 75 | 76 | /** 77 | * @private 78 | * 79 | * Run an exit transition on an element - common for both Transition and TransitionGroup 80 | */ 81 | export function exitTransition( 82 | classes: ReturnType>, 83 | events: TransitionEvents, 84 | el: Element, 85 | done?: VoidFunction, 86 | ) { 87 | const { onBeforeExit, onExit, onAfterExit } = events; 88 | 89 | // Don't animate element if it's not in the DOM 90 | // This can happen when elements are changed under Suspense 91 | if (!el.parentNode) return done?.(); 92 | 93 | onBeforeExit?.(el); 94 | 95 | el.classList.add(...classes.exit); 96 | el.classList.add(...classes.exitActive); 97 | 98 | onExit?.(el, () => endTransition()); 99 | 100 | nextFrame(() => { 101 | el.classList.remove(...classes.exit); 102 | el.classList.add(...classes.exitTo); 103 | 104 | if (!onExit || onExit.length < 2) { 105 | el.addEventListener("transitionend", endTransition); 106 | el.addEventListener("animationend", endTransition); 107 | } 108 | }); 109 | 110 | function endTransition(e?: Event) { 111 | if (!e || e.target === el) { 112 | // calling done() will remove element from the DOM, 113 | // but also trigger onChange callback in . 114 | // Which is why the classes need to removed afterwards, 115 | // so that removing them won't change el styles when for the move transition 116 | done?.(); 117 | el.removeEventListener("transitionend", endTransition); 118 | el.removeEventListener("animationend", endTransition); 119 | el.classList.remove(...classes.exitActive); 120 | el.classList.remove(...classes.exitTo); 121 | onAfterExit?.(el); 122 | } 123 | } 124 | } 125 | 126 | export type TransitionEvents = { 127 | /** 128 | * Function called before the enter transition starts. 129 | * The {@link element} is not yet rendered. 130 | */ 131 | onBeforeEnter?: (element: Element) => void; 132 | /** 133 | * Function called when the enter transition starts. 134 | * The {@link element} is rendered to the DOM. 135 | * 136 | * Call {@link done} to end the transition - removes the enter classes, 137 | * and calls {@link TransitionEvents.onAfterEnter}. 138 | * If the parameter for {@link done} is not provided, it will be called on `transitionend` or `animationend`. 139 | */ 140 | onEnter?: (element: Element, done: () => void) => void; 141 | /** 142 | * Function called after the enter transition ends. 143 | * The {@link element} is removed from the DOM. 144 | */ 145 | onAfterEnter?: (element: Element) => void; 146 | /** 147 | * Function called before the exit transition starts. 148 | * The {@link element} is still rendered, exit classes are not yet applied. 149 | */ 150 | onBeforeExit?: (element: Element) => void; 151 | /** 152 | * Function called when the exit transition starts, after the exit classes are applied 153 | * ({@link TransitionProps.enterToClass} and {@link TransitionProps.exitActiveClass}). 154 | * The {@link element} is still rendered. 155 | * 156 | * Call {@link done} to end the transition - removes exit classes, 157 | * calls {@link TransitionEvents.onAfterExit} and removes the element from the DOM. 158 | * If the parameter for {@link done} is not provided, it will be called on `transitionend` or `animationend`. 159 | */ 160 | onExit?: (element: Element, done: () => void) => void; 161 | /** 162 | * Function called after the exit transition ends. 163 | * The {@link element} is removed from the DOM. 164 | */ 165 | onAfterExit?: (element: Element) => void; 166 | }; 167 | 168 | /** 169 | * Props for the {@link Transition} component. 170 | */ 171 | export type TransitionProps = TransitionEvents & { 172 | /** 173 | * Used to automatically generate transition CSS class names. 174 | * e.g. `name: 'fade'` will auto expand to `.fade-enter`, `.fade-enter-active`, etc. 175 | * Defaults to `"s"`. 176 | */ 177 | name?: string; 178 | /** 179 | * CSS class applied to the entering element for the entire duration of the enter transition. 180 | * Defaults to `"s-enter-active"`. 181 | */ 182 | enterActiveClass?: string; 183 | /** 184 | * CSS class applied to the entering element at the start of the enter transition, and removed the frame after. 185 | * Defaults to `"s-enter"`. 186 | */ 187 | enterClass?: string; 188 | /** 189 | * CSS class applied to the entering element after the enter transition starts. 190 | * Defaults to `"s-enter-to"`. 191 | */ 192 | enterToClass?: string; 193 | /** 194 | * CSS class applied to the exiting element for the entire duration of the exit transition. 195 | * Defaults to `"s-exit-active"`. 196 | */ 197 | exitActiveClass?: string; 198 | /** 199 | * CSS class applied to the exiting element at the start of the exit transition, and removed the frame after. 200 | * Defaults to `"s-exit"`. 201 | */ 202 | exitClass?: string; 203 | /** 204 | * CSS class applied to the exiting element after the exit transition starts. 205 | * Defaults to `"s-exit-to"`. 206 | */ 207 | exitToClass?: string; 208 | /** 209 | * Whether to apply transition on initial render. Defaults to `false`. 210 | */ 211 | appear?: boolean; 212 | /** 213 | * Controls the timing sequence of leaving/entering transitions. 214 | * Available modes are `"outin"` and `"inout"`; 215 | * Defaults to simultaneous. 216 | */ 217 | mode?: "inout" | "outin"; 218 | }; 219 | 220 | const TRANSITION_MODE_MAP = { 221 | inout: "in-out", 222 | outin: "out-in", 223 | } as const; 224 | 225 | /** 226 | * The `` component lets you apply enter and leave animations on element passed to `props.children`. 227 | * 228 | * It only supports transitioning a single element at a time. 229 | * 230 | * @param props {@link TransitionProps} 231 | */ 232 | export const Transition: FlowComponent = props => { 233 | const classnames = createClassnames(props); 234 | 235 | return createSwitchTransition( 236 | resolveFirst(() => props.children), 237 | { 238 | mode: TRANSITION_MODE_MAP[props.mode!], 239 | appear: props.appear, 240 | onEnter(el, done) { 241 | enterTransition(classnames(), props, el, done); 242 | }, 243 | onExit(el, done) { 244 | exitTransition(classnames(), props, el, done); 245 | }, 246 | }, 247 | ) as unknown as JSX.Element; 248 | }; 249 | 250 | /** 251 | * Props for the {@link TransitionGroup} component. 252 | */ 253 | export type TransitionGroupProps = Omit & { 254 | /** 255 | * CSS class applied to the moving elements for the entire duration of the move transition. 256 | * Defaults to `"s-move"`. 257 | */ 258 | moveClass?: string; 259 | }; 260 | 261 | /** 262 | * The `` component lets you apply enter and leave animations on elements passed to `props.children`. 263 | * 264 | * It supports transitioning multiple elements at a time and moving elements around. 265 | * 266 | * @param props {@link TransitionGroupProps} 267 | */ 268 | export const TransitionGroup: FlowComponent = props => { 269 | const classnames = createClassnames(props); 270 | 271 | return createListTransition(resolveElements(() => props.children).toArray, { 272 | appear: props.appear, 273 | exitMethod: "keep-index", 274 | onChange({ added, removed, finishRemoved, list }) { 275 | const classes = classnames(); 276 | 277 | // ENTER 278 | for (const el of added) { 279 | enterTransition(classes, props, el); 280 | } 281 | 282 | // MOVE 283 | const toMove: { el: HTMLElement | SVGElement; rect: DOMRect }[] = []; 284 | // get rects of elements before the changes to the DOM 285 | for (const el of list) { 286 | if (el.isConnected && (el instanceof HTMLElement || el instanceof SVGElement)) { 287 | toMove.push({ el, rect: el.getBoundingClientRect() }); 288 | } 289 | } 290 | 291 | // wait for th new list to be rendered 292 | queueMicrotask(() => { 293 | const moved: (HTMLElement | SVGElement)[] = []; 294 | 295 | for (const { el, rect } of toMove) { 296 | if (el.isConnected) { 297 | const newRect = el.getBoundingClientRect(), 298 | dX = rect.left - newRect.left, 299 | dY = rect.top - newRect.top; 300 | if (dX || dY) { 301 | // set els to their old position before transition 302 | el.style.transform = `translate(${dX}px, ${dY}px)`; 303 | el.style.transitionDuration = "0s"; 304 | moved.push(el); 305 | } 306 | } 307 | } 308 | 309 | document.body.offsetHeight; // force reflow 310 | 311 | for (const el of moved) { 312 | el.classList.add(...classes.move); 313 | 314 | // clear transition - els will move to their new position 315 | el.style.transform = el.style.transitionDuration = ""; 316 | 317 | function endTransition(e: Event) { 318 | if (e.target === el || /transform$/.test((e as TransitionEvent).propertyName)) { 319 | el.removeEventListener("transitionend", endTransition); 320 | el.classList.remove(...classes.move); 321 | } 322 | } 323 | el.addEventListener("transitionend", endTransition); 324 | } 325 | }); 326 | 327 | // EXIT 328 | for (const el of removed) { 329 | exitTransition(classes, props, el, () => finishRemoved([el])); 330 | } 331 | }, 332 | }) as unknown as JSX.Element; 333 | }; 334 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"], 4 | theme: { 5 | extend: {} 6 | }, 7 | plugins: [] 8 | }; 9 | -------------------------------------------------------------------------------- /test/index.test.tsx: -------------------------------------------------------------------------------- 1 | // Flush animation frames manually 2 | const rafQueue: VoidFunction[] = []; 3 | (globalThis as any).requestAnimationFrame = (cb: VoidFunction) => { 4 | rafQueue.push(cb); 5 | }; 6 | function flushRaf() { 7 | const queue = rafQueue.slice(); 8 | rafQueue.length = 0; 9 | queue.forEach(cb => cb()); 10 | } 11 | 12 | import { Show, createRoot, createSignal } from "solid-js"; 13 | import { describe, expect, it } from "vitest"; 14 | import { Transition, exitTransition } from "../src/index.js"; 15 | 16 | describe("Transition", () => { 17 | it("matches the timing of vue out-in transition", async () => { 18 | const captured: [type: string, parentNode: boolean, classname: string][] = []; 19 | let runEnter!: VoidFunction; 20 | let runExit!: VoidFunction; 21 | 22 | function onBeforeEnter(el: Element) { 23 | captured.push(["before enter", el.parentNode !== null, el.className]); 24 | requestAnimationFrame(() => { 25 | captured.push(["1 frame", el.parentNode !== null, el.className]); 26 | requestAnimationFrame(() => { 27 | captured.push(["2 frame", el.parentNode !== null, el.className]); 28 | requestAnimationFrame(() => { 29 | captured.push(["3 frame", el.parentNode !== null, el.className]); 30 | }); 31 | }); 32 | }); 33 | } 34 | function onEnter(el: Element, done: VoidFunction) { 35 | captured.push(["enter", el.parentNode !== null, el.className]); 36 | runEnter = done; 37 | } 38 | function onAfterEnter(el: Element) { 39 | captured.push(["after enter", el.parentNode !== null, el.className]); 40 | } 41 | function onBeforeExit(el: Element) { 42 | captured.push(["before exit", el.parentNode !== null, el.className]); 43 | } 44 | function onExit(el: Element, done: VoidFunction) { 45 | captured.push(["exit", el.parentNode !== null, el.className]); 46 | runExit = done; 47 | } 48 | function onAfterExit(el: Element) { 49 | captured.push(["after exit", el.parentNode !== null, el.className]); 50 | } 51 | 52 | const [page, setPage] = createSignal(1); 53 | 54 | const dispose = createRoot(dispose => { 55 |
56 | 65 | 66 | {i =>
{i}
} 67 |
68 |
69 |
; 70 | 71 | return dispose; 72 | }); 73 | 74 | expect(captured).toHaveLength(0); 75 | 76 | setPage(2); 77 | 78 | flushRaf(); 79 | flushRaf(); 80 | 81 | runExit(); 82 | 83 | // enter - await microtask 84 | await Promise.resolve(); 85 | 86 | flushRaf(); 87 | flushRaf(); 88 | flushRaf(); 89 | 90 | runEnter(); 91 | 92 | expect(captured).toEqual([ 93 | ["before exit", true, ""], 94 | ["exit", true, "s-exit s-exit-active"], 95 | ["before enter", false, ""], 96 | ["after exit", false, ""], 97 | ["enter", true, "s-enter s-enter-active"], 98 | ["1 frame", true, "s-enter s-enter-active"], 99 | ["2 frame", true, "s-enter s-enter-active"], 100 | ["3 frame", true, "s-enter-active s-enter-to"], 101 | ["after enter", true, ""], 102 | ]); 103 | 104 | dispose(); 105 | }); 106 | }); 107 | 108 | describe("exitTransition", () => { 109 | it("removes exit classes after calling done()", () => { 110 | const parent = document.createElement("div"); 111 | const el = document.createElement("div"); 112 | parent.appendChild(el); 113 | 114 | let capturedClassName: string | null = null; 115 | 116 | exitTransition( 117 | { 118 | enterActive: ["s-enter-active"], 119 | enterTo: ["s-enter-to"], 120 | enter: ["s-enter"], 121 | exitActive: ["s-exit-active"], 122 | exitTo: ["s-exit-to"], 123 | exit: ["s-exit"], 124 | move: ["s-move"], 125 | }, 126 | {}, 127 | el, 128 | () => (capturedClassName = el.className), 129 | ); 130 | 131 | expect(el.className).toBe("s-exit s-exit-active"); 132 | expect(capturedClassName).toBe(null); 133 | 134 | flushRaf(); 135 | flushRaf(); 136 | 137 | expect(el.className).toBe("s-exit-active s-exit-to"); 138 | expect(capturedClassName).toBe(null); 139 | 140 | el.dispatchEvent(new Event("transitionend")); 141 | 142 | expect(capturedClassName).toBe("s-exit-active s-exit-to"); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /test/server.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { Transition } from "../src/index.js"; 3 | import { renderToString } from "solid-js/web"; 4 | 5 | describe("Transition", () => { 6 | it("returns elements in SSR", () => { 7 | const html = renderToString(() => ( 8 | 9 |
hello
10 |
11 | )); 12 | 13 | expect(html).toBe("
hello
"); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "rootDir": "src", 6 | "outDir": "dist", 7 | "noEmit": false 8 | }, 9 | "include": ["src"], 10 | "exclude": ["node_modules", "dist"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "NodeNext", 4 | "target": "ESNext", 5 | "moduleResolution": "NodeNext", 6 | "lib": ["DOM", "ESNext"], 7 | "types": [], 8 | "newLine": "LF", 9 | "noEmit": true, 10 | "allowSyntheticDefaultImports": true, 11 | "strict": true, 12 | "esModuleInterop": true, 13 | "isolatedModules": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noUncheckedIndexedAccess": true, 16 | "skipLibCheck": true, 17 | "jsx": "preserve", 18 | "jsxImportSource": "solid-js", 19 | "paths": { 20 | "solid-transition-group": ["./src/index.ts"] 21 | } 22 | }, 23 | "exclude": ["node_modules", "dist"] 24 | } 25 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import solidPlugin from "vite-plugin-solid"; 3 | 4 | export default defineConfig(({ mode }) => { 5 | // to test in server environment, run with "--mode ssr" or "--mode test:ssr" flag 6 | // loads only server.test.ts file 7 | const testSSR = mode === "test:ssr" || mode === "ssr"; 8 | 9 | return { 10 | plugins: [ 11 | solidPlugin({ 12 | // https://github.com/solidjs/solid-refresh/issues/29 13 | hot: false, 14 | // For testing SSR we need to do a SSR JSX transform 15 | solid: { generate: testSSR ? "ssr" : "dom" }, 16 | }), 17 | ], 18 | test: { 19 | watch: false, 20 | isolate: !testSSR, 21 | environment: testSSR ? "node" : "jsdom", 22 | transformMode: { web: [/\.[jt]sx$/] }, 23 | ...(testSSR 24 | ? { 25 | include: ["test/server.test.{ts,tsx}"], 26 | } 27 | : { 28 | include: ["test/*.test.{ts,tsx}"], 29 | exclude: ["test/server.test.{ts,tsx}"], 30 | }), 31 | }, 32 | resolve: { 33 | conditions: testSSR ? ["node"] : ["browser", "development"], 34 | }, 35 | }; 36 | }); 37 | --------------------------------------------------------------------------------