Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 16 additions & 27 deletions packages/motion/src/state/motion-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,17 +137,12 @@ export class MotionState {
this.element = element
this.updateOptions(options)

const shouldDelay = this.options.layoutId && this.visualElement.projection.getStack()?.members.length > 0
// Mount features in parent-to-child order
this.featureManager.mount()
if (!notAnimate && this.options.animate) {
this.startAnimation?.()
}
if (this.options.layoutId) {
mountedLayoutIds.add(this.options.layoutId)
frame.render(() => {
mountedLayoutIds.clear()
})
}
}

clearAnimation() {
Expand All @@ -173,28 +168,22 @@ export class MotionState {
}

unmount(unMountChildren = false) {
/**
* Unlike React, within the same update cycle, the execution order of unmount and mount depends on the component's order in the component tree.
* Here we delay unmount for components with layoutId to ensure unmount executes after mount for layout animations.
*/
const shouldDelay = this.options.layoutId && !mountedLayoutIds.has(this.options.layoutId)
const unmount = () => {
const unmountState = () => {
if (unMountChildren) {
Array.from(this.children).reverse().forEach(this.unmountChild)
}
this.parent?.children?.delete(this)
mountedStates.delete(this.element)
this.featureManager.unmount()
this.visualElement?.unmount()
// clear animation
this.clearAnimation()
}
// Delay unmount if needed for layout animations
shouldDelay ? Promise.resolve().then(unmountState) : unmountState()
const shouldDelay = this.options.layoutId && this.visualElement.projection?.getStack().lead === this.visualElement.projection && this.visualElement.projection.isProjecting()
if (shouldDelay) {
Promise.resolve().then(() => {
this.unmount(unMountChildren)
})
return
}

unmount()
if (unMountChildren) {
Array.from(this.children).reverse().forEach(this.unmountChild)
}
this.parent?.children?.delete(this)
mountedStates.delete(this.element)
this.featureManager.unmount()
this.visualElement?.unmount()
// clear animation
this.clearAnimation()
}

private unmountChild(child: MotionState) {
Expand Down
2 changes: 1 addition & 1 deletion playground/nuxt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"motion-number-vue": "latest",
"motion-plus-vue": "0.1.0",
"motion-v": "workspace:*",
"nuxt": "^3.16.0",
"nuxt": "3.15.4",
"reka-ui": "^2.0.0",
"vue": "latest",
"vue-router": "latest"
Expand Down
1 change: 0 additions & 1 deletion playground/nuxt/pages/layout-id-tabs/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ const selectedTab = ref(tabs[0])
:initial="{ y: 10, opacity: 0 }"
:animate="{ y: 0, opacity: 1 }"
:exit="{ y: -10, opacity: 0 }"
:transition="{ duration: 3 }"
>
{{ selectedTab ? selectedTab.icon : '😋' }}
</Motion>
Expand Down
305 changes: 225 additions & 80 deletions playground/nuxt/pages/test.vue
Original file line number Diff line number Diff line change
@@ -1,93 +1,238 @@
<script setup lang="ts">
<script setup lang="tsx">
/** @jsxImportSource vue */
import { AnimatePresence, LayoutGroup, motion } from 'motion-v'
import { Tabs } from 'reka-ui/namespaced'
import { ref } from 'vue'
import { Motion } from 'motion-v'

// Tab data
const tab = ref('account')

const tabs = [
{ id: 'home', label: 'Home' },
{ id: 'about', label: 'About' },
{ id: 'services', label: 'Services' },
{ id: 'contact', label: 'Contact' },
{
value: 'account',
label: 'Account',
title: 'Account settings',
content: () => (
<div class="form-fields">
<input
type="text"
placeholder="Username"
class="input-field"
/>
<input
type="email"
placeholder="Email"
class="input-field"
/>
</div>
),
buttonText: 'Save changes',
},
{
value: 'password',
label: 'Password',
title: 'Password',
content: () => 'Change your password here. After saving, you\'ll be logged out.',
buttonText: 'Change password',
},
{
value: 'settings',
label: 'Settings',
title: 'Settings',
content: () => 'Manage your notification and privacy settings.',
buttonText: 'Update settings',
},
]

// Current active tab
const activeTab = ref(tabs[0].id)
// Hovered tab
const hoveredTab = ref<string | null>(null)

function setActiveTab(tabId: string) {
activeTab.value = tabId
}

function setHoveredTab(tabId: string | null) {
hoveredTab.value = tabId
}
</script>

<template>
<div class="min-h-screen bg-background flex items-center justify-center p-8">
<div class="w-full max-w-md">
<!-- Tab Navigation -->
<div class="relative p-1 bg-muted rounded-lg">
<!-- Active Tab Background -->

<!-- Tab List -->
<div
class="relative flex"
@mouseleave="setHoveredTab(null)"
<div class="flex justify-center items-center h-screen">
<LayoutGroup>
<Tabs.Root
v-model="tab"
as-child
>
<motion.div
class="tabs-root"
layout
:data-layout-id="tab"
>
<button
v-for="tab in tabs"
:key="tab.id"
class="relative px-3 py-1.5 text-sm font-medium transition-colors z-10 flex-1"
:class="[
activeTab === tab.id
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground',
]"
@mouseenter="setHoveredTab(tab.id)"
<Tabs.List as-child>
<motion.div
class="tabs-list"
layout
>
<Tabs.Trigger
v-for="item in tabs"
:key="item.value"
:value="item.value"
class="tabs-trigger"
>
{{ item.label }}
<motion.div
v-if="tab === item.value"
class="tabs-indicator"
layout-id="tabs-indicator"
/>
</Tabs.Trigger>
</motion.div>
</Tabs.List>

<AnimatePresence
:initial="false"
mode="wait"
>
{{ tab.label }}
<AnimatePresence mode="sync">
<Motion
v-if="hoveredTab === tab.id"
:data-id="tab.id"
class="absolute bg-background bg-blue-500 z-[-1] w-full h-full top-0 left-0 rounded-md shadow-sm border"
layout-id="hover-tab"
:initial="{
opacity: 0,
}"
:animate="{
opacity: 1,
}"
:exit="{
opacity: 0,
}"
:transition="{
layout: {
type: 'spring',
stiffness: 250,
damping: 27,
mass: 1,
},
}"
:style="{
position: 'absolute',
top: -1,
left: 0,
width: '100%',
height: '26px',
zIndex: 0,
}"
/>
</AnimatePresence>
</button>
</div>
</div>
</div>
<template
v-for="item in tabs"
:key="item.value"
>
<Tabs.Content
v-if="tab === item.value"
:value="item.value"
as-child
>
<motion.div
layout
class="tabs-content"
:initial="{ opacity: 0, filter: 'blur(5px)' }"
:animate="{ opacity: 1, filter: 'blur(0px)' }"
:exit="{
opacity: 0,
filter: 'blur(5px)',
transition: { duration: 0.15 },
}"
>
<h3>{{ item.title }}</h3>
<div class="content-wrapper">
<component :is="item.content" />
</div>
<motion.button
as="button"
class="button large"
:while-press="{ scale: 0.95 }"
>
{{ item.buttonText }}
</motion.button>
</motion.div>
</Tabs.Content>
</template>
</AnimatePresence>
</motion.div>
</Tabs.Root>
</LayoutGroup>
</div>
</template>

