Skip to content

Commit b6cbb96

Browse files
authored
Merge pull request #180 from humanspeak/initial-creation
Enhancement: Exit Animations
2 parents 38760ce + 998ab1e commit b6cbb96

File tree

22 files changed

+928
-235
lines changed

22 files changed

+928
-235
lines changed

.github/workflows/cache-cleanup.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ on:
99

1010
jobs:
1111
cleanup:
12-
runs-on: ubuntu-24.04
12+
runs-on: ubuntu-latest
1313
steps:
1414
- name: Cleanup
1515
env:

.github/workflows/codeql.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ on:
1616
jobs:
1717
analyze:
1818
name: Analyze
19-
runs-on: ubuntu-24.04
19+
runs-on: ubuntu-latest
2020
if: github.event_name == 'push' || github.event_name == 'schedule' || github.event.pull_request.head.repo.full_name != github.repository
2121

2222
steps:

.github/workflows/lint-check.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ on:
2323

2424
jobs:
2525
lint:
26-
runs-on: ubuntu-24.04
26+
runs-on: ubuntu-latest
2727

2828
steps:
2929
- name: Checkout code

.github/workflows/stale-pr.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ on:
99

1010
jobs:
1111
stale:
12-
runs-on: ubuntu-24.04
12+
runs-on: ubuntu-latest
1313

1414
steps:
1515
- uses: actions/stale@v9

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,5 @@ junit-playwright.xml
3636

3737
# Custom
3838
.notes
39-
docs/src/lib/sitemap-manifest.json
39+
docs/src/lib/sitemap-manifest.json
40+
.playwright-mcp/

PRD.md

Lines changed: 85 additions & 187 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,37 @@ Svelte Motion supports minimal layout animations via FLIP using the `layout` pro
5050
- **`layout`**: smoothly animates translation and scale between layout changes (size and position).
5151
- **`layout="position"`**: only animates translation (no scale).
5252

