Skip to content

Commit 2076fae

Browse files
committed
feat: stagger entering children
1 parent d7b4d4c commit 2076fae

File tree

4 files changed

+249
-40
lines changed

4 files changed

+249
-40
lines changed

packages/motion/src/features/animation/animation.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { VisualElement } from 'framer-motion'
1313
import { animate, noop } from 'framer-motion/dom'
1414
import { createVisualElement } from '@/state/create-visual-element'
1515
import { prefersReducedMotion } from 'framer-motion/dist/es/utils/reduced-motion/state.mjs'
16+
import { calcChildStagger } from '@/features/animation/calc-child-stagger'
1617

1718
const STATE_TYPES = ['initial', 'animate', 'whileInView', 'whileHover', 'whilePress', 'whileDrag', 'whileFocus', 'exit'] as const
1819
export type StateType = typeof STATE_TYPES[number]
@@ -43,6 +44,7 @@ export class AnimationFeature extends Feature {
4344
},
4445
reducedMotionConfig: this.state.options.motionConfig.reducedMotion,
4546
})
47+
this.state.visualElement.parent?.addChild(this.state.visualElement)
4648
this.state.animateUpdates = this.animateUpdates
4749
if (this.state.isMounted())
4850
this.state.startAnimation()
@@ -75,6 +77,8 @@ export class AnimationFeature extends Feature {
7577
directAnimate,
7678
directTransition,
7779
})
80+
// The final transition to be applied to the state
81+
this.state.finalTransition = animationOptions
7882

7983
const factories = this.createAnimationFactories(prevTarget, animationOptions, controlDelay)
8084
const { getChildAnimations } = this.setupChildAnimations(animationOptions, this.state.activeStates)
@@ -259,6 +263,21 @@ export class AnimationFeature extends Feature {
259263
// Add state reference to visual element
260264
(this.state.visualElement as any).state = this.state
261265
this.updateAnimationControlsSubscription()
266+
267+
const visualElement = this.state.visualElement
268+
const parentVisualElement = visualElement.parent
269+
visualElement.enteringChildren = undefined
270+
/**
271+
* when current element is new entering child and it's controlled by parent,
272+
* animate it by delayChildren
273+
*/
274+
if (this.state.parent?.isMounted() && !visualElement.isControllingVariants && parentVisualElement?.enteringChildren?.has(visualElement)) {
275+
const { delayChildren } = this.state.parent.finalTransition;
276+
(this.animateUpdates({
277+
controlActiveState: this.state.parent.activeStates,
278+
controlDelay: calcChildStagger(parentVisualElement.enteringChildren, visualElement, delayChildren),
279+
}) as Function) ()
280+
}
262281
}
263282

