Skip to content

Commit 9294fea

Browse files
committed
fix: improve unmount delay logic for layout animations
1 parent 5c84a10 commit 9294fea

File tree

5 files changed

+985
-243
lines changed

5 files changed

+985
-243
lines changed

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

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -137,17 +137,12 @@ export class MotionState {
137137
this.element = element
138138
this.updateOptions(options)
139139

140+
const shouldDelay = this.options.layoutId && this.visualElement.projection.getStack()?.members.length > 0
140141
// Mount features in parent-to-child order
141142
this.featureManager.mount()
142143
if (!notAnimate && this.options.animate) {
143144
this.startAnimation?.()
144145
}
145-
if (this.options.layoutId) {
146-
mountedLayoutIds.add(this.options.layoutId)
147-
frame.render(() => {
148-
mountedLayoutIds.clear()
149-
})
150-
}
151146
}
152147

153148
clearAnimation() {
@@ -173,28 +168,22 @@ export class MotionState {
173168
}
174169

175170
unmount(unMountChildren = false) {
176-
/**
177-
* Unlike React, within the same update cycle, the execution order of unmount and mount depends on the component's order in the component tree.
178-
* Here we delay unmount for components with layoutId to ensure unmount executes after mount for layout animations.
179-
*/
180-
const shouldDelay = this.options.layoutId && !mountedLayoutIds.has(this.options.layoutId)
181-
const unmount = () => {
182-
const unmountState = () => {
183-
if (unMountChildren) {
184-
Array.from(this.children).reverse().forEach(this.unmountChild)
185-
}
186-
this.parent?.children?.delete(this)
187-
mountedStates.delete(this.element)
188-
this.featureManager.unmount()
189-
this.visualElement?.unmount()
190-
// clear animation
191-
this.clearAnimation()
192-
}
193-
// Delay unmount if needed for layout animations
194-
shouldDelay ? Promise.resolve().then(unmountState) : unmountState()
171+
const shouldDelay = this.options.layoutId && this.visualElement.projection?.getStack().lead === this.visualElement.projection && this.visualElement.projection.isProjecting()
172+
if (shouldDelay) {
173+
Promise.resolve().then(() => {
174+
this.unmount(unMountChildren)
175+
})
176+
return
195177
}
196-
197-
unmount()
178+
if (unMountChildren) {
179+
Array.from(this.children).reverse().forEach(this.unmountChild)
180+
}
181+
this.parent?.children?.delete(this)
182+
mountedStates.delete(this.element)
183+
this.featureManager.unmount()
184+
this.visualElement?.unmount()
185+
// clear animation
186+
this.clearAnimation()
198187
}
199188

200189
private unmountChild(child: MotionState) {

playground/nuxt/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"motion-number-vue": "latest",
1414
"motion-plus-vue": "0.1.0",
1515
"motion-v": "workspace:*",
16-
"nuxt": "^3.16.0",
16+
"nuxt": "3.15.4",
1717
"reka-ui": "^2.0.0",
1818
"vue": "latest",
1919
"vue-router": "latest"

playground/nuxt/pages/layout-id-tabs/index.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ const selectedTab = ref(tabs[0])
3333
:initial="{ y: 10, opacity: 0 }"
3434
:animate="{ y: 0, opacity: 1 }"
3535
:exit="{ y: -10, opacity: 0 }"
36-
:transition="{ duration: 3 }"
3736
>
3837
{{ selectedTab ? selectedTab.icon : '😋' }}
3938
</Motion>

playground/nuxt/pages/test.vue

Lines changed: 225 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,238 @@
1-
<script setup lang="ts">
1+
<script setup lang="tsx">
2+
/** @jsxImportSource vue */
3+
import { AnimatePresence, LayoutGroup, motion } from 'motion-v'
4+
import { Tabs } from 'reka-ui/namespaced'
25
import { ref } from 'vue'
3-
import { Motion } from 'motion-v'
46
5-
// Tab data
7+
const tab = ref('account')
8+
69
const tabs = [
7-
{ id: 'home', label: 'Home' },
8-
{ id: 'about', label: 'About' },
9-
{ id: 'services', label: 'Services' },
10-
{ id: 'contact', label: 'Contact' },
10+
{
11+
value: 'account',
12+
label: 'Account',
13+
title: 'Account settings',
14+
content: () => (
15+
<div class="form-fields">
16+
<input
17+
type="text"
18+
placeholder="Username"
19+
class="input-field"
20+
/>
21+
<input
22+
type="email"
23+
placeholder="Email"
24+
class="input-field"
25+
/>
26+
</div>
27+
),
28+
buttonText: 'Save changes',
29+
},
30+
{
31+
value: 'password',
32+
label: 'Password',
33+
title: 'Password',
34+
content: () => 'Change your password here. After saving, you\'ll be logged out.',
35+
buttonText: 'Change password',
36+
},
37+
{
38+
value: 'settings',
39+
label: 'Settings',
40+
title: 'Settings',
41+
content: () => 'Manage your notification and privacy settings.',
42+
buttonText: 'Update settings',
43+
},
1144
]
12-
13-
// Current active tab
14-
const activeTab = ref(tabs[0].id)
15-
// Hovered tab
16-
const hoveredTab = ref<string | null>(null)
17-
18-
function setActiveTab(tabId: string) {
19-
activeTab.value = tabId
20-
}
21-
22-
function setHoveredTab(tabId: string | null) {
23-
hoveredTab.value = tabId
24-
}
2545
</script>
2646

2747
<template>
28-
<div class="min-h-screen bg-background flex items-center justify-center p-8">
29-
<div class="w-full max-w-md">
30-
<!-- Tab Navigation -->
31-
<div class="relative p-1 bg-muted rounded-lg">
32-
<!-- Active Tab Background -->
33-
34-
<!-- Tab List -->
35-
<div
36-
class="relative flex"
37-
@mouseleave="setHoveredTab(null)"
48+
<div class="flex justify-center items-center h-screen">
49+
<LayoutGroup>
50+
<Tabs.Root
51+
v-model="tab"
52+
as-child
53+
>
54+
<motion.div
55+
class="tabs-root"
56+
layout
57+
:data-layout-id="tab"
3858
>
39-
<button
40-
v-for="tab in tabs"
41-
:key="tab.id"
42-
class="relative px-3 py-1.5 text-sm font-medium transition-colors z-10 flex-1"
43-
:class="[
44-
activeTab === tab.id
45-
? 'text-foreground'
46-
: 'text-muted-foreground hover:text-foreground',
47-
]"
48-
@mouseenter="setHoveredTab(tab.id)"
59+
<Tabs.List as-child>
60+
<motion.div
61+
class="tabs-list"
62+
layout
63+
>
64+
<Tabs.Trigger
65+
v-for="item in tabs"
66+
:key="item.value"
67+
:value="item.value"
68+
class="tabs-trigger"
69+
>
70+
{{ item.label }}
71+
<motion.div
72+
v-if="tab === item.value"
73+
class="tabs-indicator"
74+
layout-id="tabs-indicator"
75+
/>
76+
</Tabs.Trigger>
77+
</motion.div>
78+
</Tabs.List>
79+
80+
<AnimatePresence
81+
:initial="false"
82+
mode="wait"
4983
>
50-
{{ tab.label }}
51-
<AnimatePresence mode="sync">
52-
<Motion
53-
v-if="hoveredTab === tab.id"
54-
:data-id="tab.id"
55-
class="absolute bg-background bg-blue-500 z-[-1] w-full h-full top-0 left-0 rounded-md shadow-sm border"
56-
layout-id="hover-tab"
57-
:initial="{
58-
opacity: 0,
59-
}"
60-
:animate="{
61-
opacity: 1,
62-
}"
63-
:exit="{
64-
opacity: 0,
65-
}"
66-
:transition="{
67-
layout: {
68-
type: 'spring',
69-
stiffness: 250,
70-
damping: 27,
71-
mass: 1,
72-
},
73-
}"
74-
:style="{
75-
position: 'absolute',
76-
top: -1,
77-
left: 0,
78-
width: '100%',
79-
height: '26px',
80-
zIndex: 0,
81-
}"
82-
/>
83-
</AnimatePresence>
84-
</button>
85-
</div>
86-
</div>
87-
</div>
84+
<template
85+
v-for="item in tabs"
86+
:key="item.value"
87+
>
88+
<Tabs.Content
89+
v-if="tab === item.value"
90+
:value="item.value"
91+
as-child
92+
>
93+
<motion.div
94+
layout
95+
class="tabs-content"
96+
:initial="{ opacity: 0, filter: 'blur(5px)' }"
97+
:animate="{ opacity: 1, filter: 'blur(0px)' }"
98+
:exit="{
99+
opacity: 0,
100+
filter: 'blur(5px)',
101+
transition: { duration: 0.15 },
102+
}"
103+
>
104+
<h3>{{ item.title }}</h3>
105+
<div class="content-wrapper">
106+
<component :is="item.content" />
107+
</div>
108+
<motion.button
109+
as="button"
110+
class="button large"
111+
:while-press="{ scale: 0.95 }"
112+
>
113+
{{ item.buttonText }}
114+
</motion.button>
115+
</motion.div>
116+
</Tabs.Content>
117+
</template>
118+
</AnimatePresence>
119+
</motion.div>
120+
</Tabs.Root>
121+
</LayoutGroup>
88122
</div>
89123
</template>
90124