53+
### Exit Animations (AnimatePresence)
54+
55+
Animate elements as they leave the DOM using `AnimatePresence`. This mirrors Motion’s React API and docs for exit animations ([reference](https://motion.dev/docs/react)).
56+
57+
```svelte
58+
<script lang="ts">
59+
import { motion, AnimatePresence } from '$lib'
60+
let show = $state(true)
61+
</script>
62+
63+
<AnimatePresence>
64+
{#if show}
65+
<motion.div
66+
initial={{ opacity: 0, scale: 0.9 }}
67+
animate={{ opacity: 1, scale: 1 }}
68+
exit={{ opacity: 0, scale: 0.9 }}
69+
transition={{ duration: 0.5 }}
70+
class="size-24 rounded-md bg-cyan-400"
71+
/>
72+
{/if}
73+
</AnimatePresence>
74+
75+
<motion.button whileTap={{ scale: 0.97 }} onclick={() => (show = !show)}>Toggle</motion.button>
76+
```
77+
78+
- The exit animation is driven by `exit` and will play when the element unmounts.
79+
- Transition precedence (merged before running exit):
80+
- `exit.transition` (highest precedence)
81+
- component `transition` (merged with `MotionConfig`)
82+
- fallback default `{ duration: 0.35 }`
83+
5384
#### Current Limitations
5485

5586
Some Motion features are not yet implemented:
@@ -84,6 +115,7 @@ This package carefully selects its dependencies to provide a robust and maintain
84115
| [Fancy Like Button](https://github.com/DRlFTER/fancyLikeButton) | `/tests/random/fancy-like-button` | [View Example](https://svelte.dev/playground/c34b7e53d41c48b0ab1eaf21ca120c6e?version=5.38.10) |
85116
| [Keyframes (square → circle → square; scale 1→2→1)](https://motion.dev/docs/react-animation#keyframes) | `/tests/motion/keyframes` | [View Example](https://svelte.dev/playground/05595ce0db124c1cbbe4e74fda68d717?version=5.38.10) |
86117
| [Animated Border Gradient (conic-gradient rotate)](https://www.youtube.com/watch?v=OgQI1-9T6ZA) | `/tests/random/animated-border-gradient` | [View Example](https://svelte.dev/playground/6983a61b4c35441b8aa72a971de01a23?version=5.38.10) |
118+
| [Exit Animation](https://motion.dev/docs/react#exit-animations) | `/tests/motion/animate-presence` | [View Example](https://svelte.dev/playground/ef277e283d864653ace54e7453801601?version=5.38.10) |
87119

88120
## Interactions
89121

docs/src/app.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,11 @@
254254
.prose ol > li::marker {
255255
color: var(--color-accent);
256256
}
257+
258+
.prose code::before,
259+
.prose code::after {
260+
content: '' !important;
261+
}
257262
}
258263

259264
/* Orb backgrounds using the brand color */

docs/src/routes/docs/+page.svx

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ description: "Svelte Motion is a Svelte animation library for building smooth, p
44
---
55

66
<script>
7-
import { motion } from '@humanspeak/svelte-motion';
7+
import { motion, AnimatePresence } from '@humanspeak/svelte-motion';
88
</script>
99

1010
# Get started with Svelte Motion
@@ -34,13 +34,13 @@ Svelte gives you the power to build dynamic user interfaces with excellent perfo
3434
Here's when it's the right choice for your project.
3535

3636
- **Built for Svelte.** While other animation libraries can feel foreign, Svelte Motion's API feels like a natural extension of Svelte. Animations can be linked directly to reactive state and props.
37-
- **Hardware-acceleration.** Svelte Motion leverages the same high-performance browser animations as pure CSS, ensuring your UIs stay smooth and snappy. You get the power of 120fps animations with a much simpler and more expressive API.
37+
- **Hardware-acceleration.** Svelte Motion leverages high-performance browser animations, ensuring your UIs stay smooth and snappy.
3838
- **Animate anything.** CSS has hard limits. There are values you can't animate, keyframes you can't interrupt, staggers that must be hardcoded. Svelte Motion provides a single, consistent API that handles everything from simple transitions to advanced scroll, layout, and gesture-driven effects.
39-
- **App-like gestures.** Standard CSS `:hover` events are unreliable on touch devices. Svelte Motion provides robust, cross-device gesture recognizers for tap, drag, and hover, making it easy to build interactions that feel native and intuitive on any device.
39+
- **App-like gestures.** Standard CSS `:hover` events can be unreliable on touch devices. Svelte Motion provides robust, cross-device gesture recognizers for tap and hover.
4040

4141
### When is CSS a better choice?
4242

43-
For simple, self-contained effects (like a color change on hover) a standard CSS transition is a lightweight solution. The strength of Svelte Motion is that it can do these simple kinds of animations but also scale to anything you can imagine. All with the same easy to write and maintain API.
43+
For simple, self-contained effects (like a color change on hover) a standard CSS transition is a lightweight solution. The strength of Svelte Motion is that it can do these simple kinds of animations but also scale to anything you can imagine—using the same easy to write and maintain API.
4444

4545
## Install
4646

@@ -52,7 +52,7 @@ npm install @humanspeak/svelte-motion
5252

5353
Features can now be imported:
5454

55-
```javascript
55+
```js
5656
import { motion } from '@humanspeak/svelte-motion'
5757
```
5858

@@ -107,14 +107,79 @@ Or disable this initial animation entirely by setting `initial` to `false`.
107107

108108
## Hover & tap animation
109109

110-
`<motion>` extends Svelte's event system with powerful gesture animations. It currently supports hover, tap, focus, and drag.
110+
`<motion>` extends Svelte's event system with powerful gesture animations. It currently supports hover and tap.
111111

112112
```svelte
113113
<motion.button
114114
whileHover={{ scale: 1.1 }}
115115
whileTap={{ scale: 0.95 }}
116-
onHoverStart={() => console.log('hover started!')}
117116
>
118117
Interactive button
119118
</motion.button>
120119
```
120+
121+
## Scroll animation
122+
123+
Svelte Motion supports both styles of scroll animations: **scroll-triggered** and **scroll-linked**.
124+
125+
To trigger an animation on scroll, you can bind a class, style, or state change based on an `IntersectionObserver`, or pair Motion with stores to flip between `initial` and `animate` states.
126+
127+
To link a value directly to scroll position, use utilities like `useTime`/`useTransform` to drive an animated style.
128+
129+
```svelte
130+
<script>
131+
import { motion, useTime, useTransform } from '@humanspeak/svelte-motion'
132+
const time = useTime()
133+
const progress = useTransform(() => ($time % 4000) / 4000, [time])
134+
</script>
135+
136+
<motion.div style={`transform: scaleX(${$progress})`}/>
137+
```
138+
139+
## Layout animation
140+
141+
Animate between changes in layout using transforms. It's as easy as applying the `layout` prop.
142+
143+
```svelte
144+
<motion.div layout />
145+
```
146+
147+
- `layout` animates translation and scale between layout changes.
148+
- `layout="position"` animates translation only.
149+
150+
## Exit animations
151+
152+
By wrapping motion components with `AnimatePresence` you gain access to exit animations. This allows you to animate elements as they're removed from the DOM. ([Reference](https://motion.dev/docs/react))
153+
154+
```svelte
155+
<script>
156+
import { motion, AnimatePresence } from '@humanspeak/svelte-motion'
157+
let show = $state(true)
158+
</script>
159+
160+
<AnimatePresence>
161+
{#if show}
162+
<motion.div
163+
initial={{ opacity: 0, scale: 0.9 }}
164+
animate={{ opacity: 1, scale: 1 }}
165+
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.6 } }}
166+
transition={{ duration: 0.4 }}
167+
class="size-24 rounded-md bg-cyan-400"
168+
/>
169+
{/if}
170+
</AnimatePresence>
171+
172+
<motion.button whileTap={{ scale: 0.97 }} on:click={() => (show = !show)}>
173+
Toggle
174+
</motion.button>
175+
```
176+
177+
Transition precedence for exit timing (merged):
178+
179+
- `exit.transition` (highest precedence)
180+
- component `transition` (merged with any `MotionConfig` defaults)
181+
- fallback `{ duration: 0.35 }`
182+
183+
## Learn next
184+
185+
That's a quick overview of Svelte Motion's basic features. Next, explore animation patterns, layout, and gestures—or dive straight into our examples. For React-oriented reference material, see the Motion for React docs ([motion.dev](https://motion.dev/docs/react)).

e2e/motion/exit-animation.spec.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { expect, test } from '@playwright/test'
2+
3+
function parseScale(transform: string): number | null {
4+
const m = transform.match(/matrix\(([^)]+)\)/)
5+
if (!m) return transform === 'none' ? 1 : null
6+
const parts = m[1].split(',').map((s) => parseFloat(s.trim()))
7+
if (parts.length < 4 || Number.isNaN(parts[0]) || Number.isNaN(parts[3])) return null
8+
return parts[0]
9+
}
10+
11+
test.describe('AnimatePresence exit animation', () => {
12+
test('animates out when toggled off with preserved shape', async ({ page }) => {
13+
await page.goto(
14+
'/tests/motion/animate-presence?@humanspeak-svelte-motion-isPlaywright=true'
15+
)
16+
17+
const box = page.locator('[data-testid="box"]')
18+
const toggle = page.locator('[data-testid="toggle"]')
19+
20+
await expect(box).toBeVisible()
21+
22+
// Wait for enter animation to complete (size + opacity + scale = 1)
23+
// This is required - AnimatePresence only works if you wait for enter to finish
24+
await page.waitForFunction(
25+
() => {
26+
const el = document.querySelector('[data-testid="box"]') as HTMLElement | null
27+
if (!el) return false
28+
29+
const rect = el.getBoundingClientRect()
30+
const styles = getComputedStyle(el)
31+
const opacity = parseFloat(styles.opacity)
32+
const transform = styles.transform
33+
34+
// Must have full size (96px for size-24)
35+
if (rect.width < 90 || rect.height < 90) return false
36+
37+
// Must be fully opaque
38+
if (opacity < 0.99) return false
39+
40+
// Must be fully scaled in (scale = 1)
41+
if (transform === 'none') return true
42+
43+
// scale(s)
44+
const scaleMatch = transform.match(/scale\(([^)]+)\)/)
45+
if (scaleMatch) {
46+
const scale = parseFloat(scaleMatch[1])
47+
return scale >= 0.99
48+
}
49+
50+
// 2D matrix(a, b, c, d, tx, ty) → scaleX = a
51+
const matrixMatch = transform.match(/matrix\(([^)]+)\)/)
52+
if (matrixMatch) {
53+
const values = matrixMatch[1].split(',').map((v) => parseFloat(v.trim()))
54+
if (values.length >= 4) {
55+
const scaleX = values[0]
56+
return scaleX >= 0.99
57+
}
58+
}
59+
60+
// 3D matrix3d(m11, m12, ..., m33, tx, ty, tz) → scaleX=m11 (index 0), scaleY=m22 (index 5)
61+
const matrix3dMatch = transform.match(/matrix3d\(([^)]+)\)/)
62+
if (matrix3dMatch) {
63+
const values = matrix3dMatch[1].split(',').map((v) => parseFloat(v.trim()))
64+
if (values.length >= 16) {
65+
const scaleX = values[0]
66+
const scaleY = values[5]
67+
return scaleX >= 0.99 && scaleY >= 0.99
68+
}
69+
}
70+
71+
// Unknown transform → fail closed
72+
return false
73+
},
74+
{ timeout: 5000 }
75+
)
76+
77+
const boxRect = await box.evaluate((el) => el.getBoundingClientRect())
78+
const boxStyles = await box.evaluate((el) => {
79+
const cs = getComputedStyle(el as HTMLElement)
80+
return {
81+
borderRadius: cs.borderRadius,
82+
clipPath: cs.clipPath,
83+
boxShadow: cs.boxShadow,
84+
transform: cs.transform
85+
}
86+
})
87+
88+
// Click hide button to trigger exit animation
89+
await toggle.click()
90+
91+
// Wait for clone to appear and capture its properties immediately
92+
await page.waitForFunction(() => !!document.querySelector('[data-clone="true"]'), {
93+
timeout: 2000
94+
})
95+
96+
// Capture clone properties as soon as it appears (before it animates out)
97+
const cloneData = await page.evaluate(() => {
98+
const clone = document.querySelector('[data-clone="true"]') as HTMLElement
99+
if (!clone) return null
100+
101+
const rect = clone.getBoundingClientRect()
102+
const cs = getComputedStyle(clone)
103+
return {
104+
rect: {
105+
width: rect.width,
106+
height: rect.height,
107+
top: rect.top,
108+
left: rect.left
109+
},
110+
styles: {
111+
borderRadius: cs.borderRadius,
112+
clipPath: cs.clipPath,
113+
boxShadow: cs.boxShadow,
114+
transform: cs.transform,
115+
opacity: cs.opacity
116+
}
117+
}
118+
})
119+
120+
expect(cloneData).toBeTruthy()
121+
expect(Math.abs(cloneData!.rect.width - boxRect.width)).toBeLessThanOrEqual(2)
122+
expect(Math.abs(cloneData!.rect.height - boxRect.height)).toBeLessThanOrEqual(2)
123+
expect(cloneData!.styles.borderRadius).toBe(boxStyles.borderRadius)
124+
expect(cloneData!.styles.clipPath).toBe(boxStyles.clipPath)
125+
expect(typeof cloneData!.styles.boxShadow).toBe('string')
126+
127+
// Original box should eventually disappear from DOM after exit animation starts
128+
await expect(box).toHaveCount(0, { timeout: 2000 })
129+
130+
// Verify clone animates out and gets removed
131+
// Since the animation is fast, we'll check if clone eventually disappears
132+
const clone = page.locator('[data-clone="true"]')
133+
134+
// Try to observe animation changes, but don't fail if clone disappears quickly
135+
const start = Date.now()
136+
let sawAnimation = false
137+
while (Date.now() - start < 1500 && !sawAnimation) {
138+
try {
139+
const animationData = await page.evaluate(() => {
140+
const cloneEl = document.querySelector('[data-clone="true"]') as HTMLElement
141+
if (!cloneEl) return null
142+
const cs = getComputedStyle(cloneEl)
143+
return { opacity: cs.opacity, transform: cs.transform }
144+
})
145+
146+
if (!animationData) break // Clone was removed
147+
148+
const op = parseFloat(animationData.opacity)
149+
const scale = parseScale(animationData.transform)
150+
if (!Number.isNaN(op) && op < 1) sawAnimation = true
151+
if (scale !== null && scale < 1) sawAnimation = true
152+
} catch {
153+
break // Clone was removed during check
154+
}
155+
await page.waitForTimeout(50)
156+
}
157+
158+
// Clone should eventually be removed (this is the key test)
159+
await expect(clone).toHaveCount(0, { timeout: 3000 })
160+
})
161+
})

0 commit comments

Comments
 (0)