264283
update() {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { VisualElement } from 'framer-motion'
2+
import type { DynamicOption } from 'motion-dom'
3+
4+
export function calcChildStagger(
5+
children: Set<VisualElement>,
6+
child: VisualElement,
7+
delayChildren?: number | DynamicOption<number>,
8+
staggerChildren: number = 0,
9+
staggerDirection: number = 1,
10+
): number {
11+
const sortedChildren = Array.from(children).sort((a, b) => a.sortNodePosition(b))
12+
const index = sortedChildren.indexOf(child)
13+
const numChildren = children.size
14+
const maxStaggerDuration = (numChildren - 1) * staggerChildren
15+
const delayIsFunction = typeof delayChildren === 'function'
16+
/**
17+
* parent may not update, so we need to clear the enteringChildren when the child is the last one
18+
*/
19+
if (index === sortedChildren.length - 1) {
20+
child.parent.enteringChildren = undefined
21+
}
22+
return delayIsFunction
23+
? delayChildren(index, numChildren)
24+
: staggerDirection === 1
25+
? index * staggerChildren
26+
: maxStaggerDuration - index * staggerChildren
27+
}

packages/motion/src/state/motion-state.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { MotionStateContext, Options } from '@/types'
1+
import type { $Transition, MotionStateContext, Options } from '@/types'
22
import { invariant } from 'hey-listen'
33
import type { DOMKeyframesDefinition, VisualElement } from 'framer-motion'
44
import { cancelFrame, frame, noop } from 'framer-motion/dom'
@@ -58,6 +58,10 @@ export class MotionState {
5858

5959
// Current animation target values
6060
public target: DOMKeyframesDefinition
61+
/**
62+
* The final transition to be applied to the state
63+
*/
64+
public finalTransition: $Transition
6165
private featureManager: FeatureManager
6266

6367
// Visual element instance from Framer Motion

playground/nuxt/pages/test.vue

Lines changed: 198 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,221 @@
1+
<script lang="tsx">
2+
// Motion-inspired fake news data
3+
const newsData = [
4+
{
5+
headline: 'Motion 12.23 revolutionizes staggered animations',
6+
subtitle:
7+
'delayChildren now accepts stagger() function enabling advanced timing control for variant children',
8+
},
9+
{
10+
headline: 'Layout animation performance breakthrough announced',
11+
subtitle:
12+
'New optimization writes directly to element.style, reducing render overhead by 40%',
13+
},
14+
{
15+
headline: 'React 19 compatibility officially confirmed',
16+
subtitle:
17+
'Motion library successfully tested with latest React version, strict mode issues resolved',
18+
},
19+
{
20+
headline: 'WAAPI animations get linear() easing upgrade',
21+
subtitle:
22+
'Custom easing functions now compile to native CSS linear() for hardware acceleration',
23+
},
24+
{
25+
headline: 'Spring animations receive visual duration control',
26+
subtitle:
27+
'New spring(visualDuration, bounce) syntax simplifies complex animation timing',
28+
},
29+
{
30+
headline: 'Memory leak eliminated in component unmounting',
31+
subtitle:
32+
'Critical fix prevents memory buildup when motion components are frequently added and removed',
33+
},
34+
{
35+
headline: 'Drag controls gain stop() and cancel() methods',
36+
subtitle:
37+
'useDragControls API expanded with imperative control over gesture interactions',
38+
},
39+
{
40+
headline: 'CSS variables support enhanced for keyframes',
41+
subtitle:
42+
'Multi-keyframe animations now fully support CSS custom properties and unit conversion',
43+
},
44+
{
45+
headline: 'AnimatePresence gets React 19 optimization',
46+
subtitle:
47+
'Exit animations now work seamlessly with concurrent rendering and Suspense boundaries',
48+
},
49+
{
50+
headline: 'Bundle size reduced with tree-shaking improvements',
51+
subtitle:
52+
'Motion-dom package achieves significant size reduction through better dead code elimination',
53+
},
54+
{
55+
headline: 'Scroll animations become lazy by default',
56+
subtitle:
57+
'Performance boost as scroll listeners now activate only when needed, reducing CPU usage',
58+
},
59+
{
60+
headline: 'Server Components support officially launched',
61+
subtitle:
62+
'New motion/react-client entrypoint enables seamless SSR and hydration workflows',
63+
},
64+
]
65+
</script>
66+
167
<script setup lang="tsx">
2-
/** @jsxImportSource vue */
368
import { ref } from 'vue'
69+
import { motion, stagger } from 'motion-v'
70+
71+
const items = ref(newsData.slice(0, 3))
72+
const isLoading = ref(false)
73+
const currentIndex = ref(3)
74+
75+
async function fetchMoreItems() {
76+
console.log('items', items.value)
77+
if (isLoading.value)
78+
return
479
5-
const isShow = ref(false)
80+
isLoading.value = true
81+
82+
// Simulate API call delay
83+
await new Promise(resolve => setTimeout(resolve, 1000))
84+
85+
// Get next batch of items (3 at a time)
86+
const nextItems = []
87+
for (let i = 0; i < 3; i++) {
88+
const index = (currentIndex.value + i) % newsData.length
89+
nextItems.push(newsData[index])
90+
}
91+
92+
items.value = [...items.value, ...nextItems]
93+
currentIndex.value = (currentIndex.value + 3) % newsData.length
94+
isLoading.value = false
95+
}
696
</script>
797

898
<template>
9-
<button @click="isShow = !isShow">
10-
show
11-
</button>
12-
<MotionConfig reduced-motion="user">
13-
<Motion
14-
class="w-[100px] h-[100px] bg-red-500"
15-
:animate="{
16-
scale: isShow ? 1 : 0.5,
99+
<div class="container">
100+
<header>
101+
<h1 class="h1">
102+
News
103+
</h1>
104+
<p class="big">
105+
The latest news from the world of Motion
106+
</p>
107+
</header>
108+
109+
<motion.main
110+
class="news-list"
111+
:variants="{
112+
hidden: {},
113+
visible: {
114+
transition: {
115+
delayChildren: stagger(0.2),
116+
},
117+
},
118+
}"
119+
initial="hidden"
120+
animate="visible"
121+
>
122+
<motion.article
123+
v-for="(item, index) in items"
124+
:key="`${item.headline}-${index}`"
125+
class="news-item"
126+
:variants="{
127+
hidden: { opacity: 0, y: 20 },
128+
visible: { opacity: 1, y: 0 },
129+
}"
130+
:transition="{ duration: 0.4, ease: 'easeOut' }"
131+
>
132+
<h3 class="h3">
133+
{{ item.headline }}
134+
</h3>
135+
<p class="small">
136+
{{ item.subtitle }}
137+
</p>
138+
</motion.article>
139+
</motion.main>
140+
141+
<motion.div
142+
:key="items.length"
143+
class="loading-spinner"
144+
:animate="{ rotate: 360 }"
145+
:transition="{
146+
duration: 1.5,
147+
repeat: Infinity,
148+
ease: 'linear',
149+
}"
150+
:style="{
151+
display: 'flex',
152+
justifyContent: 'center',
153+
alignItems: 'center',
17154
}"
18-
/>
19-
</MotionConfig>
155+
@viewport-enter="fetchMoreItems"
156+
>
157+
<div class="spinner" />
158+
</motion.div>
159+
</div>
20160
</template>
21161