91-
<style scoped>
92-
/* Additional custom styles if needed */
125+
<style>
126+
.tabs-root {
127+
display: flex;
128+
flex-direction: column;
129+
width: 400px;
130+
max-width: 100%;
131+
background-color: #0b1011;
132+
border: 1px solid #1d2628;
133+
overflow: hidden;
134+
border-radius: 10px;
135+
}
136+
137+
.tabs-list {
138+
display: flex;
139+
border-bottom: 1px solid #1d2628;
140+
}
141+
142+
.tabs-trigger {
143+
font-family: inherit;
144+
padding: 0 20px;
145+
height: 45px;
146+
flex: 1;
147+
display: flex;
148+
align-items: center;
149+
justify-content: center;
150+
font-size: 15px;
151+
line-height: 1;
152+
color: var(--feint-text);
153+
user-select: none;
154+
cursor: pointer;
155+
background: transparent;
156+
border: none;
157+
border-bottom: 2px solid transparent;
158+
transition: all 0.2s ease;
159+
position: relative;
160+
}
161+
162+
.tabs-trigger .tabs-indicator {
163+
position: absolute;
164+
bottom: -2px;
165+
left: 0;
166+
right: 0;
167+
height: 2px;
168+
background: #ff0088;
169+
}
170+
171+
.tabs-trigger:hover {
172+
color: var(--text);
173+
}
174+
175+
.tabs-trigger[data-state='active'] {
176+
color: var(--text);
177+
}
178+
179+
.tabs-content {
180+
padding: 20px;
181+
will-change: opacity, filter;
182+
}
183+
184+
.tabs-content h3 {
185+
margin: 0 0 10px 0;
186+
color: var(--text);
187+
font-size: 18px;
188+
font-weight: 500;
189+
}
190+
191+
.content-wrapper {
192+
margin: 0 0 20px 0;
193+
color: var(--feint-text);
194+
font-size: 14px;
195+
line-height: 1.5;
196+
}
197+
198+
.form-fields {
199+
display: flex;
200+
flex-direction: column;
201+
gap: 10px;
202+
}
203+
204+
.input-field {
205+
padding: 8px 12px;
206+
border: 1px solid #1d2628;
207+
border-radius: 4px;
208+
background: #0b1011;
209+
color: var(--text);
210+
font-size: 14px;
211+
}
212+
213+
.input-field:focus {
214+
outline: none;
215+
border-color: #ff0088;
216+
transition: border-color 0.2s ease;
217+
}
218+
219+
.button {
220+
display: inline-flex;
221+
align-items: center;
222+
justify-content: center;
223+
border-radius: 5px;
224+
font-weight: 500;
225+
user-select: none;
226+
border: none;
227+
background: #ff0088;
228+
color: white;
229+
cursor: pointer;
230+
}
231+
232+
.button.large {
233+
font-size: 16px;
234+
padding: 0 20px;
235+
line-height: 35px;
236+
height: 35px;
237+
}
93238
</style>

0 commit comments

Comments
 (0)