<style scoped>
/* Additional custom styles if needed */
<style>
.tabs-root {
display: flex;
flex-direction: column;
width: 400px;
max-width: 100%;
background-color: #0b1011;
border: 1px solid #1d2628;
overflow: hidden;
border-radius: 10px;
}

.tabs-list {
display: flex;
border-bottom: 1px solid #1d2628;
}

.tabs-trigger {
font-family: inherit;
padding: 0 20px;
height: 45px;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
line-height: 1;
color: var(--feint-text);
user-select: none;
cursor: pointer;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
transition: all 0.2s ease;
position: relative;
}

.tabs-trigger .tabs-indicator {
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 2px;
background: #ff0088;
}

.tabs-trigger:hover {
color: var(--text);
}

.tabs-trigger[data-state='active'] {
color: var(--text);
}

.tabs-content {
padding: 20px;
will-change: opacity, filter;
}

.tabs-content h3 {
margin: 0 0 10px 0;
color: var(--text);
font-size: 18px;
font-weight: 500;
}

.content-wrapper {
margin: 0 0 20px 0;
color: var(--feint-text);
font-size: 14px;
line-height: 1.5;
}

.form-fields {
display: flex;
flex-direction: column;
gap: 10px;
}

.input-field {
padding: 8px 12px;
border: 1px solid #1d2628;
border-radius: 4px;
background: #0b1011;
color: var(--text);
font-size: 14px;
}

.input-field:focus {
outline: none;
border-color: #ff0088;
transition: border-color 0.2s ease;
}

.button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 5px;
font-weight: 500;
user-select: none;
border: none;
background: #ff0088;
color: white;
cursor: pointer;
}

.button.large {
font-size: 16px;
padding: 0 20px;
line-height: 35px;
height: 35px;
}
</style>
Loading