22162
<style>
23163
.container {
24-
display: flex;
25-
flex-direction: column;
26-
align-items: center;
27-
gap: 20px;
164+
padding: 140px 20px;
165+
display: flex;
166+
align-items: center;
167+
flex-direction: column;
168+
width: 100%;
169+
max-width: 550px;
170+
box-sizing: border-box;
171+
gap: 40px;
172+
}
173+
174+
.container header {
175+
text-align: center;
176+
display: flex;
177+
align-items: center;
178+
flex-direction: column;
179+
}
180+
181+
.container header p {
182+
max-width: 200px;
183+
text-wrap: balance;
28184
}
29185
30-
.number {
31-
font-size: 78px;
186+
.container main {
187+
display: flex;
188+
align-items: center;
189+
flex-direction: column;
190+
align-items: stretch;
32191
}
33192
34-
.controls {
35-
display: flex;
36-
gap: 20px;
37-
border-radius: 50px;
193+
.container h3 {
194+
text-wrap: balance;
38195
}
39196
40-
.controls > div {
41-
display: flex;
42-
align-items: center;
43-
gap: 10px;
44-
font-size: 18px;
197+
.container article {
198+
border-bottom: 1px dashed #1d2628;
199+
padding: 20px 0;
200+
display: flex;
201+
align-items: flex-start;
202+
flex-direction: column;
203+
gap: 10px;
204+
flex: 1;
45205
}
46206
47-
.switch-container {
48-
width: 40px;
49-
height: 20px;
50-
border-radius: 25px;
51-
cursor: pointer;
52-
display: flex;
53-
padding: 5px;
207+
.loading-spinner {
208+
width: 50px;
209+
height: 50px;
210+
will-change: transform;
211+
position: relative;
54212
}
55213
56-
.switch-handle {
57-
width: 20px;
58-
height: 20px;
59-
background-color: #4ff0b7;
60-
border-radius: 50%;
214+
.spinner {
215+
position: absolute;
216+
inset: 0;
217+
border-radius: 50%;
218+
border: 4px solid var(--divider);
219+
border-top-color: #8df0cc;
61220
}
62221
</style>

0 commit comments

Comments
 (0)