diff --git a/packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts b/packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts
new file mode 100644
index 00000000000..ba050f0f263
--- /dev/null
+++ b/packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts
@@ -0,0 +1,406 @@
+import path from 'node:path'
+import {
+  E2E_TIMEOUT,
+  setupPuppeteer,
+} from '../../../packages/vue/__tests__/e2e/e2eUtils'
+import connect from 'connect'
+import sirv from 'sirv'
+import { expect } from 'vitest'
+const { page, nextFrame, timeout, html, transitionStart } = setupPuppeteer()
+
+const duration = process.env.CI ? 200 : 50
+const buffer = process.env.CI ? 50 : 20
+const transitionFinish = (time = duration) => timeout(time + buffer)
+
+describe('vapor transition-group', () => {
+  let server: any
+  const port = '8196'
+  beforeAll(() => {
+    server = connect()
+      .use(sirv(path.resolve(import.meta.dirname, '../dist')))
+      .listen(port)
+    process.on('SIGTERM', () => server && server.close())
+  })
+
+  afterAll(() => {
+    server.close()
+  })
+
+  beforeEach(async () => {
+    const baseUrl = `http://localhost:${port}/transition-group/`
+    await page().goto(baseUrl)
+    await page().waitForSelector('#app')
+  })
+
+  test(
+    'enter',
+    async () => {
+      const btnSelector = '.enter > button'
+      const containerSelector = '.enter > div'
+
+      expect(await html(containerSelector)).toBe(
+        `
a
` +
+          `b
` +
+          `c
`,
+      )
+
+      expect(
+        (await transitionStart(btnSelector, containerSelector)).innerHTML,
+      ).toBe(
+        `a
` +
+          `b
` +
+          `c
` +
+          `d
` +
+          `e
`,
+      )
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `a
` +
+          `b
` +
+          `c
` +
+          `d
` +
+          `e
`,
+      )
+
+      await transitionFinish()
+      expect(await html(containerSelector)).toBe(
+        `a
` +
+          `b
` +
+          `c
` +
+          `d
` +
+          `e
`,
+      )
+    },
+    E2E_TIMEOUT,
+  )
+
+  test(
+    'leave',
+    async () => {
+      const btnSelector = '.leave > button'
+      const containerSelector = '.leave > div'
+
+      expect(await html(containerSelector)).toBe(
+        `a
` +
+          `b
` +
+          `c
`,
+      )
+
+      expect(
+        (await transitionStart(btnSelector, containerSelector)).innerHTML,
+      ).toBe(
+        `a
` +
+          `b
` +
+          `c
`,
+      )
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `a
` +
+          `b
` +
+          `c
`,
+      )
+      await transitionFinish()
+      expect(await html(containerSelector)).toBe(`b
`)
+    },
+    E2E_TIMEOUT,
+  )
+
+  test(
+    'enter + leave',
+    async () => {
+      const btnSelector = '.enter-leave > button'
+      const containerSelector = '.enter-leave > div'
+
+      expect(await html(containerSelector)).toBe(
+        `a
` +
+          `b
` +
+          `c
`,
+      )
+
+      expect(
+        (await transitionStart(btnSelector, containerSelector)).innerHTML,
+      ).toBe(
+        `a
` +
+          `b
` +
+          `c
` +
+          `d
`,
+      )
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `a
` +
+          `b
` +
+          `c
` +
+          `d
`,
+      )
+      await transitionFinish()
+      expect(await html(containerSelector)).toBe(
+        `b
` +
+          `c
` +
+          `d
`,
+      )
+    },
+    E2E_TIMEOUT,
+  )
+
+  test(
+    'appear',
+    async () => {
+      const btnSelector = '.appear > button'
+      const containerSelector = '.appear > div'
+
+      expect(await html('.appear')).toBe(``)
+
+      await page().evaluate(() => {
+        return (window as any).setAppear()
+      })
+
+      // appear
+      expect(await html(containerSelector)).toBe(
+        `a
` +
+          `b
` +
+          `c
`,
+      )
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `a
` +
+          `b
` +
+          `c
`,
+      )
+
+      await transitionFinish()
+      expect(await html(containerSelector)).toBe(
+        `a
` +
+          `b
` +
+          `c
`,
+      )
+
+      // enter
+      expect(
+        (await transitionStart(btnSelector, containerSelector)).innerHTML,
+      ).toBe(
+        `a
` +
+          `b
` +
+          `c
` +
+          `d
` +
+          `e
`,
+      )
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `a
` +
+          `b
` +
+          `c
` +
+          `d
` +
+          `e
`,
+      )
+
+      await transitionFinish()
+      expect(await html(containerSelector)).toBe(
+        `a
` +
+          `b
` +
+          `c
` +
+          `d
` +
+          `e
`,
+      )
+    },
+    E2E_TIMEOUT,
+  )
+
+  test(
+    'move',
+    async () => {
+      const btnSelector = '.move > button'
+      const containerSelector = '.move > div'
+
+      expect(await html(containerSelector)).toBe(
+        `a
` +
+          `b
` +
+          `c
`,
+      )
+
+      expect(
+        (await transitionStart(btnSelector, containerSelector)).innerHTML,
+      ).toBe(
+        `d
` +
+          `b
` +
+          `a
` +
+          `c
`,
+      )
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `d
` +
+          `b
` +
+          `a
` +
+          `c
`,
+      )
+      await transitionFinish(duration * 2)
+      expect(await html(containerSelector)).toBe(
+        `d
` +
+          `b
` +
+          `a
`,
+      )
+    },
+    E2E_TIMEOUT,
+  )
+
+  test('dynamic name', async () => {
+    const btnSelector = '.dynamic-name button.toggleBtn'
+    const btnChangeName = '.dynamic-name button.changeNameBtn'
+    const containerSelector = '.dynamic-name > div'
+
+    expect(await html(containerSelector)).toBe(
+      `a
` + `b
` + `c
`,
+    )
+
+    // invalid name
+    expect(
+      (await transitionStart(btnSelector, containerSelector)).innerHTML,
+    ).toBe(`b
` + `c
` + `a
`)
+
+    // change name
+    expect(
+      (await transitionStart(btnChangeName, containerSelector)).innerHTML,
+    ).toBe(
+      `a
` +
+        `b
` +
+        `c
`,
+    )
+
+    await transitionFinish()
+    expect(await html(containerSelector)).toBe(
+      `a
` +
+        `b
` +
+        `c
`,
+    )
+  })
+
+  test('events', async () => {
+    const btnSelector = '.events > button'
+    const containerSelector = '.events > div'
+
+    expect(await html('.events')).toBe(``)
+
+    await page().evaluate(() => {
+      return (window as any).setAppear()
+    })
+
+    // appear
+    expect(await html(containerSelector)).toBe(
+      `a
` +
+        `b
` +
+        `c
`,
+    )
+    await nextFrame()
+    expect(await html(containerSelector)).toBe(
+      `a
` +
+        `b
` +
+        `c
`,
+    )
+
+    let calls = await page().evaluate(() => {
+      return (window as any).getCalls()
+    })
+    expect(calls).toContain('beforeAppear')
+    expect(calls).toContain('onAppear')
+    expect(calls).not.toContain('afterAppear')
+
+    await transitionFinish()
+    expect(await html(containerSelector)).toBe(
+      `a
` +
+        `b
` +
+        `c
`,
+    )
+
+    expect(
+      await page().evaluate(() => {
+        return (window as any).getCalls()
+      }),
+    ).toContain('afterAppear')
+
+    // enter + leave
+    expect(
+      (await transitionStart(btnSelector, containerSelector)).innerHTML,
+    ).toBe(
+      `a
` +
+        `b
` +
+        `c
` +
+        `d
`,
+    )
+
+    calls = await page().evaluate(() => {
+      return (window as any).getCalls()
+    })
+    expect(calls).toContain('beforeLeave')
+    expect(calls).toContain('onLeave')
+    expect(calls).not.toContain('afterLeave')
+    expect(calls).toContain('beforeEnter')
+    expect(calls).toContain('onEnter')
+    expect(calls).not.toContain('afterEnter')
+
+    await nextFrame()
+    expect(await html(containerSelector)).toBe(
+      `a
` +
+        `b
` +
+        `c
` +
+        `d
`,
+    )
+    calls = await page().evaluate(() => {
+      return (window as any).getCalls()
+    })
+    expect(calls).not.toContain('afterLeave')
+    expect(calls).not.toContain('afterEnter')
+
+    await transitionFinish()
+    expect(await html(containerSelector)).toBe(
+      `b
` +
+        `c
` +
+        `d
`,
+    )
+
+    calls = await page().evaluate(() => {
+      return (window as any).getCalls()
+    })
+    expect(calls).toContain('afterLeave')
+    expect(calls).toContain('afterEnter')
+  })
+
+  test('interop: render vdom component', async () => {
+    const btnSelector = '.interop > button'
+    const containerSelector = '.interop > div'
+
+    expect(await html(containerSelector)).toBe(
+      `` +
+        `` +
+        ``,
+    )
+
+    expect(
+      (await transitionStart(btnSelector, containerSelector)).innerHTML,
+    ).toBe(
+      `` +
+        `` +
+        `` +
+        ``,
+    )
+
+    await nextFrame()
+    expect(await html(containerSelector)).toBe(
+      `` +
+        `` +
+        `` +
+        ``,
+    )
+
+    await transitionFinish()
+    expect(await html(containerSelector)).toBe(
+      `` +
+        `` +
+        ``,
+    )
+  })
+})
diff --git a/packages-private/vapor-e2e-test/__tests__/transition.spec.ts b/packages-private/vapor-e2e-test/__tests__/transition.spec.ts
new file mode 100644
index 00000000000..0bfc30598cc
--- /dev/null
+++ b/packages-private/vapor-e2e-test/__tests__/transition.spec.ts
@@ -0,0 +1,1660 @@
+import path from 'node:path'
+import {
+  E2E_TIMEOUT,
+  setupPuppeteer,
+} from '../../../packages/vue/__tests__/e2e/e2eUtils'
+import connect from 'connect'
+import sirv from 'sirv'
+import { nextTick } from 'vue'
+const {
+  page,
+  classList,
+  text,
+  nextFrame,
+  timeout,
+  isVisible,
+  count,
+  html,
+  transitionStart,
+  waitForElement,
+  click,
+} = setupPuppeteer()
+
+const duration = process.env.CI ? 200 : 50
+const buffer = process.env.CI ? 50 : 20
+const transitionFinish = (time = duration) => timeout(time + buffer)
+
+describe('vapor transition', () => {
+  let server: any
+  const port = '8195'
+  beforeAll(() => {
+    server = connect()
+      .use(sirv(path.resolve(import.meta.dirname, '../dist')))
+      .listen(port)
+    process.on('SIGTERM', () => server && server.close())
+  })
+
+  afterAll(() => {
+    server.close()
+  })
+
+  beforeEach(async () => {
+    const baseUrl = `http://localhost:${port}/transition/`
+    await page().goto(baseUrl)
+    await page().waitForSelector('#app')
+  })
+
+  describe('transition with v-if', () => {
+    test(
+      'basic transition',
+      async () => {
+        const btnSelector = '.if-basic > button'
+        const containerSelector = '.if-basic > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe(
+          `content
`,
+        )
+
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'v-leave-from', 'v-leave-active'])
+
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'v-leave-active',
+          'v-leave-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe('')
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'v-enter-from', 'v-enter-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'v-enter-active',
+          'v-enter-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'named transition',
+      async () => {
+        const btnSelector = '.if-named > button'
+        const containerSelector = '.if-named > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-leave-active',
+          'test-leave-to',
+        ])
+
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe('')
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-enter-active',
+          'test-enter-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'custom transition classes',
+      async () => {
+        const btnSelector = '.if-custom-classes > button'
+        const containerSelector = '.if-custom-classes > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'bye-from', 'bye-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'bye-active',
+          'bye-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe('')
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'hello-from', 'hello-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'hello-active',
+          'hello-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'transition with dynamic name',
+      async () => {
+        const btnSelector = '.if-dynamic-name > button.toggle'
+        const btnChangeNameSelector = '.if-dynamic-name > button.change'
+        const containerSelector = '.if-dynamic-name > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-leave-active',
+          'test-leave-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe('')
+
+        // enter
+        await click(btnChangeNameSelector)
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'changed-enter-from', 'changed-enter-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'changed-enter-active',
+          'changed-enter-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'transition events without appear',
+      async () => {
+        const btnSelector = '.if-events-without-appear > button'
+        const containerSelector = '.if-events-without-appear > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+
+        let calls = await page().evaluate(() => {
+          return (window as any).getCalls('withoutAppear')
+        })
+        expect(calls).toStrictEqual(['beforeLeave', 'onLeave'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-leave-active',
+          'test-leave-to',
+        ])
+
+        expect(
+          await page().evaluate(() => {
+            return (window as any).getCalls('withoutAppear')
+          }),
+        ).not.contain('afterLeave')
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe('')
+        expect(
+          await page().evaluate(() => {
+            return (window as any).getCalls('withoutAppear')
+          }),
+        ).toStrictEqual(['beforeLeave', 'onLeave', 'afterLeave'])
+
+        await page().evaluate(() => {
+          ;(window as any).resetCalls('withoutAppear')
+        })
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('withoutAppear')
+        })
+        expect(calls).toStrictEqual(['beforeEnter', 'onEnter'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-enter-active',
+          'test-enter-to',
+        ])
+        expect(
+          await page().evaluate(() => {
+            return (window as any).getCalls('withoutAppear')
+          }),
+        ).not.contain('afterEnter')
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+        expect(
+          await page().evaluate(() => {
+            return (window as any).getCalls('withoutAppear')
+          }),
+        ).toStrictEqual(['beforeEnter', 'onEnter', 'afterEnter'])
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'events with arguments',
+      async () => {
+        const btnSelector = '.if-events-with-args > button'
+        const containerSelector = '.if-events-with-args > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+
+        // leave
+        await click(btnSelector)
+        let calls = await page().evaluate(() => {
+          return (window as any).getCalls('withArgs')
+        })
+        expect(calls).toStrictEqual(['beforeLeave', 'onLeave'])
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'before-leave',
+          'leave',
+        ])
+
+        await timeout(200 + buffer)
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('withArgs')
+        })
+        expect(calls).toStrictEqual(['beforeLeave', 'onLeave', 'afterLeave'])
+        expect(await html(containerSelector)).toBe('')
+
+        await page().evaluate(() => {
+          ;(window as any).resetCalls('withArgs')
+        })
+
+        // enter
+        await click(btnSelector)
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('withArgs')
+        })
+        expect(calls).toStrictEqual(['beforeEnter', 'onEnter'])
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'before-enter',
+          'enter',
+        ])
+
+        await timeout(200 + buffer)
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('withArgs')
+        })
+        expect(calls).toStrictEqual(['beforeEnter', 'onEnter', 'afterEnter'])
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'onEnterCancelled',
+      async () => {
+        const btnSelector = '.if-enter-cancelled > button'
+        const containerSelector = '.if-enter-cancelled > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe('')
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-enter-active',
+          'test-enter-to',
+        ])
+
+        // cancel (leave)
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+        let calls = await page().evaluate(() => {
+          return (window as any).getCalls('enterCancel')
+        })
+        expect(calls).toStrictEqual(['enterCancelled'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-leave-active',
+          'test-leave-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe('')
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'transition on appear',
+      async () => {
+        const btnSelector = '.if-appear > button'
+        const containerSelector = '.if-appear > div'
+        const childSelector = `${containerSelector} > div`
+
+        // appear
+        expect(await classList(childSelector)).contains('test-appear-active')
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-leave-active',
+          'test-leave-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe('')
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-enter-active',
+          'test-enter-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'transition events with appear',
+      async () => {
+        const btnSelector = '.if-events-with-appear > button'
+        const containerSelector = '.if-events-with-appear > div'
+        const childSelector = `${containerSelector} > div`
+        // appear
+        expect(await classList(childSelector)).contains('test-appear-active')
+        let calls = await page().evaluate(() => {
+          return (window as any).getCalls('withAppear')
+        })
+        expect(calls).toStrictEqual(['beforeAppear', 'onAppear'])
+
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('withAppear')
+        })
+        expect(calls).toStrictEqual(['beforeAppear', 'onAppear', 'afterAppear'])
+
+        await page().evaluate(() => {
+          ;(window as any).resetCalls('withAppear')
+        })
+
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('withAppear')
+        })
+        expect(calls).toStrictEqual(['beforeLeave', 'onLeave'])
+
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-leave-active',
+          'test-leave-to',
+        ])
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('withAppear')
+        })
+        expect(calls).not.contain('afterLeave')
+
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe('')
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('withAppear')
+        })
+        expect(calls).toStrictEqual(['beforeLeave', 'onLeave', 'afterLeave'])
+
+        await page().evaluate(() => {
+          ;(window as any).resetCalls('withAppear')
+        })
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('withAppear')
+        })
+        expect(calls).toStrictEqual(['beforeEnter', 'onEnter'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-enter-active',
+          'test-enter-to',
+        ])
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('withAppear')
+        })
+        expect(calls).not.contain('afterEnter')
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('withAppear')
+        })
+        expect(calls).toStrictEqual(['beforeEnter', 'onEnter', 'afterEnter'])
+      },
+      E2E_TIMEOUT,
+    )
+    test(
+      'css: false',
+      async () => {
+        const btnSelector = '.if-css-false > button'
+        const containerSelector = '.if-css-false > div'
+        const childSelector = `${containerSelector} > div`
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+
+        // leave
+        await click(btnSelector)
+        let calls = await page().evaluate(() => {
+          return (window as any).getCalls('cssFalse')
+        })
+        expect(calls).toStrictEqual(['beforeLeave', 'onLeave', 'afterLeave'])
+        expect(await html(containerSelector)).toBe('')
+
+        await page().evaluate(() => {
+          ;(window as any).resetCalls('cssFalse')
+        })
+
+        // enter
+        await transitionStart(btnSelector, childSelector)
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('cssFalse')
+        })
+        expect(calls).toStrictEqual(['beforeEnter', 'onEnter', 'afterEnter'])
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'no transition detected',
+      async () => {
+        const btnSelector = '.if-no-trans > button'
+        const containerSelector = '.if-no-trans > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe('content
')
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['noop-leave-from', 'noop-leave-active'])
+        await nextFrame()
+        expect(await html(containerSelector)).toBe('')
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['noop-enter-from', 'noop-enter-active'])
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'animations',
+      async () => {
+        const btnSelector = '.if-ani > button'
+        const containerSelector = '.if-ani > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe('content
')
+
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test-anim-leave-from', 'test-anim-leave-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test-anim-leave-active',
+          'test-anim-leave-to',
+        ])
+        await transitionFinish(duration * 2)
+        expect(await html(containerSelector)).toBe('')
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test-anim-enter-from', 'test-anim-enter-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test-anim-enter-active',
+          'test-anim-enter-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'explicit transition type',
+      async () => {
+        const btnSelector = '.if-ani-explicit-type > button'
+        const containerSelector = '.if-ani-explicit-type > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe('content
')
+
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual([
+          'test-anim-long-leave-from',
+          'test-anim-long-leave-active',
+        ])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test-anim-long-leave-active',
+          'test-anim-long-leave-to',
+        ])
+
+        if (!process.env.CI) {
+          await new Promise(r => {
+            setTimeout(r, duration - buffer)
+          })
+          expect(await classList(childSelector)).toStrictEqual([
+            'test-anim-long-leave-active',
+            'test-anim-long-leave-to',
+          ])
+        }
+
+        await transitionFinish(duration * 2)
+        expect(await html(containerSelector)).toBe('')
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual([
+          'test-anim-long-enter-from',
+          'test-anim-long-enter-active',
+        ])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test-anim-long-enter-active',
+          'test-anim-long-enter-to',
+        ])
+
+        if (!process.env.CI) {
+          await new Promise(r => {
+            setTimeout(r, duration - buffer)
+          })
+          expect(await classList(childSelector)).toStrictEqual([
+            'test-anim-long-enter-active',
+            'test-anim-long-enter-to',
+          ])
+        }
+
+        await transitionFinish(duration * 2)
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test.todo('transition on SVG elements', async () => {}, E2E_TIMEOUT)
+
+    test(
+      'custom transition higher-order component',
+      async () => {
+        const btnSelector = '.if-high-order > button'
+        const containerSelector = '.if-high-order > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-leave-active',
+          'test-leave-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe('')
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-enter-active',
+          'test-enter-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'transition on child components with empty root node',
+      async () => {
+        const btnSelector = '.if-empty-root > button.toggle'
+        const btnChangeSelector = '.if-empty-root > button.change'
+        const containerSelector = '.if-empty-root > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe('')
+
+        // change view -> 'two'
+        await click(btnChangeSelector)
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-enter-active',
+          'test-enter-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'two
',
+        )
+
+        // change view -> 'one'
+        await click(btnChangeSelector)
+
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-leave-active',
+          'test-leave-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe('')
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'transition with v-if at component root-level',
+      async () => {
+        const btnSelector = '.if-at-component-root-level > button.toggle'
+        const btnChangeSelector = '.if-at-component-root-level > button.change'
+        const containerSelector = '.if-at-component-root-level > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe('')
+
+        // change view -> 'two'
+        await click(btnChangeSelector)
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-enter-active',
+          'test-enter-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'two
',
+        )
+
+        // change view -> 'one'
+        await click(btnChangeSelector)
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-leave-active',
+          'test-leave-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe('')
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'wrapping transition + fallthrough attrs',
+      async () => {
+        const btnSelector = '.if-fallthrough-attr > button'
+        const containerSelector = '.if-fallthrough-attr > div'
+
+        expect(await html(containerSelector)).toBe('content
')
+
+        await click(btnSelector)
+        // toggle again before leave finishes
+        await nextTick()
+        await click(btnSelector)
+
+        await transitionFinish(duration * 2)
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'transition + fallthrough attrs (in-out mode)',
+      async () => {
+        const btnSelector = '.if-fallthrough-attr-in-out > button'
+        const containerSelector = '.if-fallthrough-attr-in-out > div'
+
+        expect(await html(containerSelector)).toBe('one
')
+
+        // toggle
+        await click(btnSelector)
+        await nextTick()
+        await transitionFinish(duration * 3)
+        let calls = await page().evaluate(() => {
+          return (window as any).getCalls('ifInOut')
+        })
+        expect(calls).toStrictEqual([
+          'beforeEnter',
+          'onEnter',
+          'afterEnter',
+          'beforeLeave',
+          'onLeave',
+          'afterLeave',
+        ])
+
+        expect(await html(containerSelector)).toBe(
+          'two
',
+        )
+
+        // clear calls
+        await page().evaluate(() => {
+          ;(window as any).resetCalls('ifInOut')
+        })
+
+        // toggle back
+        await click(btnSelector)
+        await nextTick()
+        await transitionFinish(duration * 3)
+
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('ifInOut')
+        })
+        expect(calls).toStrictEqual([
+          'beforeEnter',
+          'onEnter',
+          'afterEnter',
+          'beforeLeave',
+          'onLeave',
+          'afterLeave',
+        ])
+
+        expect(await html(containerSelector)).toBe(
+          'one
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+  })
+
+  describe.todo('transition with KeepAlive', () => {})
+  describe.todo('transition with Suspense', () => {})
+  describe.todo('transition with Teleport', () => {})
+
+  describe('transition with v-show', () => {
+    test(
+      'named transition with v-show',
+      async () => {
+        const btnSelector = '.show-named > button'
+        const containerSelector = '.show-named > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+        expect(await isVisible(childSelector)).toBe(true)
+
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-leave-active',
+          'test-leave-to',
+        ])
+        await transitionFinish()
+        expect(await isVisible(childSelector)).toBe(false)
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-enter-active',
+          'test-enter-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'transition events with v-show',
+      async () => {
+        const btnSelector = '.show-events > button'
+        const containerSelector = '.show-events > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+
+        let calls = await page().evaluate(() => {
+          return (window as any).getCalls('show')
+        })
+        expect(calls).toStrictEqual(['beforeLeave', 'onLeave'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-leave-active',
+          'test-leave-to',
+        ])
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('show')
+        })
+        expect(calls).not.contain('afterLeave')
+        await transitionFinish()
+        expect(await isVisible(childSelector)).toBe(false)
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('show')
+        })
+        expect(calls).toStrictEqual(['beforeLeave', 'onLeave', 'afterLeave'])
+
+        // clear calls
+        await page().evaluate(() => {
+          ;(window as any).resetCalls('show')
+        })
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-enter-active',
+          'test-enter-to',
+        ])
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('show')
+        })
+        expect(calls).toStrictEqual(['beforeEnter', 'onEnter'])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('show')
+        })
+        expect(calls).toStrictEqual(['beforeEnter', 'onEnter', 'afterEnter'])
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'onLeaveCancelled (v-show only)',
+      async () => {
+        const btnSelector = '.show-leave-cancelled > button'
+        const containerSelector = '.show-leave-cancelled > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-leave-active',
+          'test-leave-to',
+        ])
+
+        // cancel (enter)
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+        let calls = await page().evaluate(() => {
+          return (window as any).getCalls('showLeaveCancel')
+        })
+        expect(calls).toStrictEqual(['leaveCancelled'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-enter-active',
+          'test-enter-to',
+        ])
+        await transitionFinish()
+        expect(await isVisible(childSelector)).toBe(true)
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'transition on appear with v-show',
+      async () => {
+        const btnSelector = '.show-appear > button'
+        const containerSelector = '.show-appear > div'
+        const childSelector = `${containerSelector} > div`
+
+        let calls = await page().evaluate(() => {
+          return (window as any).getCalls('showAppear')
+        })
+        expect(calls).toStrictEqual(['beforeEnter', 'onEnter'])
+
+        // appear
+        expect(await classList(childSelector)).contains('test-appear-active')
+
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('showAppear')
+        })
+        expect(calls).toStrictEqual(['beforeEnter', 'onEnter', 'afterEnter'])
+
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-leave-active',
+          'test-leave-to',
+        ])
+        await transitionFinish()
+        expect(await isVisible(childSelector)).toBe(false)
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-enter-active',
+          'test-enter-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'transition events should not call onEnter with v-show false',
+      async () => {
+        const btnSelector = '.show-appear-not-enter > button'
+        const containerSelector = '.show-appear-not-enter > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await isVisible(childSelector)).toBe(false)
+        let calls = await page().evaluate(() => {
+          return (window as any).getCalls('notEnter')
+        })
+        expect(calls).toStrictEqual([])
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('notEnter')
+        })
+        expect(calls).toStrictEqual(['beforeEnter', 'onEnter'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-enter-active',
+          'test-enter-to',
+        ])
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('notEnter')
+        })
+        expect(calls).not.contain('afterEnter')
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+        calls = await page().evaluate(() => {
+          return (window as any).getCalls('notEnter')
+        })
+        expect(calls).toStrictEqual(['beforeEnter', 'onEnter', 'afterEnter'])
+      },
+      E2E_TIMEOUT,
+    )
+  })
+
+  describe('explicit durations', () => {
+    test(
+      'single value',
+      async () => {
+        const btnSelector = '.duration-single-value > button'
+        const containerSelector = '.duration-single-value > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-leave-active',
+          'test-leave-to',
+        ])
+        await transitionFinish(duration * 2)
+        expect(await html(containerSelector)).toBe('')
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-enter-active',
+          'test-enter-to',
+        ])
+        await transitionFinish(duration * 2)
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'enter with explicit durations',
+      async () => {
+        const btnSelector = '.duration-enter > button'
+        const containerSelector = '.duration-enter > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-leave-active',
+          'test-leave-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe('')
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-enter-active',
+          'test-enter-to',
+        ])
+        await transitionFinish(duration * 2)
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'leave with explicit durations',
+      async () => {
+        const btnSelector = '.duration-leave > button'
+        const containerSelector = '.duration-leave > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-leave-active',
+          'test-leave-to',
+        ])
+        await transitionFinish(duration * 2)
+        expect(await html(containerSelector)).toBe('')
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-enter-active',
+          'test-enter-to',
+        ])
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'separate enter and leave',
+      async () => {
+        const btnSelector = '.duration-enter-leave > button'
+        const containerSelector = '.duration-enter-leave > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+
+        // leave
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-leave-active',
+          'test-leave-to',
+        ])
+        await transitionFinish(duration * 2)
+        expect(await html(containerSelector)).toBe('')
+
+        // enter
+        expect(
+          (await transitionStart(btnSelector, childSelector)).classNames,
+        ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+        await nextFrame()
+        expect(await classList(childSelector)).toStrictEqual([
+          'test',
+          'test-enter-active',
+          'test-enter-to',
+        ])
+        await transitionFinish(duration * 4)
+        expect(await html(containerSelector)).toBe(
+          'content
',
+        )
+      },
+      E2E_TIMEOUT,
+    )
+  })
+
+  test(
+    'should work with keyed element',
+    async () => {
+      const btnSelector = '.keyed > button'
+      const containerSelector = '.keyed > h1'
+
+      expect(await text(containerSelector)).toContain('0')
+
+      // change key
+      expect(
+        (await transitionStart(btnSelector, containerSelector)).classNames,
+      ).toStrictEqual(['v-leave-from', 'v-leave-active'])
+
+      await nextFrame()
+      expect(await classList(containerSelector)).toStrictEqual([
+        'v-leave-active',
+        'v-leave-to',
+      ])
+
+      await transitionFinish()
+      await nextFrame()
+      expect(await text(containerSelector)).toContain('1')
+
+      // change key again
+      expect(
+        (await transitionStart(btnSelector, containerSelector)).classNames,
+      ).toStrictEqual(['v-leave-from', 'v-leave-active'])
+
+      await nextFrame()
+      expect(await classList(containerSelector)).toStrictEqual([
+        'v-leave-active',
+        'v-leave-to',
+      ])
+
+      await transitionFinish()
+      await nextFrame()
+      expect(await text(containerSelector)).toContain('2')
+    },
+    E2E_TIMEOUT,
+  )
+
+  test(
+    'should work with out-in mode',
+    async () => {
+      const btnSelector = '.out-in > button'
+      const containerSelector = '.out-in > div'
+      const childSelector = `${containerSelector} > div`
+
+      expect(await html(containerSelector)).toBe(`vapor compB
`)
+
+      // compB -> compA
+      // compB leave
+      expect(
+        (await transitionStart(btnSelector, containerSelector)).innerHTML,
+      ).toBe(`vapor compB
`)
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `vapor compB
`,
+      )
+
+      // compA enter
+      await waitForElement(childSelector, 'vapor compA', [
+        'fade-enter-from',
+        'fade-enter-active',
+      ])
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `vapor compA
`,
+      )
+
+      await transitionFinish()
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `vapor compA
`,
+      )
+
+      // compA -> compB
+      // compA leave
+      expect(
+        (await transitionStart(btnSelector, containerSelector)).innerHTML,
+      ).toBe(`vapor compA
`)
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `vapor compA
`,
+      )
+
+      // compB enter
+      await waitForElement(childSelector, 'vapor compB', [
+        'fade-enter-from',
+        'fade-enter-active',
+      ])
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `vapor compB
`,
+      )
+
+      await transitionFinish()
+      expect(await html(containerSelector)).toBe(
+        `vapor compB
`,
+      )
+    },
+    E2E_TIMEOUT,
+  )
+
+  test(
+    'should work with in-out mode',
+    async () => {
+      const btnSelector = '.in-out > button'
+      const containerSelector = '.in-out > div'
+      const childSelector = `${containerSelector} > div`
+
+      expect(await html(containerSelector)).toBe(`vapor compB
`)
+
+      // compA enter
+      expect(
+        (await transitionStart(btnSelector, containerSelector)).innerHTML,
+      ).toBe(
+        `vapor compB
vapor compA
`,
+      )
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `vapor compB
vapor compA
`,
+      )
+
+      // compB leave
+      await waitForElement(childSelector, 'vapor compB', [
+        'fade-leave-from',
+        'fade-leave-active',
+      ])
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `vapor compB
vapor compA
`,
+      )
+
+      await transitionFinish()
+      expect(await html(containerSelector)).toBe(
+        `vapor compA
`,
+      )
+    },
+    E2E_TIMEOUT,
+  )
+
+  // tests for using vdom component in createVaporApp + vaporInteropPlugin
+  describe('interop', () => {
+    test(
+      'render vdom component',
+      async () => {
+        const btnSelector = '.vdom > button'
+        const containerSelector = '.vdom > div'
+
+        expect(await html(containerSelector)).toBe(`vdom comp
`)
+
+        // comp leave
+        expect(
+          (await transitionStart(btnSelector, containerSelector)).innerHTML,
+        ).toBe(`vdom comp
`)
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `vdom comp
`,
+        )
+
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(``)
+
+        // comp enter
+        expect(
+          (await transitionStart(btnSelector, containerSelector)).innerHTML,
+        ).toBe(`vdom comp
`)
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `vdom comp
`,
+        )
+
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          `vdom comp
`,
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'switch between vdom/vapor component (out-in mode)',
+      async () => {
+        const btnSelector = '.vdom-vapor-out-in > button'
+        const containerSelector = '.vdom-vapor-out-in > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe(`vdom comp
`)
+
+        // switch to vapor comp
+        // vdom comp leave
+        expect(
+          (await transitionStart(btnSelector, containerSelector)).innerHTML,
+        ).toBe(`vdom comp
`)
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `vdom comp
`,
+        )
+
+        // vapor comp enter
+        await waitForElement(childSelector, 'vapor compA', [
+          'fade-enter-from',
+          'fade-enter-active',
+        ])
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `vapor compA
`,
+        )
+
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          `vapor compA
`,
+        )
+
+        // switch to vdom comp
+        // vapor comp leave
+        expect(
+          (await transitionStart(btnSelector, containerSelector)).innerHTML,
+        ).toBe(
+          `vapor compA
`,
+        )
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `vapor compA
`,
+        )
+
+        // vdom comp enter
+        await waitForElement(childSelector, 'vdom comp', [
+          'fade-enter-from',
+          'fade-enter-active',
+        ])
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `vdom comp
`,
+        )
+
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          `vdom comp
`,
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'switch between vdom/vapor component (in-out mode)',
+      async () => {
+        const btnSelector = '.vdom-vapor-in-out > button'
+        const containerSelector = '.vdom-vapor-in-out > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe(`vapor compA
`)
+
+        // switch to vdom comp
+        // vdom comp enter
+        expect(
+          (await transitionStart(btnSelector, containerSelector)).innerHTML,
+        ).toBe(
+          `vapor compA
vdom comp
`,
+        )
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `vapor compA
vdom comp
`,
+        )
+
+        // vapor comp leave
+        await waitForElement(childSelector, 'vapor compA', [
+          'fade-leave-from',
+          'fade-leave-active',
+        ])
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `vapor compA
vdom comp
`,
+        )
+
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          `vdom comp
`,
+        )
+
+        // switch to vapor comp
+        // vapor comp enter
+        expect(
+          (await transitionStart(btnSelector, containerSelector)).innerHTML,
+        ).toBe(
+          `vdom comp
vapor compA
`,
+        )
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `vdom comp
vapor compA
`,
+        )
+
+        // vdom comp leave
+        await waitForElement(childSelector, 'vdom comp', [
+          'fade-leave-from',
+          'fade-leave-active',
+        ])
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `vdom comp
vapor compA
`,
+        )
+
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          `vapor compA
`,
+        )
+      },
+      E2E_TIMEOUT,
+    )
+  })
+})
diff --git a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts
index 360f48085a1..e05f06e1abd 100644
--- a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts
+++ b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts
@@ -5,10 +5,23 @@ import {
 } from '../../../packages/vue/__tests__/e2e/e2eUtils'
 import connect from 'connect'
 import sirv from 'sirv'
+const {
+  page,
+  click,
+  text,
+  enterValue,
+  html,
+  transitionStart,
+  waitForElement,
+  nextFrame,
+  timeout,
+} = setupPuppeteer()
 
-describe('vdom / vapor interop', () => {
-  const { page, click, text, enterValue } = setupPuppeteer()
+const duration = process.env.CI ? 200 : 50
+const buffer = process.env.CI ? 50 : 20
+const transitionFinish = (time = duration) => timeout(time + buffer)
 
+describe('vdom / vapor interop', () => {
   let server: any
   const port = '8193'
   beforeAll(() => {
@@ -22,12 +35,15 @@ describe('vdom / vapor interop', () => {
     server.close()
   })
 
+  beforeEach(async () => {
+    const baseUrl = `http://localhost:${port}/interop/`
+    await page().goto(baseUrl)
+    await page().waitForSelector('#app')
+  })
+
   test(
     'should work',
     async () => {
-      const baseUrl = `http://localhost:${port}/interop/`
-      await page().goto(baseUrl)
-
       expect(await text('.vapor > h2')).toContain('Vapor component in VDOM')
 
       expect(await text('.vapor-prop')).toContain('hello')
@@ -81,4 +97,163 @@ describe('vdom / vapor interop', () => {
     },
     E2E_TIMEOUT,
   )
+
+  describe('vdom transition', () => {
+    test(
+      'render vapor component',
+      async () => {
+        const btnSelector = '.trans-vapor > button'
+        const containerSelector = '.trans-vapor > div'
+
+        expect(await html(containerSelector)).toBe(`vapor compA
`)
+
+        // comp leave
+        expect(
+          (await transitionStart(btnSelector, containerSelector)).innerHTML,
+        ).toBe(
+          `vapor compA
`,
+        )
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `vapor compA
`,
+        )
+
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(``)
+
+        // comp enter
+        expect(
+          (await transitionStart(btnSelector, containerSelector)).innerHTML,
+        ).toBe(`vapor compA
`)
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `vapor compA
`,
+        )
+
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          `vapor compA
`,
+        )
+      },
+      E2E_TIMEOUT,
+    )
+
+    test(
+      'switch between vdom/vapor component (out-in mode)',
+      async () => {
+        const btnSelector = '.trans-vdom-vapor-out-in > button'
+        const containerSelector = '.trans-vdom-vapor-out-in > div'
+        const childSelector = `${containerSelector} > div`
+
+        expect(await html(containerSelector)).toBe(`vdom comp
`)
+
+        // switch to vapor comp
+        // vdom comp leave
+        expect(
+          (await transitionStart(btnSelector, containerSelector)).innerHTML,
+        ).toBe(
+          `vdom comp
`,
+        )
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `vdom comp
`,
+        )
+
+        // vapor comp enter
+        await waitForElement(childSelector, 'vapor compA', [
+          'fade-enter-from',
+          'fade-enter-active',
+        ])
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `vapor compA
`,
+        )
+
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          `vapor compA
`,
+        )
+
+        // switch to vdom comp
+        // vapor comp leave
+        expect(
+          (await transitionStart(btnSelector, containerSelector)).innerHTML,
+        ).toBe(
+          `vapor compA
`,
+        )
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `vapor compA
`,
+        )
+
+        // vdom comp enter
+        await waitForElement(childSelector, 'vdom comp', [
+          'fade-enter-from',
+          'fade-enter-active',
+        ])
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `vdom comp
`,
+        )
+
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          `vdom comp
`,
+        )
+      },
+      E2E_TIMEOUT,
+    )
+  })
+
+  describe('vdom transition-group', () => {
+    test(
+      'render vapor component',
+      async () => {
+        const btnSelector = '.trans-group-vapor > button'
+        const containerSelector = '.trans-group-vapor > div'
+
+        expect(await html(containerSelector)).toBe(
+          `` +
+            `` +
+            ``,
+        )
+
+        // insert
+        expect(
+          (await transitionStart(btnSelector, containerSelector)).innerHTML,
+        ).toBe(
+          `` +
+            `` +
+            `` +
+            `` +
+            ``,
+        )
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `` +
+            `` +
+            `` +
+            `` +
+            ``,
+        )
+
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          `` +
+            `` +
+            `` +
+            `` +
+            ``,
+        )
+      },
+      E2E_TIMEOUT,
+    )
+  })
 })
diff --git a/packages-private/vapor-e2e-test/index.html b/packages-private/vapor-e2e-test/index.html
index 7dc205e5ab0..09ea6aa607a 100644
--- a/packages-private/vapor-e2e-test/index.html
+++ b/packages-private/vapor-e2e-test/index.html
@@ -1,2 +1,11 @@
 VDOM / Vapor interop
 Vapor TodoMVC
+Vapor Transition
+Vapor TransitionGroup
+
+
diff --git a/packages-private/vapor-e2e-test/interop/App.vue b/packages-private/vapor-e2e-test/interop/App.vue
index 772a6989dd7..8cf42e47549 100644
--- a/packages-private/vapor-e2e-test/interop/App.vue
+++ b/packages-private/vapor-e2e-test/interop/App.vue
@@ -1,9 +1,22 @@
 
 
 
@@ -19,4 +32,41 @@ const passSlot = ref(true)
 
     A test slot
   
+
+  
+  
+    
+      
+      
+        
+          
+        
+      
+    
+    
+      
+      
+        
+          
+        
+      
+    
+  
+    
+    
+    
+    
+    
+    
+      
+      
+      
+    
+    
+      
+      
+        
 calls.push('beforeEnter')"
+          @enter="() => calls.push('onEnter')"
+          @afterEnter="() => calls.push('afterEnter')"
+          @beforeLeave="() => calls.push('beforeLeave')"
+          @leave="() => calls.push('onLeave')"
+          @afterLeave="() => calls.push('afterLeave')"
+          @beforeAppear="() => calls.push('beforeAppear')"
+          @appear="() => calls.push('onAppear')"
+          @afterAppear="() => calls.push('afterAppear')"
+        >
+          {{ item }}
+        
+      
+    
+    
+  
+    
+  
+
diff --git a/packages-private/vapor-e2e-test/transition-group/components/VdomComp.vue b/packages-private/vapor-e2e-test/transition-group/components/VdomComp.vue
new file mode 100644
index 00000000000..afd7d55f2be
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition-group/components/VdomComp.vue
@@ -0,0 +1,9 @@
+
+
+
+  
+    
+  
+
diff --git a/packages-private/vapor-e2e-test/transition-group/index.html b/packages-private/vapor-e2e-test/transition-group/index.html
new file mode 100644
index 00000000000..79052a023ba
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition-group/index.html
@@ -0,0 +1,2 @@
+
+
diff --git a/packages-private/vapor-e2e-test/transition-group/main.ts b/packages-private/vapor-e2e-test/transition-group/main.ts
new file mode 100644
index 00000000000..efa06a296cc
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition-group/main.ts
@@ -0,0 +1,5 @@
+import { createVaporApp, vaporInteropPlugin } from 'vue'
+import App from './App.vue'
+import '../../../packages/vue/__tests__/e2e/style.css'
+
+createVaporApp(App).use(vaporInteropPlugin).mount('#app')
diff --git a/packages-private/vapor-e2e-test/transition/App.vue b/packages-private/vapor-e2e-test/transition/App.vue
new file mode 100644
index 00000000000..4855098243b
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition/App.vue
@@ -0,0 +1,528 @@
+
+
+
+  
+    
+    
+    
+    
+    
+      
+      
+      
+    
+    
+      
+        
 calls.withoutAppear.push('beforeEnter')"
+          @enter="() => calls.withoutAppear.push('onEnter')"
+          @after-enter="() => calls.withoutAppear.push('afterEnter')"
+          @beforeLeave="() => calls.withoutAppear.push('beforeLeave')"
+          @leave="() => calls.withoutAppear.push('onLeave')"
+          @afterLeave="() => calls.withoutAppear.push('afterLeave')"
+        >
+          content
+        
+      
+      
+    
+    
+      
+        
 {
+              calls.withArgs.push('beforeEnter')
+              el.classList.add('before-enter')
+            }
+          "
+          @enter="
+            (el, done) => {
+              calls.withArgs.push('onEnter')
+              el.classList.add('enter')
+              timeout(done, 200)
+            }
+          "
+          @after-enter="
+            el => {
+              calls.withArgs.push('afterEnter')
+              el.classList.add('after-enter')
+            }
+          "
+          @before-leave="
+            el => {
+              calls.withArgs.push('beforeLeave')
+              el.classList.add('before-leave')
+            }
+          "
+          @leave="
+            (el, done) => {
+              calls.withArgs.push('onLeave')
+              el.classList.add('leave')
+              timeout(done, 200)
+            }
+          "
+          @after-leave="
+            () => {
+              calls.withArgs.push('afterLeave')
+            }
+          "
+        >
+          content
+        
+      
+      
+    
+    
+      
+        
 {
+              calls.enterCancel.push('enterCancelled')
+            }
+          "
+        >
+          content
+        
+      
+      
+    
+    
+    
+      
+        
 calls.withAppear.push('beforeEnter')"
+          @enter="() => calls.withAppear.push('onEnter')"
+          @afterEnter="() => calls.withAppear.push('afterEnter')"
+          @beforeLeave="() => calls.withAppear.push('beforeLeave')"
+          @leave="() => calls.withAppear.push('onLeave')"
+          @afterLeave="() => calls.withAppear.push('afterLeave')"
+          @beforeAppear="() => calls.withAppear.push('beforeAppear')"
+          @appear="() => calls.withAppear.push('onAppear')"
+          @afterAppear="() => calls.withAppear.push('afterAppear')"
+        >
+          content
+        
+      
+      
+    
+    
+      
+        
 calls.cssFalse.push('beforeEnter')"
+          @enter="() => calls.cssFalse.push('onEnter')"
+          @afterEnter="() => calls.cssFalse.push('afterEnter')"
+          @beforeLeave="() => calls.cssFalse.push('beforeLeave')"
+          @leave="() => calls.cssFalse.push('onLeave')"
+          @afterLeave="() => calls.cssFalse.push('afterLeave')"
+        >
+          content
+        
+      
+      
+    
+    
+    
+    
+    
+    
+      
+        
+          
+        
+      
+      
+      
+    
+    
+      
+        
+          
+        
+      
+      
+      
+    
+    
+    
+      
+         calls.ifInOut.push('beforeEnter')"
+          @enter="() => calls.ifInOut.push('onEnter')"
+          @afterEnter="() => calls.ifInOut.push('afterEnter')"
+          @beforeLeave="() => calls.ifInOut.push('beforeLeave')"
+          @leave="() => calls.ifInOut.push('onLeave')"
+          @afterLeave="() => calls.ifInOut.push('afterLeave')"
+        >
+          
+        
+      
+      
+    
+    
+
+    
+    
+    
+      
+        
 calls.show.push('beforeEnter')"
+          @enter="() => calls.show.push('onEnter')"
+          @afterEnter="() => calls.show.push('afterEnter')"
+          @beforeLeave="() => calls.show.push('beforeLeave')"
+          @leave="() => calls.show.push('onLeave')"
+          @afterLeave="() => calls.show.push('afterLeave')"
+        >
+          content
+        
+      
+      
+    
+    
+      
+        
 calls.showLeaveCancel.push('leaveCancelled')"
+        >
+          content
+        
+      
+      
+    
+    
+      
+        
 calls.showAppear.push('beforeEnter')"
+          @enter="() => calls.showAppear.push('onEnter')"
+          @afterEnter="() => calls.showAppear.push('afterEnter')"
+        >
+          content
+        
+      
+      
+    
+    
+      
+        
 calls.notEnter.push('beforeEnter')"
+          @enter="() => calls.notEnter.push('onEnter')"
+          @afterEnter="() => calls.notEnter.push('afterEnter')"
+        >
+          content
+        
+      
+      
+    
+    
+
+    
+    
+    
+    
+    
+    
+
+    
+    
+      
+      
+        {{ count }}
+      
+    
+    
+
+    
+    
+      
+      
+        
+          
+        
+      
+    
+    
+      
+      
+        
+          
+        
+      
+    
+    
+
+    
+    
+      
+      
+        
+          
+        
+      
+    
+    
+      
+      
+        
+          
+        
+      
+    
+    
+      
+      
+        
+          
+          
+        
+      
+    
+    
+  
{{ msg }}
+
diff --git a/packages-private/vapor-e2e-test/transition/components/VaporCompB.vue b/packages-private/vapor-e2e-test/transition/components/VaporCompB.vue
new file mode 100644
index 00000000000..db90f993f12
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition/components/VaporCompB.vue
@@ -0,0 +1,6 @@
+
+
+  {{ msg }}
+
diff --git a/packages-private/vapor-e2e-test/transition/components/VaporSlot.vue b/packages-private/vapor-e2e-test/transition/components/VaporSlot.vue
new file mode 100644
index 00000000000..f5eff0100f8
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition/components/VaporSlot.vue
@@ -0,0 +1,8 @@
+
+
+  
+    
+  
+
diff --git a/packages-private/vapor-e2e-test/transition/components/VdomComp.vue b/packages-private/vapor-e2e-test/transition/components/VdomComp.vue
new file mode 100644
index 00000000000..cb6ec7ccad1
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition/components/VdomComp.vue
@@ -0,0 +1,6 @@
+
+
+  {{ msg }}
+
diff --git a/packages-private/vapor-e2e-test/transition/index.html b/packages-private/vapor-e2e-test/transition/index.html
new file mode 100644
index 00000000000..79052a023ba
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition/index.html
@@ -0,0 +1,2 @@
+
+
diff --git a/packages-private/vapor-e2e-test/transition/main.ts b/packages-private/vapor-e2e-test/transition/main.ts
new file mode 100644
index 00000000000..e77d51d1c03
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition/main.ts
@@ -0,0 +1,6 @@
+import { createVaporApp, vaporInteropPlugin } from 'vue'
+import App from './App.vue'
+import '../../../packages/vue/__tests__/e2e/style.css'
+import './style.css'
+
+createVaporApp(App).use(vaporInteropPlugin).mount('#app')
diff --git a/packages-private/vapor-e2e-test/transition/style.css b/packages-private/vapor-e2e-test/transition/style.css
new file mode 100644
index 00000000000..e6faf6cea53
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition/style.css
@@ -0,0 +1,35 @@
+.v-enter-active,
+.v-leave-active {
+  transition: opacity 50ms ease;
+}
+
+.v-enter-from,
+.v-leave-to {
+  opacity: 0;
+}
+
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 50ms ease;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+  opacity: 0;
+}
+
+.test-move,
+.test-enter-active,
+.test-leave-active {
+  transition: all 50ms cubic-bezier(0.55, 0, 0.1, 1);
+}
+
+.test-enter-from,
+.test-leave-to {
+  opacity: 0;
+  transform: scaleY(0.01) translate(30px, 0);
+}
+
+.test-leave-active {
+  position: absolute;
+}
diff --git a/packages-private/vapor-e2e-test/vite.config.ts b/packages-private/vapor-e2e-test/vite.config.ts
index 1e29a4dbd13..f50fccea3ce 100644
--- a/packages-private/vapor-e2e-test/vite.config.ts
+++ b/packages-private/vapor-e2e-test/vite.config.ts
@@ -14,6 +14,11 @@ export default defineConfig({
       input: {
         interop: resolve(import.meta.dirname, 'interop/index.html'),
         todomvc: resolve(import.meta.dirname, 'todomvc/index.html'),
+        transition: resolve(import.meta.dirname, 'transition/index.html'),
+        transitionGroup: resolve(
+          import.meta.dirname,
+          'transition-group/index.html',
+        ),
       },
     },
   },
diff --git a/packages/compiler-dom/src/index.ts b/packages/compiler-dom/src/index.ts
index 950901e7bf9..446a917ad7c 100644
--- a/packages/compiler-dom/src/index.ts
+++ b/packages/compiler-dom/src/index.ts
@@ -76,4 +76,5 @@ export {
 } from './errors'
 export { resolveModifiers } from './transforms/vOn'
 export { isValidHTMLNesting } from './htmlNesting'
+export { postTransformTransition } from './transforms/Transition'
 export * from '@vue/compiler-core'
diff --git a/packages/compiler-dom/src/transforms/Transition.ts b/packages/compiler-dom/src/transforms/Transition.ts
index f6cf968e372..30ea083d8fc 100644
--- a/packages/compiler-dom/src/transforms/Transition.ts
+++ b/packages/compiler-dom/src/transforms/Transition.ts
@@ -1,4 +1,5 @@
 import {
+  type CompilerError,
   type ComponentNode,
   ElementTypes,
   type IfBranchNode,
@@ -15,47 +16,55 @@ export const transformTransition: NodeTransform = (node, context) => {
   ) {
     const component = context.isBuiltInComponent(node.tag)
     if (component === TRANSITION) {
-      return () => {
-        if (!node.children.length) {
-          return
-        }
+      return postTransformTransition(node, context.onError)
+    }
+  }
+}
 
-        // warn multiple transition children
-        if (hasMultipleChildren(node)) {
-          context.onError(
-            createDOMCompilerError(
-              DOMErrorCodes.X_TRANSITION_INVALID_CHILDREN,
-              {
-                start: node.children[0].loc.start,
-                end: node.children[node.children.length - 1].loc.end,
-                source: '',
-              },
-            ),
-          )
-        }
+export function postTransformTransition(
+  node: ComponentNode,
+  onError: (error: CompilerError) => void,
+  hasMultipleChildren: (
+    node: ComponentNode,
+  ) => boolean = defaultHasMultipleChildren,
+): () => void {
+  return () => {
+    if (!node.children.length) {
+      return
+    }
+
+    if (hasMultipleChildren(node)) {
+      onError(
+        createDOMCompilerError(DOMErrorCodes.X_TRANSITION_INVALID_CHILDREN, {
+          start: node.children[0].loc.start,
+          end: node.children[node.children.length - 1].loc.end,
+          source: '',
+        }),
+      )
+    }
 
-        // check if it's s single child w/ v-show
-        // if yes, inject "persisted: true" to the transition props
-        const child = node.children[0]
-        if (child.type === NodeTypes.ELEMENT) {
-          for (const p of child.props) {
-            if (p.type === NodeTypes.DIRECTIVE && p.name === 'show') {
-              node.props.push({
-                type: NodeTypes.ATTRIBUTE,
-                name: 'persisted',
-                nameLoc: node.loc,
-                value: undefined,
-                loc: node.loc,
-              })
-            }
-          }
+    // check if it's s single child w/ v-show
+    // if yes, inject "persisted: true" to the transition props
+    const child = node.children[0]
+    if (child.type === NodeTypes.ELEMENT) {
+      for (const p of child.props) {
+        if (p.type === NodeTypes.DIRECTIVE && p.name === 'show') {
+          node.props.push({
+            type: NodeTypes.ATTRIBUTE,
+            name: 'persisted',
+            nameLoc: node.loc,
+            value: undefined,
+            loc: node.loc,
+          })
         }
       }
     }
   }
 }
 
-function hasMultipleChildren(node: ComponentNode | IfBranchNode): boolean {
+function defaultHasMultipleChildren(
+  node: ComponentNode | IfBranchNode,
+): boolean {
   // #1352 filter out potential comment nodes.
   const children = (node.children = node.children.filter(
     c =>
@@ -66,6 +75,7 @@ function hasMultipleChildren(node: ComponentNode | IfBranchNode): boolean {
   return (
     children.length !== 1 ||
     child.type === NodeTypes.FOR ||
-    (child.type === NodeTypes.IF && child.branches.some(hasMultipleChildren))
+    (child.type === NodeTypes.IF &&
+      child.branches.some(defaultHasMultipleChildren))
   )
 }
diff --git a/packages/compiler-vapor/__tests__/transforms/TransformTransition.spec.ts b/packages/compiler-vapor/__tests__/transforms/TransformTransition.spec.ts
new file mode 100644
index 00000000000..bcb7b44ce5b
--- /dev/null
+++ b/packages/compiler-vapor/__tests__/transforms/TransformTransition.spec.ts
@@ -0,0 +1,222 @@
+import { makeCompile } from './_utils'
+import {
+  transformChildren,
+  transformElement,
+  transformText,
+  transformVBind,
+  transformVIf,
+  transformVShow,
+  transformVSlot,
+} from '@vue/compiler-vapor'
+import { transformTransition } from '../../src/transforms/transformTransition'
+import { DOMErrorCodes } from '@vue/compiler-dom'
+
+const compileWithElementTransform = makeCompile({
+  nodeTransforms: [
+    transformText,
+    transformVIf,
+    transformElement,
+    transformVSlot,
+    transformChildren,
+    transformTransition,
+  ],
+  directiveTransforms: {
+    bind: transformVBind,
+    show: transformVShow,
+  },
+})
+
+describe('compiler: transition', () => {
+  test('basic', () => {
+    const { code } = compileWithElementTransform(
+      `foo
`,
+    )
+    expect(code).toMatchSnapshot()
+  })
+
+  test('v-show + appear', () => {
+    const { code } = compileWithElementTransform(
+      `foo
`,
+    )
+    expect(code).toMatchSnapshot()
+  })
+
+  test('work with v-if', () => {
+    const { code } = compileWithElementTransform(
+      `foo
`,
+    )
+
+    expect(code).toMatchSnapshot()
+    // n2 should have a key
+    expect(code).contains('n2.$key = 2')
+  })
+
+  test('work with dynamic keyed children', () => {
+    const { code } = compileWithElementTransform(
+      `
+        foo
+      `,
+    )
+
+    expect(code).toMatchSnapshot()
+    expect(code).contains('_createKeyedFragment(() => _ctx.key')
+    // should preserve key
+    expect(code).contains('n0.$key = _ctx.key')
+  })
+
+  function checkWarning(template: string, shouldWarn = true) {
+    const onError = vi.fn()
+    compileWithElementTransform(template, { onError })
+    if (shouldWarn) {
+      expect(onError).toHaveBeenCalled()
+      expect(onError.mock.calls).toMatchObject([
+        [{ code: DOMErrorCodes.X_TRANSITION_INVALID_CHILDREN }],
+      ])
+    } else {
+      expect(onError).not.toHaveBeenCalled()
+    }
+  }
+
+  test('warns if multiple children', () => {
+    checkWarning(
+      `
+        foo
+        bar
+      `,
+      true,
+    )
+  })
+
+  test('warns with v-for', () => {
+    checkWarning(
+      `
+      
+        hey
+      
+      `,
+      true,
+    )
+  })
+
+  test('warns with multiple v-if + v-for', () => {
+    checkWarning(
+      `
+      
+        hey
+        hey
+      
+      `,
+      true,
+    )
+  })
+
+  test('warns with template v-if', () => {
+    checkWarning(
+      `
+      
+        
+      
+      `,
+      true,
+    )
+  })
+
+  test('warns with multiple templates', () => {
+    checkWarning(
+      `
+      
+        
+        
+      
+      `,
+      true,
+    )
+  })
+
+  test('warns if multiple children with v-if', () => {
+    checkWarning(
+      `
+      
+        hey
+        hey
+      
+      `,
+      true,
+    )
+  })
+
+  test('does not warn with regular element', () => {
+    checkWarning(
+      `
+      
+        hey
+      
+      `,
+      false,
+    )
+  })
+
+  test('does not warn with one single v-if', () => {
+    checkWarning(
+      `
+      
+        hey
+      
+      `,
+      false,
+    )
+  })
+
+  test('does not warn with v-if v-else-if v-else', () => {
+    checkWarning(
+      `
+      
+        hey
+        hey
+        hey
+      
+      `,
+      false,
+    )
+  })
+
+  test('does not warn with v-if v-else', () => {
+    checkWarning(
+      `
+      
+        hey
+        hey
+      
+      `,
+      false,
+    )
+  })
+
+  test('inject persisted when child has v-show', () => {
+    expect(
+      compileWithElementTransform(`
+        
+          
+        
+    `).code,
+    ).toMatchSnapshot()
+  })
+
+  test('the v-if/else-if/else branches in Transition should ignore comments', () => {
+    expect(
+      compileWithElementTransform(`
+    
+      hey
+      
+      hey
+      
+      
+    
+    `).code,
+    ).toMatchSnapshot()
+  })
+})
diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/TransformTransition.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/TransformTransition.spec.ts.snap
new file mode 100644
index 00000000000..12a3f2a8e7d
--- /dev/null
+++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/TransformTransition.spec.ts.snap
@@ -0,0 +1,128 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`compiler: transition > basic 1`] = `
+"import { VaporTransition as _VaporTransition, applyVShow as _applyVShow, createComponent as _createComponent, template as _template } from 'vue';
+const t0 = _template("foo
")
+
+export function render(_ctx) {
+  const n1 = _createComponent(_VaporTransition, { persisted: () => ("") }, {
+    "default": () => {
+      const n0 = t0()
+      _applyVShow(n0, () => (_ctx.show))
+      return n0
+    }
+  }, true)
+  return n1
+}"
+`;
+
+exports[`compiler: transition > inject persisted when child has v-show 1`] = `
+"import { VaporTransition as _VaporTransition, applyVShow as _applyVShow, createComponent as _createComponent, template as _template } from 'vue';
+const t0 = _template("")
+
+export function render(_ctx) {
+  const n1 = _createComponent(_VaporTransition, { persisted: () => ("") }, {
+    "default": () => {
+      const n0 = t0()
+      _applyVShow(n0, () => (_ctx.ok))
+      return n0
+    }
+  }, true)
+  return n1
+}"
+`;
+
+exports[`compiler: transition > the v-if/else-if/else branches in Transition should ignore comments 1`] = `
+"import { VaporTransition as _VaporTransition, setInsertionState as _setInsertionState, createIf as _createIf, createComponent as _createComponent, template as _template } from 'vue';
+const t0 = _template("hey
")
+const t1 = _template("")
+const t2 = _template("")
+
+export function render(_ctx) {
+  const n16 = _createComponent(_VaporTransition, null, {
+    "default": () => {
+      const n0 = _createIf(() => (_ctx.a), () => {
+        const n2 = t0()
+        n2.$key = 2
+        return n2
+      }, () => _createIf(() => (_ctx.b), () => {
+        const n5 = t0()
+        n5.$key = 5
+        return n5
+      }, () => {
+        const n14 = t2()
+        _setInsertionState(n14, 0)
+        const n9 = _createIf(() => (_ctx.c), () => {
+          const n11 = t1()
+          return n11
+        }, () => {
+          const n13 = t1()
+          return n13
+        })
+        n14.$key = 14
+        return n14
+      }))
+      return [n0, n3, n7]
+    }
+  }, true)
+  return n16
+}"
+`;
+
+exports[`compiler: transition > v-show + appear 1`] = `
+"import { VaporTransition as _VaporTransition, applyVShow as _applyVShow, createComponent as _createComponent, template as _template } from 'vue';
+const t0 = _template("foo
")
+
+export function render(_ctx) {
+  const deferredApplyVShows = []
+  const n1 = _createComponent(_VaporTransition, {
+    appear: () => (""),
+    persisted: () => ("")
+  }, {
+    "default": () => {
+      const n0 = t0()
+      deferredApplyVShows.push(() => _applyVShow(n0, () => (_ctx.show)))
+      return n0
+    }
+  }, true)
+  deferredApplyVShows.forEach(fn => fn())
+  return n1
+}"
+`;
+
+exports[`compiler: transition > work with dynamic keyed children 1`] = `
+"import { VaporTransition as _VaporTransition, createKeyedFragment as _createKeyedFragment, createComponent as _createComponent, template as _template } from 'vue';
+const t0 = _template("foo
")
+
+export function render(_ctx) {
+  const n1 = _createComponent(_VaporTransition, null, {
+    "default": () => {
+      return _createKeyedFragment(() => _ctx.key, () => {
+        const n0 = t0()
+        n0.$key = _ctx.key
+        return n0
+      })
+    }
+  }, true)
+  return n1
+}"
+`;
+
+exports[`compiler: transition > work with v-if 1`] = `
+"import { VaporTransition as _VaporTransition, createIf as _createIf, createComponent as _createComponent, template as _template } from 'vue';
+const t0 = _template("foo
")
+
+export function render(_ctx) {
+  const n3 = _createComponent(_VaporTransition, null, {
+    "default": () => {
+      const n0 = _createIf(() => (_ctx.show), () => {
+        const n2 = t0()
+        n2.$key = 2
+        return n2
+      })
+      return n0
+    }
+  }, true)
+  return n3
+}"
+`;
diff --git a/packages/compiler-vapor/src/compile.ts b/packages/compiler-vapor/src/compile.ts
index c39037a47d8..8fa2e793321 100644
--- a/packages/compiler-vapor/src/compile.ts
+++ b/packages/compiler-vapor/src/compile.ts
@@ -26,6 +26,7 @@ import { transformVFor } from './transforms/vFor'
 import { transformComment } from './transforms/transformComment'
 import { transformSlotOutlet } from './transforms/transformSlotOutlet'
 import { transformVSlot } from './transforms/vSlot'
+import { transformTransition } from './transforms/transformTransition'
 import type { HackOptions } from './ir'
 
 export { wrapTemplate } from './transforms/utils'
@@ -54,6 +55,7 @@ export function compile(
     extend({}, resolvedOptions, {
       nodeTransforms: [
         ...nodeTransforms,
+        ...(__DEV__ ? [transformTransition] : []),
         ...(options.nodeTransforms || []), // user transforms
       ],
       directiveTransforms: extend(
diff --git a/packages/compiler-vapor/src/generators/block.ts b/packages/compiler-vapor/src/generators/block.ts
index a4f98dfdffa..40fa8da6322 100644
--- a/packages/compiler-vapor/src/generators/block.ts
+++ b/packages/compiler-vapor/src/generators/block.ts
@@ -13,6 +13,7 @@ import type { CodegenContext } from '../generate'
 import { genEffects, genOperations } from './operation'
 import { genChildren, genSelf } from './template'
 import { toValidAssetId } from '@vue/compiler-dom'
+import { genExpression } from './expression'
 
 export function genBlock(
   oper: BlockIRNode,
@@ -39,9 +40,13 @@ export function genBlockContent(
   genEffectsExtraFrag?: () => CodeFragment[],
 ): CodeFragment[] {
   const [frag, push] = buildCodeFragment()
-  const { dynamic, effect, operation, returns } = block
+  const { dynamic, effect, operation, returns, key } = block
   const resetBlock = context.enterBlock(block)
 
+  if (block.hasDeferredVShow) {
+    push(NEWLINE, `const deferredApplyVShows = []`)
+  }
+
   if (root) {
     for (let name of context.ir.component) {
       const id = toValidAssetId(name, 'component')
@@ -73,6 +78,19 @@ export function genBlockContent(
   push(...genOperations(operation, context))
   push(...genEffects(effect, context, genEffectsExtraFrag))
 
+  if (block.hasDeferredVShow) {
+    push(NEWLINE, `deferredApplyVShows.forEach(fn => fn())`)
+  }
+
+  if (dynamic.needsKey) {
+    for (const child of dynamic.children) {
+      const keyValue = key
+        ? genExpression(key, context)
+        : JSON.stringify(child.id)
+      push(NEWLINE, `n${child.id}.$key = `, ...keyValue)
+    }
+  }
+
   push(NEWLINE, `return `)
 
   const returnNodes = returns.map(n => `n${n}`)
diff --git a/packages/compiler-vapor/src/generators/component.ts b/packages/compiler-vapor/src/generators/component.ts
index fb76abaa505..aa2f6844c13 100644
--- a/packages/compiler-vapor/src/generators/component.ts
+++ b/packages/compiler-vapor/src/generators/component.ts
@@ -40,6 +40,8 @@ import { genDirectiveModifiers, genDirectivesForElement } from './directive'
 import { genBlock } from './block'
 import { genModelHandler } from './vModel'
 
+import { isBuiltInComponent } from '../utils'
+
 export function genCreateComponent(
   operation: CreateComponentIRNode,
   context: CodegenContext,
@@ -53,13 +55,12 @@ export function genCreateComponent(
   const rawProps = context.withId(() => genRawProps(props, context), ids)
 
   const inlineHandlers: CodeFragment[] = handlers.reduce(
-    (acc, { name, value }) => {
+    (acc, { name, value }: InlineHandler) => {
       const handler = genEventHandler(context, value, undefined, false)
       return [...acc, `const ${name} = `, ...handler, NEWLINE]
     },
     [],
   )
-
   return [
     NEWLINE,
     ...inlineHandlers,
@@ -92,8 +93,15 @@ export function genCreateComponent(
     } else if (operation.asset) {
       return toValidAssetId(operation.tag, 'component')
     } else {
+      const { tag } = operation
+      const builtInTag = isBuiltInComponent(tag)
+      if (builtInTag) {
+        // @ts-expect-error
+        helper(builtInTag)
+        return `_${builtInTag}`
+      }
       return genExpression(
-        extend(createSimpleExpression(operation.tag, false), { ast: null }),
+        extend(createSimpleExpression(tag, false), { ast: null }),
         context,
       )
     }
@@ -128,7 +136,10 @@ function processInlineHandlers(
         const isMemberExp = isMemberExpression(value, context.options)
         // cache inline handlers (fn expression or inline statement)
         if (!isMemberExp) {
-          const name = getUniqueHandlerName(context, `_on_${prop.key.content}`)
+          const name = getUniqueHandlerName(
+            context,
+            `_on_${prop.key.content.replace(/-/g, '_')}`,
+          )
           handlers.push({ name, value })
           ids[name] = null
           // replace the original prop value with the handler name
@@ -397,7 +408,7 @@ function genSlotBlockWithProps(oper: SlotBlockIRNode, context: CodegenContext) {
   let propsName: string | undefined
   let exitScope: (() => void) | undefined
   let depth: number | undefined
-  const { props } = oper
+  const { props, key } = oper
   const idsOfProps = new Set()
 
   if (props) {
@@ -425,11 +436,28 @@ function genSlotBlockWithProps(oper: SlotBlockIRNode, context: CodegenContext) {
         ? `${propsName}[${JSON.stringify(id)}]`
         : null),
   )
-  const blockFn = context.withId(
+  let blockFn = context.withId(
     () => genBlock(oper, context, [propsName]),
     idMap,
   )
   exitScope && exitScope()
 
+  if (key) {
+    blockFn = [
+      `() => {`,
+      INDENT_START,
+      NEWLINE,
+      `return `,
+      ...genCall(
+        context.helper('createKeyedFragment'),
+        [`() => `, ...genExpression(key, context)],
+        blockFn,
+      ),
+      INDENT_END,
+      NEWLINE,
+      `}`,
+    ]
+  }
+
   return blockFn
 }
diff --git a/packages/compiler-vapor/src/generators/vShow.ts b/packages/compiler-vapor/src/generators/vShow.ts
index 9a6ccefcded..5ff6b257dc7 100644
--- a/packages/compiler-vapor/src/generators/vShow.ts
+++ b/packages/compiler-vapor/src/generators/vShow.ts
@@ -7,12 +7,15 @@ export function genVShow(
   oper: DirectiveIRNode,
   context: CodegenContext,
 ): CodeFragment[] {
+  const { deferred, element } = oper
   return [
     NEWLINE,
-    ...genCall(context.helper('applyVShow'), `n${oper.element}`, [
+    deferred ? `deferredApplyVShows.push(() => ` : undefined,
+    ...genCall(context.helper('applyVShow'), `n${element}`, [
       `() => (`,
       ...genExpression(oper.dir.exp!, context),
       `)`,
     ]),
+    deferred ? `)` : undefined,
   ]
 }
diff --git a/packages/compiler-vapor/src/ir/index.ts b/packages/compiler-vapor/src/ir/index.ts
index 18f0139ab56..a8130be3890 100644
--- a/packages/compiler-vapor/src/ir/index.ts
+++ b/packages/compiler-vapor/src/ir/index.ts
@@ -39,6 +39,7 @@ export enum IRNodeTypes {
 
 export interface BaseIRNode {
   type: IRNodeTypes
+  key?: SimpleExpressionNode | undefined
 }
 
 export type CoreHelper = keyof typeof import('packages/runtime-dom/src')
@@ -53,6 +54,7 @@ export interface BlockIRNode extends BaseIRNode {
   effect: IREffect[]
   operation: OperationNode[]
   returns: number[]
+  hasDeferredVShow: boolean
 }
 
 export interface RootIRNode {
@@ -181,6 +183,7 @@ export interface DirectiveIRNode extends BaseIRNode {
   builtin?: boolean
   asset?: boolean
   modelType?: 'text' | 'dynamic' | 'radio' | 'checkbox' | 'select'
+  deferred?: boolean
 }
 
 export interface CreateComponentIRNode extends BaseIRNode {
@@ -259,6 +262,7 @@ export interface IRDynamicInfo {
   children: IRDynamicInfo[]
   template?: number
   hasDynamicChild?: boolean
+  needsKey?: boolean
   operation?: OperationNode
 }
 
diff --git a/packages/compiler-vapor/src/transforms/transformElement.ts b/packages/compiler-vapor/src/transforms/transformElement.ts
index 05153e729af..dcabe360938 100644
--- a/packages/compiler-vapor/src/transforms/transformElement.ts
+++ b/packages/compiler-vapor/src/transforms/transformElement.ts
@@ -1,4 +1,3 @@
-import { isValidHTMLNesting } from '@vue/compiler-dom'
 import {
   type AttributeNode,
   type ComponentNode,
@@ -11,6 +10,7 @@ import {
   createCompilerError,
   createSimpleExpression,
   isStaticArgOf,
+  isValidHTMLNesting,
 } from '@vue/compiler-dom'
 import {
   camelize,
@@ -36,7 +36,7 @@ import {
   type VaporDirectiveNode,
 } from '../ir'
 import { EMPTY_EXPRESSION } from './utils'
-import { findProp } from '../utils'
+import { findProp, isBuiltInComponent } from '../utils'
 
 export const isReservedProp: (key: string) => boolean = /*#__PURE__*/ makeMap(
   // the leading comma is intentional so empty string "" is also included
@@ -122,6 +122,12 @@ function transformComponentElement(
       asset = false
     }
 
+    const builtInTag = isBuiltInComponent(tag)
+    if (builtInTag) {
+      tag = builtInTag
+      asset = false
+    }
+
     const dotIndex = tag.indexOf('.')
     if (dotIndex > 0) {
       const ns = resolveSetupReference(tag.slice(0, dotIndex), context)
@@ -437,7 +443,9 @@ function dedupeProperties(results: DirectiveTransformResult[]): IRProp[] {
     }
     const name = prop.key.content
     const existing = knownProps.get(name)
-    if (existing) {
+    // prop names and event handler names can be the same but serve different purposes
+    // e.g. `:appear="true"` is a prop while `@appear="handler"` is an event handler
+    if (existing && existing.handler === prop.handler) {
       if (name === 'style' || name === 'class') {
         mergePropValues(existing, prop)
       }
diff --git a/packages/compiler-vapor/src/transforms/transformTransition.ts b/packages/compiler-vapor/src/transforms/transformTransition.ts
new file mode 100644
index 00000000000..60142350831
--- /dev/null
+++ b/packages/compiler-vapor/src/transforms/transformTransition.ts
@@ -0,0 +1,65 @@
+import type { NodeTransform } from '@vue/compiler-vapor'
+import { findDir, isTransitionTag } from '../utils'
+import {
+  type ElementNode,
+  ElementTypes,
+  NodeTypes,
+  isTemplateNode,
+  postTransformTransition,
+} from '@vue/compiler-dom'
+
+export const transformTransition: NodeTransform = (node, context) => {
+  if (
+    node.type === NodeTypes.ELEMENT &&
+    node.tagType === ElementTypes.COMPONENT
+  ) {
+    if (isTransitionTag(node.tag)) {
+      return postTransformTransition(
+        node,
+        context.options.onError,
+        hasMultipleChildren,
+      )
+    }
+  }
+}
+
+function hasMultipleChildren(node: ElementNode): boolean {
+  const children = (node.children = node.children.filter(
+    c =>
+      c.type !== NodeTypes.COMMENT &&
+      !(c.type === NodeTypes.TEXT && !c.content.trim()),
+  ))
+
+  const first = children[0]
+
+  // has v-for
+  if (
+    children.length === 1 &&
+    first.type === NodeTypes.ELEMENT &&
+    (findDir(first, 'for') || isTemplateNode(first))
+  ) {
+    return true
+  }
+
+  const hasElse = (node: ElementNode) =>
+    findDir(node, 'else-if') || findDir(node, 'else', true)
+
+  // has v-if/v-else-if/v-else
+  if (
+    children.every(
+      (c, index) =>
+        c.type === NodeTypes.ELEMENT &&
+        // not template
+        !isTemplateNode(c) &&
+        // not has v-for
+        !findDir(c, 'for') &&
+        // if the first child has v-if, the rest should also have v-else-if/v-else
+        (index === 0 ? findDir(c, 'if') : hasElse(c)) &&
+        !hasMultipleChildren(c),
+    )
+  ) {
+    return false
+  }
+
+  return children.length > 1
+}
diff --git a/packages/compiler-vapor/src/transforms/utils.ts b/packages/compiler-vapor/src/transforms/utils.ts
index f7d0594fe58..b746999a18a 100644
--- a/packages/compiler-vapor/src/transforms/utils.ts
+++ b/packages/compiler-vapor/src/transforms/utils.ts
@@ -30,6 +30,7 @@ export const newBlock = (node: BlockIRNode['node']): BlockIRNode => ({
   operation: [],
   returns: [],
   tempId: 0,
+  hasDeferredVShow: false,
 })
 
 export function wrapTemplate(node: ElementNode, dirs: string[]): TemplateNode {
diff --git a/packages/compiler-vapor/src/transforms/vIf.ts b/packages/compiler-vapor/src/transforms/vIf.ts
index bae9f1aa23f..2426fa0215e 100644
--- a/packages/compiler-vapor/src/transforms/vIf.ts
+++ b/packages/compiler-vapor/src/transforms/vIf.ts
@@ -18,7 +18,7 @@ import {
 import { extend } from '@vue/shared'
 import { newBlock, wrapTemplate } from './utils'
 import { getSiblingIf } from './transformComment'
-import { isStaticExpression } from '../utils'
+import { isInTransition, isStaticExpression } from '../utils'
 
 export const transformVIf: NodeTransform = createStructuralDirectiveTransform(
   ['if', 'else', 'else-if'],
@@ -135,5 +135,8 @@ export function createIfBranch(
   const branch: BlockIRNode = newBlock(node)
   const exitBlock = context.enterBlock(branch)
   context.reference()
+  // generate key for branch result when it's in transition
+  // the key will be used to track node leaving at runtime
+  branch.dynamic.needsKey = isInTransition(context)
   return [branch, exitBlock]
 }
diff --git a/packages/compiler-vapor/src/transforms/vShow.ts b/packages/compiler-vapor/src/transforms/vShow.ts
index f1135d6b0a5..a60b20a71fa 100644
--- a/packages/compiler-vapor/src/transforms/vShow.ts
+++ b/packages/compiler-vapor/src/transforms/vShow.ts
@@ -2,11 +2,13 @@ import {
   DOMErrorCodes,
   ElementTypes,
   ErrorCodes,
+  NodeTypes,
   createCompilerError,
   createDOMCompilerError,
 } from '@vue/compiler-dom'
 import type { DirectiveTransform } from '../transform'
 import { IRNodeTypes } from '../ir'
+import { findProp, isTransitionTag } from '../utils'
 
 export const transformVShow: DirectiveTransform = (dir, node, context) => {
   const { exp, loc } = dir
@@ -27,11 +29,26 @@ export const transformVShow: DirectiveTransform = (dir, node, context) => {
     return
   }
 
+  // lazy apply vshow if the node is inside a transition with appear
+  let shouldDeferred = false
+  const parentNode = context.parent && context.parent.node
+  if (parentNode && parentNode.type === NodeTypes.ELEMENT) {
+    shouldDeferred = !!(
+      isTransitionTag(parentNode.tag) &&
+      findProp(parentNode, 'appear', false, true)
+    )
+
+    if (shouldDeferred) {
+      context.parent!.parent!.block.hasDeferredVShow = true
+    }
+  }
+
   context.registerOperation({
     type: IRNodeTypes.DIRECTIVE,
     element: context.reference(),
     dir,
     name: 'show',
     builtin: true,
+    deferred: shouldDeferred,
   })
 }
diff --git a/packages/compiler-vapor/src/transforms/vSlot.ts b/packages/compiler-vapor/src/transforms/vSlot.ts
index 3e78913a23e..05aac4aee3c 100644
--- a/packages/compiler-vapor/src/transforms/vSlot.ts
+++ b/packages/compiler-vapor/src/transforms/vSlot.ts
@@ -23,7 +23,12 @@ import {
   type SlotBlockIRNode,
   type VaporDirectiveNode,
 } from '../ir'
-import { findDir, resolveExpression } from '../utils'
+import {
+  findDir,
+  findProp,
+  isTransitionNode,
+  resolveExpression,
+} from '../utils'
 import { markNonTemplate } from './transformText'
 
 export const transformVSlot: NodeTransform = (node, context) => {
@@ -83,7 +88,18 @@ function transformComponentSlot(
     })
   }
 
-  const [block, onExit] = createSlotBlock(node, dir, context)
+  let slotKey
+  if (isTransitionNode(node) && nonSlotTemplateChildren.length) {
+    const keyProp = findProp(
+      nonSlotTemplateChildren[0] as ElementNode,
+      'key',
+    ) as VaporDirectiveNode
+    if (keyProp) {
+      slotKey = keyProp.exp
+    }
+  }
+
+  const [block, onExit] = createSlotBlock(node, dir, context, slotKey)
 
   const { slots } = context
 
@@ -244,9 +260,14 @@ function createSlotBlock(
   slotNode: ElementNode,
   dir: VaporDirectiveNode | undefined,
   context: TransformContext,
+  key: SimpleExpressionNode | undefined = undefined,
 ): [SlotBlockIRNode, () => void] {
   const block: SlotBlockIRNode = newBlock(slotNode)
   block.props = dir && dir.exp
+  if (key) {
+    block.key = key
+    block.dynamic.needsKey = true
+  }
   const exitBlock = context.enterBlock(block)
   return [block, exitBlock]
 }
diff --git a/packages/compiler-vapor/src/utils.ts b/packages/compiler-vapor/src/utils.ts
index 728281914fd..d2c7eca3bb1 100644
--- a/packages/compiler-vapor/src/utils.ts
+++ b/packages/compiler-vapor/src/utils.ts
@@ -15,6 +15,7 @@ import {
 } from '@vue/compiler-dom'
 import type { VaporDirectiveNode } from './ir'
 import { EMPTY_EXPRESSION } from './transforms/utils'
+import type { TransformContext } from './transform'
 
 export const findProp = _findProp as (
   node: ElementNode,
@@ -88,3 +89,36 @@ export function getLiteralExpressionValue(
   }
   return exp.isStatic ? exp.content : null
 }
+
+export function isInTransition(
+  context: TransformContext,
+): boolean {
+  const parentNode = context.parent && context.parent.node
+  return !!(parentNode && isTransitionNode(parentNode as ElementNode))
+}
+
+export function isTransitionNode(node: ElementNode): boolean {
+  return node.type === NodeTypes.ELEMENT && isTransitionTag(node.tag)
+}
+
+export function isTransitionGroupNode(node: ElementNode): boolean {
+  return node.type === NodeTypes.ELEMENT && isTransitionGroupTag(node.tag)
+}
+
+export function isTransitionTag(tag: string): boolean {
+  tag = tag.toLowerCase()
+  return tag === 'transition' || tag === 'vaportransition'
+}
+
+export function isTransitionGroupTag(tag: string): boolean {
+  tag = tag.toLowerCase().replace(/-/g, '')
+  return tag === 'transitiongroup' || tag === 'vaportransitiongroup'
+}
+
+export function isBuiltInComponent(tag: string): string | undefined {
+  if (isTransitionTag(tag)) {
+    return 'VaporTransition'
+  } else if (isTransitionGroupTag(tag)) {
+    return 'VaporTransitionGroup'
+  }
+}
diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts
index a1409a7fe44..4c18a11f493 100644
--- a/packages/runtime-core/src/apiCreateApp.ts
+++ b/packages/runtime-core/src/apiCreateApp.ts
@@ -27,7 +27,7 @@ import { warn } from './warning'
 import type { VNode } from './vnode'
 import { devtoolsInitApp, devtoolsUnmountApp } from './devtools'
 import { NO, extend, hasOwn, isFunction, isObject } from '@vue/shared'
-import { version } from '.'
+import { type TransitionHooks, version } from '.'
 import { installAppCompatProperties } from './compat/global'
 import type { NormalizedPropsOptions } from './componentProps'
 import type { ObjectEmitsOptions } from './componentEmits'
@@ -175,7 +175,6 @@ export interface AppConfig extends GenericAppConfig {
 
 /**
  * The vapor in vdom implementation is in runtime-vapor/src/vdomInterop.ts
- * @internal
  */
 export interface VaporInteropInterface {
   mount(
@@ -188,6 +187,10 @@ export interface VaporInteropInterface {
   unmount(vnode: VNode, doRemove?: boolean): void
   move(vnode: VNode, container: any, anchor: any): void
   slot(n1: VNode | null, n2: VNode, container: any, anchor: any): void
+  setTransitionHooks(
+    component: ComponentInternalInstance,
+    transition: TransitionHooks,
+  ): void
 
   vdomMount: (component: ConcreteComponent, props?: any, slots?: any) => any
   vdomUnmount: UnmountComponentFn
diff --git a/packages/runtime-core/src/components/BaseTransition.ts b/packages/runtime-core/src/components/BaseTransition.ts
index 698ed428d1c..dcfb81370a6 100644
--- a/packages/runtime-core/src/components/BaseTransition.ts
+++ b/packages/runtime-core/src/components/BaseTransition.ts
@@ -1,6 +1,8 @@
 import {
   type ComponentInternalInstance,
   type ComponentOptions,
+  type ConcreteComponent,
+  type GenericComponentInstance,
   type SetupContext,
   getCurrentInstance,
 } from '../component'
@@ -19,7 +21,7 @@ import { ErrorCodes, callWithAsyncErrorHandling } from '../errorHandling'
 import { PatchFlags, ShapeFlags, isArray, isFunction } from '@vue/shared'
 import { onBeforeUnmount, onMounted } from '../apiLifecycle'
 import { isTeleport } from './Teleport'
-import type { RendererElement } from '../renderer'
+import { type RendererElement, getVaporInterface } from '../renderer'
 import { SchedulerJobFlags } from '../scheduler'
 
 type Hook void> = T | T[]
@@ -87,7 +89,7 @@ export interface TransitionState {
   isUnmounting: boolean
   // Track pending leave callbacks for children of the same key.
   // This is used to force remove leaving a child when a new copy is entering.
-  leavingVNodes: Map>
+  leavingNodes: Map>
 }
 
 export interface TransitionElement {
@@ -103,7 +105,7 @@ export function useTransitionState(): TransitionState {
     isMounted: false,
     isLeaving: false,
     isUnmounting: false,
-    leavingVNodes: new Map(),
+    leavingNodes: new Map(),
   }
   onMounted(() => {
     state.isMounted = true
@@ -138,7 +140,9 @@ export const BaseTransitionPropsValidators: Record = {
 }
 
 const recursiveGetSubtree = (instance: ComponentInternalInstance): VNode => {
-  const subTree = instance.subTree
+  const subTree = instance.type.__vapor
+    ? (instance as any).block
+    : instance.subTree
   return subTree.component ? recursiveGetSubtree(subTree.component) : subTree
 }
 
@@ -164,15 +168,7 @@ const BaseTransitionImpl: ComponentOptions = {
       const rawProps = toRaw(props)
       const { mode } = rawProps
       // check mode
-      if (
-        __DEV__ &&
-        mode &&
-        mode !== 'in-out' &&
-        mode !== 'out-in' &&
-        mode !== 'default'
-      ) {
-        warn(`invalid  mode: ${mode}`)
-      }
+      checkTransitionMode(mode)
 
       if (state.isLeaving) {
         return emptyPlaceholder(child)
@@ -309,24 +305,83 @@ function getLeavingNodesForType(
   state: TransitionState,
   vnode: VNode,
 ): Record {
-  const { leavingVNodes } = state
-  let leavingVNodesCache = leavingVNodes.get(vnode.type)!
+  const { leavingNodes } = state
+  let leavingVNodesCache = leavingNodes.get(vnode.type)!
   if (!leavingVNodesCache) {
     leavingVNodesCache = Object.create(null)
-    leavingVNodes.set(vnode.type, leavingVNodesCache)
+    leavingNodes.set(vnode.type, leavingVNodesCache)
   }
   return leavingVNodesCache
 }
 
+export interface TransitionHooksContext {
+  setLeavingNodeCache: (node: any) => void
+  unsetLeavingNodeCache: (node: any) => void
+  earlyRemove: () => void
+  cloneHooks: (node: any) => TransitionHooks
+}
+
 // The transition hooks are attached to the vnode as vnode.transition
 // and will be called at appropriate timing in the renderer.
 export function resolveTransitionHooks(
   vnode: VNode,
   props: BaseTransitionProps,
   state: TransitionState,
-  instance: ComponentInternalInstance,
+  instance: GenericComponentInstance,
   postClone?: (hooks: TransitionHooks) => void,
+): TransitionHooks {
+  const key = String(vnode.key)
+  const leavingVNodesCache = getLeavingNodesForType(state, vnode)
+  const context: TransitionHooksContext = {
+    setLeavingNodeCache: () => {
+      leavingVNodesCache[key] = vnode
+    },
+    unsetLeavingNodeCache: () => {
+      if (leavingVNodesCache[key] === vnode) {
+        delete leavingVNodesCache[key]
+      }
+    },
+    earlyRemove: () => {
+      const leavingVNode = leavingVNodesCache[key]
+      if (
+        leavingVNode &&
+        isSameVNodeType(vnode, leavingVNode) &&
+        (leavingVNode.el as TransitionElement)[leaveCbKey]
+      ) {
+        // force early removal (not cancelled)
+        ;(leavingVNode.el as TransitionElement)[leaveCbKey]!()
+      }
+    },
+    cloneHooks: vnode => {
+      const hooks = resolveTransitionHooks(
+        vnode,
+        props,
+        state,
+        instance,
+        postClone,
+      )
+      if (postClone) postClone(hooks)
+      return hooks
+    },
+  }
+
+  return baseResolveTransitionHooks(context, props, state, instance)
+}
+
+// shared between vdom and vapor
+export function baseResolveTransitionHooks(
+  context: TransitionHooksContext,
+  props: BaseTransitionProps,
+  state: TransitionState,
+  instance: GenericComponentInstance,
 ): TransitionHooks {
+  const {
+    setLeavingNodeCache,
+    unsetLeavingNodeCache,
+    earlyRemove,
+    cloneHooks,
+  } = context
+
   const {
     appear,
     mode,
@@ -344,8 +399,6 @@ export function resolveTransitionHooks(
     onAfterAppear,
     onAppearCancelled,
   } = props
-  const key = String(vnode.key)
-  const leavingVNodesCache = getLeavingNodesForType(state, vnode)
 
   const callHook: TransitionHookCaller = (hook, args) => {
     hook &&
@@ -387,15 +440,7 @@ export function resolveTransitionHooks(
         el[leaveCbKey](true /* cancelled */)
       }
       // for toggled element with same key (v-if)
-      const leavingVNode = leavingVNodesCache[key]
-      if (
-        leavingVNode &&
-        isSameVNodeType(vnode, leavingVNode) &&
-        (leavingVNode.el as TransitionElement)[leaveCbKey]
-      ) {
-        // force early removal (not cancelled)
-        ;(leavingVNode.el as TransitionElement)[leaveCbKey]!()
-      }
+      earlyRemove()
       callHook(hook, [el])
     },
 
@@ -434,7 +479,7 @@ export function resolveTransitionHooks(
     },
 
     leave(el, remove) {
-      const key = String(vnode.key)
+      // const key = String(vnode.key)
       if (el[enterCbKey]) {
         el[enterCbKey](true /* cancelled */)
       }
@@ -453,11 +498,9 @@ export function resolveTransitionHooks(
           callHook(onAfterLeave, [el])
         }
         el[leaveCbKey] = undefined
-        if (leavingVNodesCache[key] === vnode) {
-          delete leavingVNodesCache[key]
-        }
+        unsetLeavingNodeCache(el)
       })
-      leavingVNodesCache[key] = vnode
+      setLeavingNodeCache(el)
       if (onLeave) {
         callAsyncHook(onLeave, [el, done])
       } else {
@@ -465,16 +508,8 @@ export function resolveTransitionHooks(
       }
     },
 
-    clone(vnode) {
-      const hooks = resolveTransitionHooks(
-        vnode,
-        props,
-        state,
-        instance,
-        postClone,
-      )
-      if (postClone) postClone(hooks)
-      return hooks
+    clone(node) {
+      return cloneHooks(node)
     },
   }
 
@@ -524,8 +559,15 @@ function getInnerChild(vnode: VNode): VNode | undefined {
 
 export function setTransitionHooks(vnode: VNode, hooks: TransitionHooks): void {
   if (vnode.shapeFlag & ShapeFlags.COMPONENT && vnode.component) {
-    vnode.transition = hooks
-    setTransitionHooks(vnode.component.subTree, hooks)
+    if ((vnode.type as ConcreteComponent).__vapor) {
+      getVaporInterface(vnode.component, vnode).setTransitionHooks(
+        vnode.component,
+        hooks,
+      )
+    } else {
+      vnode.transition = hooks
+      setTransitionHooks(vnode.component.subTree, hooks)
+    }
   } else if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) {
     vnode.ssContent!.transition = hooks.clone(vnode.ssContent!)
     vnode.ssFallback!.transition = hooks.clone(vnode.ssFallback!)
@@ -571,3 +613,18 @@ export function getTransitionRawChildren(
   }
   return ret
 }
+
+/**
+ * dev-only
+ */
+export function checkTransitionMode(mode: string | undefined): void {
+  if (
+    __DEV__ &&
+    mode &&
+    mode !== 'in-out' &&
+    mode !== 'out-in' &&
+    mode !== 'default'
+  ) {
+    warn(`invalid  mode: ${mode}`)
+  }
+}
diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts
index 9d97bb18593..aec56912325 100644
--- a/packages/runtime-core/src/index.ts
+++ b/packages/runtime-core/src/index.ts
@@ -118,6 +118,7 @@ export { KeepAlive, type KeepAliveProps } from './components/KeepAlive'
 export {
   BaseTransition,
   BaseTransitionPropsValidators,
+  checkTransitionMode,
   type BaseTransitionProps,
 } from './components/BaseTransition'
 // For using custom directives
@@ -150,8 +151,10 @@ export { registerRuntimeCompiler, isRuntimeOnly } from './component'
 export {
   useTransitionState,
   resolveTransitionHooks,
+  baseResolveTransitionHooks,
   setTransitionHooks,
   getTransitionRawChildren,
+  leaveCbKey,
 } from './components/BaseTransition'
 export { initCustomFormatter } from './customFormatter'
 
@@ -335,6 +338,8 @@ export type { SuspenseBoundary } from './components/Suspense'
 export type {
   TransitionState,
   TransitionHooks,
+  TransitionHooksContext,
+  TransitionElement,
 } from './components/BaseTransition'
 export type {
   AsyncComponentOptions,
@@ -558,6 +563,10 @@ export { startMeasure, endMeasure } from './profiling'
  * @internal
  */
 export { initFeatureFlags } from './featureFlags'
+/**
+ * @internal
+ */
+export { performTransitionEnter, performTransitionLeave } from './renderer'
 /**
  * @internal
  */
diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts
index 17ae7c6aba3..7a4e16f8781 100644
--- a/packages/runtime-core/src/renderer.ts
+++ b/packages/runtime-core/src/renderer.ts
@@ -738,20 +738,21 @@ function baseCreateRenderer(
     }
     // #1583 For inside suspense + suspense not resolved case, enter hook should call when suspense resolved
     // #1689 For inside suspense + suspense resolved case, just call it
-    const needCallTransitionHooks = needTransition(parentSuspense, transition)
-    if (needCallTransitionHooks) {
-      transition!.beforeEnter(el)
+    if (transition) {
+      performTransitionEnter(
+        el,
+        transition,
+        () => hostInsert(el, container, anchor),
+        parentSuspense,
+      )
+    } else {
+      hostInsert(el, container, anchor)
     }
-    hostInsert(el, container, anchor)
-    if (
-      (vnodeHook = props && props.onVnodeMounted) ||
-      needCallTransitionHooks ||
-      dirs
-    ) {
+
+    if ((vnodeHook = props && props.onVnodeMounted) || dirs) {
       queuePostRenderEffect(
         () => {
           vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
-          needCallTransitionHooks && transition!.enter(el)
           dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
         },
         undefined,
@@ -2188,12 +2189,12 @@ function baseCreateRenderer(
       transition
     if (needTransition) {
       if (moveType === MoveType.ENTER) {
-        transition!.beforeEnter(el!)
-        hostInsert(el!, container, anchor)
-        queuePostRenderEffect(
-          () => transition!.enter(el!),
-          undefined,
+        performTransitionEnter(
+          el!,
+          transition,
+          () => hostInsert(el!, container, anchor),
           parentSuspense,
+          true,
         )
       } else {
         const { leave, delayLeave, afterLeave } = transition!
@@ -2387,27 +2388,15 @@ function baseCreateRenderer(
       return
     }
 
-    const performRemove = () => {
-      hostRemove(el!)
-      if (transition && !transition.persisted && transition.afterLeave) {
-        transition.afterLeave()
-      }
-    }
-
-    if (
-      vnode.shapeFlag & ShapeFlags.ELEMENT &&
-      transition &&
-      !transition.persisted
-    ) {
-      const { leave, delayLeave } = transition
-      const performLeave = () => leave(el!, performRemove)
-      if (delayLeave) {
-        delayLeave(vnode.el!, performRemove, performLeave)
-      } else {
-        performLeave()
-      }
+    if (transition) {
+      performTransitionLeave(
+        el!,
+        transition,
+        () => hostRemove(el!),
+        !!(vnode.shapeFlag & ShapeFlags.ELEMENT),
+      )
     } else {
-      performRemove()
+      hostRemove(el!)
     }
   }
 
@@ -2707,7 +2696,7 @@ export function traverseStaticChildren(
 function locateNonHydratedAsyncRoot(
   instance: ComponentInternalInstance,
 ): ComponentInternalInstance | undefined {
-  const subComponent = instance.vapor ? null : instance.subTree.component
+  const subComponent = instance.subTree && instance.subTree.component
   if (subComponent) {
     if (subComponent.asyncDep && !subComponent.asyncResolved) {
       return subComponent
@@ -2724,7 +2713,51 @@ export function invalidateMount(hooks: LifecycleHook | undefined): void {
   }
 }
 
-function getVaporInterface(
+// shared between vdom and vapor
+export function performTransitionEnter(
+  el: RendererElement,
+  transition: TransitionHooks,
+  insert: () => void,
+  parentSuspense: SuspenseBoundary | null,
+  force: boolean = false,
+): void {
+  if (force || needTransition(parentSuspense, transition)) {
+    transition.beforeEnter(el)
+    insert()
+    queuePostRenderEffect(() => transition.enter(el), undefined, parentSuspense)
+  } else {
+    insert()
+  }
+}
+
+// shared between vdom and vapor
+export function performTransitionLeave(
+  el: RendererElement,
+  transition: TransitionHooks,
+  remove: () => void,
+  isElement: boolean = true,
+): void {
+  const performRemove = () => {
+    remove()
+    if (transition && !transition.persisted && transition.afterLeave) {
+      transition.afterLeave()
+    }
+  }
+
+  if (isElement && transition && !transition.persisted) {
+    const { leave, delayLeave } = transition
+    const performLeave = () => leave(el, performRemove)
+    if (delayLeave) {
+      delayLeave(el, performRemove, performLeave)
+    } else {
+      performLeave()
+    }
+  } else {
+    performRemove()
+  }
+}
+
+export function getVaporInterface(
   instance: ComponentInternalInstance | null,
   vnode: VNode,
 ): VaporInteropInterface {
diff --git a/packages/runtime-dom/src/components/TransitionGroup.ts b/packages/runtime-dom/src/components/TransitionGroup.ts
index 72af535d385..abf3e095424 100644
--- a/packages/runtime-dom/src/components/TransitionGroup.ts
+++ b/packages/runtime-dom/src/components/TransitionGroup.ts
@@ -32,7 +32,7 @@ import { extend } from '@vue/shared'
 
 const positionMap = new WeakMap()
 const newPositionMap = new WeakMap()
-const moveCbKey = Symbol('_moveCb')
+export const moveCbKey: symbol = Symbol('_moveCb')
 const enterCbKey = Symbol('_enterCb')
 
 export type TransitionGroupProps = Omit & {
@@ -88,7 +88,7 @@ const TransitionGroupImpl: ComponentOptions = /*@__PURE__*/ decorate({
 
       // we divide the work into three loops to avoid mixing DOM reads and writes
       // in each iteration - which helps prevent layout thrashing.
-      prevChildren.forEach(callPendingCbs)
+      prevChildren.forEach(vnode => callPendingCbs(vnode.el))
       prevChildren.forEach(recordPosition)
       const movedChildren = prevChildren.filter(applyTranslation)
 
@@ -97,20 +97,7 @@ const TransitionGroupImpl: ComponentOptions = /*@__PURE__*/ decorate({
 
       movedChildren.forEach(c => {
         const el = c.el as ElementWithTransition
-        const style = el.style
-        addTransitionClass(el, moveClass)
-        style.transform = style.webkitTransform = style.transitionDuration = ''
-        const cb = ((el as any)[moveCbKey] = (e: TransitionEvent) => {
-          if (e && e.target !== el) {
-            return
-          }
-          if (!e || /transform$/.test(e.propertyName)) {
-            el.removeEventListener('transitionend', cb)
-            ;(el as any)[moveCbKey] = null
-            removeTransitionClass(el, moveClass)
-          }
-        })
-        el.addEventListener('transitionend', cb)
+        handleMovedChildren(el, moveClass)
       })
       prevChildren = []
     })
@@ -179,8 +166,7 @@ export const TransitionGroup = TransitionGroupImpl as unknown as {
   }
 }
 
-function callPendingCbs(c: VNode) {
-  const el = c.el as any
+export function callPendingCbs(el: any): void {
   if (el[moveCbKey]) {
     el[moveCbKey]()
   }
@@ -194,19 +180,36 @@ function recordPosition(c: VNode) {
 }
 
 function applyTranslation(c: VNode): VNode | undefined {
-  const oldPos = positionMap.get(c)!
-  const newPos = newPositionMap.get(c)!
+  if (
+    baseApplyTranslation(
+      positionMap.get(c)!,
+      newPositionMap.get(c)!,
+      c.el as ElementWithTransition,
+    )
+  ) {
+    return c
+  }
+}
+
+// shared between vdom and vapor
+export function baseApplyTranslation(
+  oldPos: DOMRect,
+  newPos: DOMRect,
+  el: ElementWithTransition,
+): boolean {
   const dx = oldPos.left - newPos.left
   const dy = oldPos.top - newPos.top
   if (dx || dy) {
-    const s = (c.el as HTMLElement).style
+    const s = (el as HTMLElement).style
     s.transform = s.webkitTransform = `translate(${dx}px,${dy}px)`
     s.transitionDuration = '0s'
-    return c
+    return true
   }
+  return false
 }
 
-function hasCSSTransform(
+// shared between vdom and vapor
+export function hasCSSTransform(
   el: ElementWithTransition,
   root: Node,
   moveClass: string,
@@ -233,3 +236,24 @@ function hasCSSTransform(
   container.removeChild(clone)
   return hasTransform
 }
+
+// shared between vdom and vapor
+export const handleMovedChildren = (
+  el: ElementWithTransition,
+  moveClass: string,
+): void => {
+  const style = el.style
+  addTransitionClass(el, moveClass)
+  style.transform = style.webkitTransform = style.transitionDuration = ''
+  const cb = ((el as any)[moveCbKey] = (e: TransitionEvent) => {
+    if (e && e.target !== el) {
+      return
+    }
+    if (!e || /transform$/.test(e.propertyName)) {
+      el.removeEventListener('transitionend', cb)
+      ;(el as any)[moveCbKey] = null
+      removeTransitionClass(el, moveClass)
+    }
+  })
+  el.addEventListener('transitionend', cb)
+}
diff --git a/packages/runtime-dom/src/index.ts b/packages/runtime-dom/src/index.ts
index 64ba93e0e3e..2ead79760cc 100644
--- a/packages/runtime-dom/src/index.ts
+++ b/packages/runtime-dom/src/index.ts
@@ -348,6 +348,27 @@ export {
   vModelSelectInit,
   vModelSetSelected,
 } from './directives/vModel'
+/**
+ * @internal
+ */
+export {
+  resolveTransitionProps,
+  TransitionPropsValidators,
+  forceReflow,
+  addTransitionClass,
+  removeTransitionClass,
+  type ElementWithTransition,
+} from './components/Transition'
+/**
+ * @internal
+ */
+export {
+  hasCSSTransform,
+  callPendingCbs,
+  moveCbKey,
+  handleMovedChildren,
+  baseApplyTranslation,
+} from './components/TransitionGroup'
 /**
  * @internal
  */
diff --git a/packages/runtime-vapor/src/apiCreateDynamicComponent.ts b/packages/runtime-vapor/src/apiCreateDynamicComponent.ts
index 945e0f38d87..8a127c2daf1 100644
--- a/packages/runtime-vapor/src/apiCreateDynamicComponent.ts
+++ b/packages/runtime-vapor/src/apiCreateDynamicComponent.ts
@@ -1,6 +1,6 @@
-import { resolveDynamicComponent } from '@vue/runtime-dom'
+import { currentInstance, resolveDynamicComponent } from '@vue/runtime-dom'
 import { DynamicFragment, type VaporFragment, insert } from './block'
-import { createComponentWithFallback } from './component'
+import { createComponentWithFallback, emptyContext } from './component'
 import { renderEffect } from './renderEffect'
 import type { RawProps } from './componentProps'
 import type { RawSlots } from './componentSlots'
@@ -31,6 +31,8 @@ export function createDynamicComponent(
 
   renderEffect(() => {
     const value = getter()
+    const appContext =
+      (currentInstance && currentInstance.appContext) || emptyContext
     frag.update(
       () =>
         createComponentWithFallback(
@@ -38,6 +40,7 @@ export function createDynamicComponent(
           rawProps,
           rawSlots,
           isSingleRoot,
+          appContext,
         ),
       value,
     )
diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts
index ffe91a87c49..763bb1defed 100644
--- a/packages/runtime-vapor/src/apiCreateFor.ts
+++ b/packages/runtime-vapor/src/apiCreateFor.ts
@@ -24,6 +24,7 @@ import { currentInstance, isVaporComponent } from './component'
 import type { DynamicSlot } from './componentSlots'
 import { renderEffect } from './renderEffect'
 import { VaporVForFlags } from '../../shared/src/vaporFlags'
+import { applyTransitionHooks } from './components/Transition'
 import { isHydrating, locateHydrationNode } from './dom/hydration'
 import {
   insertionAnchor,
@@ -387,6 +388,11 @@ export const createFor = (
       key2,
     ))
 
+    // apply transition for new nodes
+    if (frag.$transition) {
+      applyTransitionHooks(block.nodes, frag.$transition, false)
+    }
+
     if (parent) insert(block.nodes, parent, anchor)
 
     return block
@@ -596,3 +602,7 @@ export function getRestElement(val: any, keys: string[]): any {
 export function getDefaultValue(val: any, defaultVal: any): any {
   return val === undefined ? defaultVal : val
 }
+
+export function isForBlock(block: Block): block is ForBlock {
+  return block instanceof ForBlock
+}
diff --git a/packages/runtime-vapor/src/apiCreateFragment.ts b/packages/runtime-vapor/src/apiCreateFragment.ts
new file mode 100644
index 00000000000..50179b89ef9
--- /dev/null
+++ b/packages/runtime-vapor/src/apiCreateFragment.ts
@@ -0,0 +1,10 @@
+import { type Block, type BlockFn, DynamicFragment } from './block'
+import { renderEffect } from './renderEffect'
+
+export function createKeyedFragment(key: () => any, render: BlockFn): Block {
+  const frag = __DEV__ ? new DynamicFragment('keyed') : new DynamicFragment()
+  renderEffect(() => {
+    frag.update(render, key())
+  })
+  return frag
+}
diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts
index ba84161a71b..a45e98ca6a3 100644
--- a/packages/runtime-vapor/src/block.ts
+++ b/packages/runtime-vapor/src/block.ts
@@ -8,21 +8,52 @@ import {
 import { createComment, createTextNode } from './dom/node'
 import { EffectScope, setActiveSub } from '@vue/reactivity'
 import { isHydrating } from './dom/hydration'
+import {
+  type TransitionHooks,
+  type TransitionProps,
+  type TransitionState,
+  performTransitionEnter,
+  performTransitionLeave,
+} from '@vue/runtime-dom'
+import {
+  applyTransitionHooks,
+  applyTransitionLeaveHooks,
+} from './components/Transition'
+
+export interface TransitionOptions {
+  $key?: any
+  $transition?: VaporTransitionHooks
+}
+
+export interface VaporTransitionHooks extends TransitionHooks {
+  state: TransitionState
+  props: TransitionProps
+  instance: VaporComponentInstance
+  // mark transition hooks as disabled so that it skips during
+  // inserting
+  disabled?: boolean
+}
+
+export type TransitionBlock =
+  | (Node & TransitionOptions)
+  | (VaporFragment & TransitionOptions)
+  | (DynamicFragment & TransitionOptions)
 
-export type Block =
-  | Node
-  | VaporFragment
-  | DynamicFragment
-  | VaporComponentInstance
-  | Block[]
+export type Block = TransitionBlock | VaporComponentInstance | Block[]
 
 export type BlockFn = (...args: any[]) => Block
 
-export class VaporFragment {
+export class VaporFragment implements TransitionOptions {
+  $key?: any
+  $transition?: VaporTransitionHooks | undefined
   nodes: Block
   anchor?: Node
-  insert?: (parent: ParentNode, anchor: Node | null) => void
-  remove?: (parent?: ParentNode) => void
+  insert?: (
+    parent: ParentNode,
+    anchor: Node | null,
+    transitionHooks?: TransitionHooks,
+  ) => void
+  remove?: (parent?: ParentNode, transitionHooks?: TransitionHooks) => void
 
   constructor(nodes: Block) {
     this.nodes = nodes
@@ -49,21 +80,38 @@ export class DynamicFragment extends VaporFragment {
 
     const prevSub = setActiveSub()
     const parent = this.anchor.parentNode
+    const transition = this.$transition
+    const renderBranch = () => {
+      if (render) {
+        this.scope = new EffectScope()
+        this.nodes = this.scope.run(render) || []
+        if (transition) {
+          this.$transition = applyTransitionHooks(this.nodes, transition)
+        }
+        if (parent) insert(this.nodes, parent, this.anchor)
+      } else {
+        this.scope = undefined
+        this.nodes = []
+      }
+    }
 
     // teardown previous branch
     if (this.scope) {
       this.scope.stop()
-      parent && remove(this.nodes, parent)
+      const mode = transition && transition.mode
+      if (mode) {
+        applyTransitionLeaveHooks(this.nodes, transition, renderBranch)
+        parent && remove(this.nodes, parent)
+        if (mode === 'out-in') {
+          setActiveSub(prevSub)
+          return
+        }
+      } else {
+        parent && remove(this.nodes, parent)
+      }
     }
 
-    if (render) {
-      this.scope = new EffectScope()
-      this.nodes = this.scope.run(render) || []
-      if (parent) insert(this.nodes, parent, this.anchor)
-    } else {
-      this.scope = undefined
-      this.nodes = []
-    }
+    renderBranch()
 
     if (this.fallback && !isValidBlock(this.nodes)) {
       parent && remove(this.nodes, parent)
@@ -107,11 +155,26 @@ export function insert(
   block: Block,
   parent: ParentNode & { $anchor?: Node | null },
   anchor: Node | null | 0 = null, // 0 means prepend
+  parentSuspense?: any, // TODO Suspense
 ): void {
   anchor = anchor === 0 ? parent.$anchor || parent.firstChild : anchor
   if (block instanceof Node) {
     if (!isHydrating) {
-      parent.insertBefore(block, anchor)
+      // only apply transition on Element nodes
+      if (
+        block instanceof Element &&
+        (block as TransitionBlock).$transition &&
+        !(block as TransitionBlock).$transition!.disabled
+      ) {
+        performTransitionEnter(
+          block,
+          (block as TransitionBlock).$transition as TransitionHooks,
+          () => parent.insertBefore(block, anchor),
+          parentSuspense,
+        )
+      } else {
+        parent.insertBefore(block, anchor)
+      }
     }
   } else if (isVaporComponent(block)) {
     if (block.isMounted) {
@@ -127,9 +190,9 @@ export function insert(
     // fragment
     if (block.insert) {
       // TODO handle hydration for vdom interop
-      block.insert(parent, anchor)
+      block.insert(parent, anchor, (block as TransitionBlock).$transition)
     } else {
-      insert(block.nodes, parent, anchor)
+      insert(block.nodes, parent, anchor, parentSuspense)
     }
     if (block.anchor) insert(block.anchor, parent, anchor)
   }
@@ -144,7 +207,15 @@ export function prepend(parent: ParentNode, ...blocks: Block[]): void {
 
 export function remove(block: Block, parent?: ParentNode): void {
   if (block instanceof Node) {
-    parent && parent.removeChild(block)
+    if ((block as TransitionBlock).$transition && block instanceof Element) {
+      performTransitionLeave(
+        block,
+        (block as TransitionBlock).$transition as TransitionHooks,
+        () => parent && parent.removeChild(block),
+      )
+    } else {
+      parent && parent.removeChild(block)
+    }
   } else if (isVaporComponent(block)) {
     unmountComponent(block, parent)
   } else if (isArray(block)) {
@@ -154,7 +225,7 @@ export function remove(block: Block, parent?: ParentNode): void {
   } else {
     // fragment
     if (block.remove) {
-      block.remove(parent)
+      block.remove(parent, (block as TransitionBlock).$transition)
     } else {
       remove(block.nodes, parent)
     }
diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts
index 08fd881e959..4f8a2f5b2d7 100644
--- a/packages/runtime-vapor/src/component.ts
+++ b/packages/runtime-vapor/src/component.ts
@@ -57,6 +57,7 @@ import {
   getSlot,
 } from './componentSlots'
 import { hmrReload, hmrRerender } from './hmr'
+import { createElement } from './dom/node'
 import { isHydrating, locateHydrationNode } from './dom/hydration'
 import {
   insertionAnchor,
@@ -257,11 +258,7 @@ export function createComponent(
   ) {
     const el = getRootElement(instance)
     if (el) {
-      renderEffect(() => {
-        isApplyingFallthroughProps = true
-        setDynamicProps(el, [instance.attrs])
-        isApplyingFallthroughProps = false
-      })
+      renderEffect(() => applyFallthroughProps(el, instance.attrs))
     }
   }
 
@@ -284,6 +281,15 @@ export function createComponent(
 
 export let isApplyingFallthroughProps = false
 
+export function applyFallthroughProps(
+  block: Block,
+  attrs: Record,
+): void {
+  isApplyingFallthroughProps = true
+  setDynamicProps(block as Element, [attrs])
+  isApplyingFallthroughProps = false
+}
+
 /**
  * dev only
  */
@@ -318,7 +324,7 @@ export function devRender(instance: VaporComponentInstance): void {
         )) || []
 }
 
-const emptyContext: GenericAppContext = {
+export const emptyContext: GenericAppContext = {
   app: null as any,
   config: {},
   provides: /*@__PURE__*/ Object.create(null),
@@ -486,11 +492,13 @@ export function createComponentWithFallback(
   rawProps?: LooseRawProps | null,
   rawSlots?: LooseRawSlots | null,
   isSingleRoot?: boolean,
+  appContext?: GenericAppContext,
 ): HTMLElement | VaporComponentInstance {
   if (!isString(comp)) {
-    return createComponent(comp, rawProps, rawSlots, isSingleRoot)
+    return createComponent(comp, rawProps, rawSlots, isSingleRoot, appContext)
   }
 
+  const el = createElement(comp)
   const _insertionParent = insertionParent
   const _insertionAnchor = insertionAnchor
   if (isHydrating) {
@@ -499,7 +507,6 @@ export function createComponentWithFallback(
     resetInsertionState()
   }
 
-  const el = document.createElement(comp)
   // mark single root
   ;(el as any).$root = isSingleRoot
 
diff --git a/packages/runtime-vapor/src/components/Transition.ts b/packages/runtime-vapor/src/components/Transition.ts
new file mode 100644
index 00000000000..017cb0fd5c8
--- /dev/null
+++ b/packages/runtime-vapor/src/components/Transition.ts
@@ -0,0 +1,326 @@
+import {
+  type GenericComponentInstance,
+  type TransitionElement,
+  type TransitionHooks,
+  type TransitionHooksContext,
+  type TransitionProps,
+  TransitionPropsValidators,
+  type TransitionState,
+  baseResolveTransitionHooks,
+  checkTransitionMode,
+  currentInstance,
+  leaveCbKey,
+  resolveTransitionProps,
+  useTransitionState,
+  warn,
+} from '@vue/runtime-dom'
+import {
+  type Block,
+  type TransitionBlock,
+  type VaporTransitionHooks,
+  isFragment,
+} from '../block'
+import {
+  type FunctionalVaporComponent,
+  type VaporComponentInstance,
+  applyFallthroughProps,
+  isVaporComponent,
+} from '../component'
+import { extend, isArray } from '@vue/shared'
+import { renderEffect } from '../renderEffect'
+
+const decorate = (t: typeof VaporTransition) => {
+  t.displayName = 'VaporTransition'
+  t.props = TransitionPropsValidators
+  t.__vapor = true
+  return t
+}
+
+export const VaporTransition: FunctionalVaporComponent = /*@__PURE__*/ decorate(
+  (props, { slots, attrs }) => {
+    const children = (slots.default && slots.default()) as any as Block
+    if (!children) return
+
+    const instance = currentInstance! as VaporComponentInstance
+    const { mode } = props
+    checkTransitionMode(mode)
+
+    let resolvedProps
+    let isMounted = false
+    renderEffect(() => {
+      resolvedProps = resolveTransitionProps(props)
+      if (isMounted) {
+        // only update props for Fragment block, for later reusing
+        if (isFragment(children)) {
+          children.$transition!.props = resolvedProps
+        } else {
+          const child = findTransitionBlock(children)
+          if (child) {
+            // replace existing transition hooks
+            child.$transition!.props = resolvedProps
+            applyTransitionHooks(child, child.$transition!)
+          }
+        }
+      } else {
+        isMounted = true
+      }
+    })
+
+    // fallthrough attrs
+    let fallthroughAttrs = true
+    if (instance.hasFallthrough) {
+      renderEffect(() => {
+        // attrs are accessed in advance
+        const resolvedAttrs = extend({}, attrs)
+        const child = findTransitionBlock(children)
+        if (child) {
+          // mark single root
+          ;(child as any).$root = true
+
+          applyFallthroughProps(child, resolvedAttrs)
+          // ensure fallthrough attrs are not happened again in
+          // applyTransitionHooks
+          fallthroughAttrs = false
+        }
+      })
+    }
+
+    applyTransitionHooks(
+      children,
+      {
+        state: useTransitionState(),
+        props: resolvedProps!,
+        instance: instance,
+      } as VaporTransitionHooks,
+      fallthroughAttrs,
+    )
+
+    return children
+  },
+)
+
+const getTransitionHooksContext = (
+  key: String,
+  props: TransitionProps,
+  state: TransitionState,
+  instance: GenericComponentInstance,
+  postClone: ((hooks: TransitionHooks) => void) | undefined,
+) => {
+  const { leavingNodes } = state
+  const context: TransitionHooksContext = {
+    setLeavingNodeCache: el => {
+      leavingNodes.set(key, el)
+    },
+    unsetLeavingNodeCache: el => {
+      const leavingNode = leavingNodes.get(key)
+      if (leavingNode === el) {
+        leavingNodes.delete(key)
+      }
+    },
+    earlyRemove: () => {
+      const leavingNode = leavingNodes.get(key)
+      if (leavingNode && (leavingNode as TransitionElement)[leaveCbKey]) {
+        // force early removal (not cancelled)
+        ;(leavingNode as TransitionElement)[leaveCbKey]!()
+      }
+    },
+    cloneHooks: block => {
+      const hooks = resolveTransitionHooks(
+        block,
+        props,
+        state,
+        instance,
+        postClone,
+      )
+      if (postClone) postClone(hooks)
+      return hooks
+    },
+  }
+  return context
+}
+
+export function resolveTransitionHooks(
+  block: TransitionBlock,
+  props: TransitionProps,
+  state: TransitionState,
+  instance: GenericComponentInstance,
+  postClone?: (hooks: TransitionHooks) => void,
+): VaporTransitionHooks {
+  const context = getTransitionHooksContext(
+    String(block.$key),
+    props,
+    state,
+    instance,
+    postClone,
+  )
+  const hooks = baseResolveTransitionHooks(
+    context,
+    props,
+    state,
+    instance,
+  ) as VaporTransitionHooks
+  hooks.state = state
+  hooks.props = props
+  hooks.instance = instance as VaporComponentInstance
+  return hooks
+}
+
+export function applyTransitionHooks(
+  block: Block,
+  hooks: VaporTransitionHooks,
+  fallthroughAttrs: boolean = true,
+): VaporTransitionHooks {
+  const isFrag = isFragment(block)
+  const child = findTransitionBlock(block)
+  if (!child) {
+    // set transition hooks on fragment for reusing during it's updating
+    if (isFrag) setTransitionHooksOnFragment(block, hooks)
+    return hooks
+  }
+
+  const { props, instance, state, delayedLeave } = hooks
+  let resolvedHooks = resolveTransitionHooks(
+    child,
+    props,
+    state,
+    instance,
+    hooks => (resolvedHooks = hooks as VaporTransitionHooks),
+  )
+  resolvedHooks.delayedLeave = delayedLeave
+  setTransitionHooks(child, resolvedHooks)
+  if (isFrag) setTransitionHooksOnFragment(block, resolvedHooks)
+
+  // fallthrough attrs
+  if (fallthroughAttrs && instance.hasFallthrough) {
+    // mark single root
+    ;(child as any).$root = true
+    applyFallthroughProps(child, instance.attrs)
+  }
+
+  return resolvedHooks
+}
+
+export function applyTransitionLeaveHooks(
+  block: Block,
+  enterHooks: VaporTransitionHooks,
+  afterLeaveCb: () => void,
+): void {
+  const leavingBlock = findTransitionBlock(block)
+  if (!leavingBlock) return undefined
+
+  const { props, state, instance } = enterHooks
+  const leavingHooks = resolveTransitionHooks(
+    leavingBlock,
+    props,
+    state,
+    instance,
+  )
+  setTransitionHooks(leavingBlock, leavingHooks)
+
+  const { mode } = props
+  if (mode === 'out-in') {
+    state.isLeaving = true
+    leavingHooks.afterLeave = () => {
+      state.isLeaving = false
+      afterLeaveCb()
+      leavingBlock.$transition = undefined
+      delete leavingHooks.afterLeave
+    }
+  } else if (mode === 'in-out') {
+    leavingHooks.delayLeave = (
+      block: TransitionElement,
+      earlyRemove,
+      delayedLeave,
+    ) => {
+      state.leavingNodes.set(String(leavingBlock.$key), leavingBlock)
+      // early removal callback
+      block[leaveCbKey] = () => {
+        earlyRemove()
+        block[leaveCbKey] = undefined
+        leavingBlock.$transition = undefined
+        delete enterHooks.delayedLeave
+      }
+      enterHooks.delayedLeave = () => {
+        delayedLeave()
+        leavingBlock.$transition = undefined
+        delete enterHooks.delayedLeave
+      }
+    }
+  }
+}
+
+const transitionBlockCache = new WeakMap()
+export function findTransitionBlock(
+  block: Block,
+  inFragment: boolean = false,
+): TransitionBlock | undefined {
+  if (transitionBlockCache.has(block)) {
+    return transitionBlockCache.get(block)
+  }
+
+  let isFrag = false
+  let child: TransitionBlock | undefined
+  if (block instanceof Node) {
+    // transition can only be applied on Element child
+    if (block instanceof Element) child = block
+  } else if (isVaporComponent(block)) {
+    child = findTransitionBlock(block.block)
+    // use component id as key
+    if (child && child.$key === undefined) child.$key = block.uid
+  } else if (isArray(block)) {
+    child = block[0] as TransitionBlock
+    let hasFound = false
+    for (const c of block) {
+      const item = findTransitionBlock(c)
+      if (item instanceof Element) {
+        if (__DEV__ && hasFound) {
+          // warn more than one non-comment child
+          warn(
+            ' can only be used on a single element or component. ' +
+              'Use  for lists.',
+          )
+          break
+        }
+        child = item
+        hasFound = true
+        if (!__DEV__) break
+      }
+    }
+  } else if ((isFrag = isFragment(block))) {
+    if (block.insert) {
+      child = block
+    } else {
+      child = findTransitionBlock(block.nodes, true)
+    }
+  }
+
+  if (__DEV__ && !child && !inFragment && !isFrag) {
+    warn('Transition component has no valid child element')
+  }
+
+  return child
+}
+
+export function setTransitionHooksOnFragment(
+  block: Block,
+  hooks: VaporTransitionHooks,
+): void {
+  if (isFragment(block)) {
+    setTransitionHooks(block, hooks)
+  } else if (isArray(block)) {
+    for (let i = 0; i < block.length; i++) {
+      setTransitionHooksOnFragment(block[i], hooks)
+    }
+  }
+}
+
+export function setTransitionHooks(
+  block: TransitionBlock | VaporComponentInstance,
+  hooks: VaporTransitionHooks,
+): void {
+  if (isVaporComponent(block)) {
+    block = findTransitionBlock(block.block) as TransitionBlock
+    if (!block) return
+  }
+  block.$transition = hooks
+}
diff --git a/packages/runtime-vapor/src/components/TransitionGroup.ts b/packages/runtime-vapor/src/components/TransitionGroup.ts
new file mode 100644
index 00000000000..074a28c4ac6
--- /dev/null
+++ b/packages/runtime-vapor/src/components/TransitionGroup.ts
@@ -0,0 +1,227 @@
+import {
+  type ElementWithTransition,
+  type TransitionGroupProps,
+  TransitionPropsValidators,
+  baseApplyTranslation,
+  callPendingCbs,
+  currentInstance,
+  forceReflow,
+  handleMovedChildren,
+  hasCSSTransform,
+  onBeforeUpdate,
+  onUpdated,
+  resolveTransitionProps,
+  useTransitionState,
+  warn,
+} from '@vue/runtime-dom'
+import { extend, isArray } from '@vue/shared'
+import {
+  type Block,
+  DynamicFragment,
+  type TransitionBlock,
+  type VaporTransitionHooks,
+  insert,
+  isFragment,
+} from '../block'
+import {
+  resolveTransitionHooks,
+  setTransitionHooks,
+  setTransitionHooksOnFragment,
+} from './Transition'
+import {
+  type ObjectVaporComponent,
+  type VaporComponentInstance,
+  applyFallthroughProps,
+  isVaporComponent,
+} from '../component'
+import { isForBlock } from '../apiCreateFor'
+import { renderEffect } from '../renderEffect'
+import { createElement } from '../dom/node'
+
+const positionMap = new WeakMap()
+const newPositionMap = new WeakMap()
+
+const decorate = (t: typeof VaporTransitionGroup) => {
+  delete (t.props! as any).mode
+  t.__vapor = true
+  return t
+}
+
+export const VaporTransitionGroup: ObjectVaporComponent = decorate({
+  name: 'VaporTransitionGroup',
+
+  props: /*@__PURE__*/ extend({}, TransitionPropsValidators, {
+    tag: String,
+    moveClass: String,
+  }),
+
+  setup(props: TransitionGroupProps, { slots }) {
+    const instance = currentInstance as VaporComponentInstance
+    const state = useTransitionState()
+    const cssTransitionProps = resolveTransitionProps(props)
+
+    let prevChildren: TransitionBlock[]
+    let children: TransitionBlock[]
+    let slottedBlock: Block
+
+    onBeforeUpdate(() => {
+      prevChildren = []
+      children = getTransitionBlocks(slottedBlock)
+      if (children) {
+        for (let i = 0; i < children.length; i++) {
+          const child = children[i]
+          if (isValidTransitionBlock(child)) {
+            prevChildren.push(child)
+            // disabled transition during enter, so the children will be
+            // inserted into the correct position immediately. this prevents
+            // `recordPosition` from getting incorrect positions in `onUpdated`
+            child.$transition!.disabled = true
+            positionMap.set(
+              child,
+              getTransitionElement(child).getBoundingClientRect(),
+            )
+          }
+        }
+      }
+    })
+
+    onUpdated(() => {
+      if (!prevChildren.length) {
+        return
+      }
+
+      const moveClass = props.moveClass || `${props.name || 'v'}-move`
+      const firstChild = getFirstConnectedChild(prevChildren)
+      if (
+        !firstChild ||
+        !hasCSSTransform(
+          firstChild as ElementWithTransition,
+          firstChild.parentNode as Node,
+          moveClass,
+        )
+      ) {
+        prevChildren = []
+        return
+      }
+
+      prevChildren.forEach(callPendingCbs)
+      prevChildren.forEach(child => {
+        child.$transition!.disabled = false
+        recordPosition(child)
+      })
+      const movedChildren = prevChildren.filter(applyTranslation)
+
+      // force reflow to put everything in position
+      forceReflow()
+
+      movedChildren.forEach(c =>
+        handleMovedChildren(
+          getTransitionElement(c) as ElementWithTransition,
+          moveClass,
+        ),
+      )
+      prevChildren = []
+    })
+
+    slottedBlock = slots.default && slots.default()
+
+    // store props and state on fragment for reusing during insert new items
+    setTransitionHooksOnFragment(slottedBlock, {
+      props: cssTransitionProps,
+      state,
+      instance,
+    } as VaporTransitionHooks)
+
+    children = getTransitionBlocks(slottedBlock)
+    for (let i = 0; i < children.length; i++) {
+      const child = children[i]
+      if (isValidTransitionBlock(child)) {
+        if (child.$key != null) {
+          setTransitionHooks(
+            child,
+            resolveTransitionHooks(child, cssTransitionProps, state, instance!),
+          )
+        } else if (__DEV__ && child.$key == null) {
+          warn(` children must be keyed`)
+        }
+      }
+    }
+
+    const tag = props.tag
+    if (tag) {
+      const container = createElement(tag)
+      insert(slottedBlock, container)
+      // fallthrough attrs
+      if (instance!.hasFallthrough) {
+        ;(container as any).$root = true
+        renderEffect(() => applyFallthroughProps(container, instance!.attrs))
+      }
+      return container
+    } else {
+      const frag = __DEV__
+        ? new DynamicFragment('transition-group')
+        : new DynamicFragment()
+      renderEffect(() => frag.update(() => slottedBlock))
+      return frag
+    }
+  },
+})
+
+function getTransitionBlocks(block: Block) {
+  let children: TransitionBlock[] = []
+  if (block instanceof Node) {
+    children.push(block)
+  } else if (isVaporComponent(block)) {
+    children.push(...getTransitionBlocks(block.block))
+  } else if (isArray(block)) {
+    for (let i = 0; i < block.length; i++) {
+      const b = block[i]
+      const blocks = getTransitionBlocks(b)
+      if (isForBlock(b)) blocks.forEach(block => (block.$key = b.key))
+      children.push(...blocks)
+    }
+  } else if (isFragment(block)) {
+    if (block.insert) {
+      // vdom component
+      children.push(block)
+    } else {
+      children.push(...getTransitionBlocks(block.nodes))
+    }
+  }
+
+  return children
+}
+
+function isValidTransitionBlock(block: Block): boolean {
+  return !!(block instanceof Element || (isFragment(block) && block.insert))
+}
+
+function getTransitionElement(c: TransitionBlock): Element {
+  return (isFragment(c) ? (c.nodes as Element) : c) as Element
+}
+
+function recordPosition(c: TransitionBlock) {
+  newPositionMap.set(c, getTransitionElement(c).getBoundingClientRect())
+}
+
+function applyTranslation(c: TransitionBlock): TransitionBlock | undefined {
+  if (
+    baseApplyTranslation(
+      positionMap.get(c)!,
+      newPositionMap.get(c)!,
+      getTransitionElement(c) as ElementWithTransition,
+    )
+  ) {
+    return c
+  }
+}
+
+function getFirstConnectedChild(
+  children: TransitionBlock[],
+): Element | undefined {
+  for (let i = 0; i < children.length; i++) {
+    const child = children[i]
+    const el = getTransitionElement(child)
+    if (el.isConnected) return el
+  }
+}
diff --git a/packages/runtime-vapor/src/directives/vShow.ts b/packages/runtime-vapor/src/directives/vShow.ts
index 6ed28dfddfe..bb94acf95c2 100644
--- a/packages/runtime-vapor/src/directives/vShow.ts
+++ b/packages/runtime-vapor/src/directives/vShow.ts
@@ -6,7 +6,12 @@ import {
 } from '@vue/runtime-dom'
 import { renderEffect } from '../renderEffect'
 import { isVaporComponent } from '../component'
-import { type Block, DynamicFragment, VaporFragment } from '../block'
+import {
+  type Block,
+  DynamicFragment,
+  type TransitionBlock,
+  VaporFragment,
+} from '../block'
 import { isArray } from '@vue/shared'
 
 export function applyVShow(target: Block, source: () => any): void {
@@ -49,13 +54,33 @@ function setDisplay(target: Block, value: unknown): void {
   if (target instanceof VaporFragment && target.insert) {
     return setDisplay(target.nodes, value)
   }
+
+  const { $transition } = target as TransitionBlock
   if (target instanceof Element) {
     const el = target as VShowElement
     if (!(vShowOriginalDisplay in el)) {
       el[vShowOriginalDisplay] =
         el.style.display === 'none' ? '' : el.style.display
     }
-    el.style.display = value ? el[vShowOriginalDisplay]! : 'none'
+    if ($transition) {
+      if (value) {
+        $transition.beforeEnter(target)
+        el.style.display = el[vShowOriginalDisplay]!
+        $transition.enter(target)
+      } else {
+        // during initial render, the element is not yet inserted into the
+        // DOM, and it is hidden, no need to trigger transition
+        if (target.isConnected) {
+          $transition.leave(target, () => {
+            el.style.display = 'none'
+          })
+        } else {
+          el.style.display = 'none'
+        }
+      }
+    } else {
+      el.style.display = value ? el[vShowOriginalDisplay]! : 'none'
+    }
     el[vShowHidden] = !value
   } else if (__DEV__) {
     warn(
diff --git a/packages/runtime-vapor/src/dom/node.ts b/packages/runtime-vapor/src/dom/node.ts
index 83bc32c57f0..26cb66c462c 100644
--- a/packages/runtime-vapor/src/dom/node.ts
+++ b/packages/runtime-vapor/src/dom/node.ts
@@ -1,3 +1,8 @@
+/*! #__NO_SIDE_EFFECTS__ */
+export function createElement(tagName: string): HTMLElement {
+  return document.createElement(tagName)
+}
+
 /*! #__NO_SIDE_EFFECTS__ */
 export function createTextNode(value = ''): Text {
   return document.createTextNode(value)
diff --git a/packages/runtime-vapor/src/dom/prop.ts b/packages/runtime-vapor/src/dom/prop.ts
index 346bea53eb1..71aaaf28532 100644
--- a/packages/runtime-vapor/src/dom/prop.ts
+++ b/packages/runtime-vapor/src/dom/prop.ts
@@ -270,6 +270,9 @@ export function optimizePropertyLookup(): void {
   if (isOptimized) return
   isOptimized = true
   const proto = Element.prototype as any
+  proto.$transition = undefined
+  proto.$key = undefined
+  proto.$evtclick = undefined
   proto.$anchor = proto.$evtclick = undefined
   proto.$root = false
   proto.$html =
diff --git a/packages/runtime-vapor/src/dom/template.ts b/packages/runtime-vapor/src/dom/template.ts
index b78ca4e52cf..7bfbca4e52b 100644
--- a/packages/runtime-vapor/src/dom/template.ts
+++ b/packages/runtime-vapor/src/dom/template.ts
@@ -1,5 +1,5 @@
+import { child, createElement, createTextNode } from './node'
 import { adoptTemplate, currentHydrationNode, isHydrating } from './hydration'
-import { child, createTextNode } from './node'
 
 let t: HTMLTemplateElement
 
@@ -19,7 +19,7 @@ export function template(html: string, root?: boolean) {
       return createTextNode(html)
     }
     if (!node) {
-      t = t || document.createElement('template')
+      t = t || createElement('template')
       t.innerHTML = html
       node = child(t.content)
     }
diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts
index 7a8aea5a0d7..bad4b4f343a 100644
--- a/packages/runtime-vapor/src/index.ts
+++ b/packages/runtime-vapor/src/index.ts
@@ -29,6 +29,7 @@ export {
 } from './dom/prop'
 export { on, delegate, delegateEvents, setDynamicEvents } from './dom/event'
 export { createIf } from './apiCreateIf'
+export { createKeyedFragment } from './apiCreateFragment'
 export {
   createFor,
   createForSlots,
@@ -46,3 +47,5 @@ export {
   applyDynamicModel,
 } from './directives/vModel'
 export { withVaporDirectives } from './directives/custom'
+export { VaporTransition } from './components/Transition'
+export { VaporTransitionGroup } from './components/TransitionGroup'
diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts
index adc54526175..8458198e7cc 100644
--- a/packages/runtime-vapor/src/vdomInterop.ts
+++ b/packages/runtime-vapor/src/vdomInterop.ts
@@ -7,6 +7,7 @@ import {
   type RendererInternals,
   type ShallowRef,
   type Slots,
+  type TransitionHooks,
   type VNode,
   type VaporInteropInterface,
   createInternalObject,
@@ -16,6 +17,7 @@ import {
   isEmitListener,
   onScopeDispose,
   renderSlot,
+  setTransitionHooks as setVNodeTransitionHooks,
   shallowReactive,
   shallowRef,
   simpleSetCurrentInstance,
@@ -29,13 +31,20 @@ import {
   mountComponent,
   unmountComponent,
 } from './component'
-import { type Block, VaporFragment, insert, remove } from './block'
-import { EMPTY_OBJ, extend, isFunction } from '@vue/shared'
+import {
+  type Block,
+  VaporFragment,
+  type VaporTransitionHooks,
+  insert,
+  remove,
+} from './block'
+import { EMPTY_OBJ, extend, isFunction, isReservedProp } from '@vue/shared'
 import { type RawProps, rawPropsProxyHandlers } from './componentProps'
 import type { RawSlots, VaporSlot } from './componentSlots'
 import { renderEffect } from './renderEffect'
 import { createTextNode } from './dom/node'
 import { optimizePropertyLookup } from './dom/prop'
+import { setTransitionHooks as setVaporTransitionHooks } from './components/Transition'
 
 export const interopKey: unique symbol = Symbol(`interop`)
 
@@ -50,7 +59,15 @@ const vaporInteropImpl: Omit<
     const prev = currentInstance
     simpleSetCurrentInstance(parentComponent)
 
-    const propsRef = shallowRef(vnode.props)
+    // filter out reserved props
+    const props: VNode['props'] = {}
+    for (const key in vnode.props) {
+      if (!isReservedProp(key)) {
+        props[key] = vnode.props[key]
+      }
+    }
+
+    const propsRef = shallowRef(props)
     const slotsRef = shallowRef(vnode.children)
 
     const dynamicPropSource: (() => any)[] & { [interopKey]?: boolean } = [
@@ -70,6 +87,12 @@ const vaporInteropImpl: Omit<
     ))
     instance.rawPropsRef = propsRef
     instance.rawSlotsRef = slotsRef
+    if (vnode.transition) {
+      setVaporTransitionHooks(
+        instance,
+        vnode.transition as VaporTransitionHooks,
+      )
+    }
     mountComponent(instance, container, selfAnchor)
     simpleSetCurrentInstance(prev)
     return instance
@@ -123,6 +146,10 @@ const vaporInteropImpl: Omit<
     insert(vnode.vb || (vnode.component as any), container, anchor)
     insert(vnode.anchor as any, container, anchor)
   },
+
+  setTransitionHooks(component, hooks) {
+    setVaporTransitionHooks(component as any, hooks as VaporTransitionHooks)
+  },
 }
 
 const vaporSlotPropsProxyHandler: ProxyHandler<
@@ -189,12 +216,16 @@ function createVDOMComponent(
 
   let isMounted = false
   const parentInstance = currentInstance as VaporComponentInstance
-  const unmount = (parentNode?: ParentNode) => {
+  const unmount = (parentNode?: ParentNode, transition?: TransitionHooks) => {
+    if (transition) setVNodeTransitionHooks(vnode, transition)
     internals.umt(vnode.component!, null, !!parentNode)
   }
 
-  frag.insert = (parentNode, anchor) => {
+  frag.insert = (parentNode, anchor, transition) => {
+    const prev = currentInstance
+    simpleSetCurrentInstance(parentInstance)
     if (!isMounted) {
+      if (transition) setVNodeTransitionHooks(vnode, transition)
       internals.mt(
         vnode,
         parentNode,
@@ -218,6 +249,7 @@ function createVDOMComponent(
     }
 
     frag.nodes = vnode.el as Block
+    simpleSetCurrentInstance(prev)
   }
 
   frag.remove = unmount
diff --git a/packages/vue/__tests__/e2e/e2eUtils.ts b/packages/vue/__tests__/e2e/e2eUtils.ts
index 2ffebeb5950..ac05a47e7e0 100644
--- a/packages/vue/__tests__/e2e/e2eUtils.ts
+++ b/packages/vue/__tests__/e2e/e2eUtils.ts
@@ -50,6 +50,16 @@ interface PuppeteerUtils {
   clearValue(selector: string): Promise
   timeout(time: number): Promise
   nextFrame(): Promise
+  transitionStart(
+    btnSelector: string,
+    containerSelector: string,
+  ): Promise<{ classNames: string[]; innerHTML: string }>
+  waitForElement(
+    selector: string,
+    text: string,
+    classNames: string[],
+    timeout?: number,
+  ): Promise
 }
 
 export function setupPuppeteer(args?: string[]): PuppeteerUtils {
@@ -200,6 +210,43 @@ export function setupPuppeteer(args?: string[]): PuppeteerUtils {
     })
   }
 
+  const transitionStart = (btnSelector: string, containerSelector: string) =>
+    page.evaluate(
+      ([btnSel, containerSel]) => {
+        ;(document.querySelector(btnSel) as HTMLElement)!.click()
+        return Promise.resolve().then(() => {
+          const container = document.querySelector(containerSel)!
+          return {
+            classNames: container.className.split(/\s+/g),
+            innerHTML: container.innerHTML,
+          }
+        })
+      },
+      [btnSelector, containerSelector],
+    )
+
+  const waitForElement = (
+    selector: string,
+    text: string,
+    classNames: string[], // if empty, check for no classes
+    timeout = 2000,
+  ) =>
+    page.waitForFunction(
+      (sel, expectedText, expectedClasses) => {
+        const el = document.querySelector(sel)
+        const hasClasses =
+          expectedClasses.length === 0
+            ? el?.classList.length === 0
+            : expectedClasses.every(c => el?.classList.contains(c))
+        const hasText = el?.textContent?.includes(expectedText)
+        return !!el && hasClasses && hasText
+      },
+      { timeout },
+      selector,
+      text,
+      classNames,
+    )
+
   return {
     page: () => page,
     click,
@@ -219,5 +266,7 @@ export function setupPuppeteer(args?: string[]): PuppeteerUtils {
     clearValue,
     timeout,
     nextFrame,
+    transitionStart,
+    waitForElement,
   }
 }
diff --git a/packages/vue/__tests__/e2e/style.css b/packages/vue/__tests__/e2e/style.css
new file mode 100644
index 00000000000..ae6749b3afb
--- /dev/null
+++ b/packages/vue/__tests__/e2e/style.css
@@ -0,0 +1,77 @@
+.test {
+  -webkit-transition: opacity 50ms ease;
+  transition: opacity 50ms ease;
+}
+.group-move {
+  -webkit-transition: -webkit-transform 50ms ease;
+  transition: transform 50ms ease;
+}
+.v-appear,
+.v-enter,
+.v-leave-active,
+.test-appear,
+.test-enter,
+.test-leave-active,
+.test-reflow-enter,
+.test-reflow-leave-to,
+.hello,
+.bye.active,
+.changed-enter {
+  opacity: 0;
+}
+.test-reflow-leave-active,
+.test-reflow-enter-active {
+  -webkit-transition: opacity 50ms ease;
+  transition: opacity 50ms ease;
+}
+.test-reflow-leave-from {
+  opacity: 0.9;
+}
+.test-anim-enter-active {
+  animation: test-enter 50ms;
+  -webkit-animation: test-enter 50ms;
+}
+.test-anim-leave-active {
+  animation: test-leave 50ms;
+  -webkit-animation: test-leave 50ms;
+}
+.test-anim-long-enter-active {
+  animation: test-enter 100ms;
+  -webkit-animation: test-enter 100ms;
+}
+.test-anim-long-leave-active {
+  animation: test-leave 100ms;
+  -webkit-animation: test-leave 100ms;
+}
+@keyframes test-enter {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+@-webkit-keyframes test-enter {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+@keyframes test-leave {
+  from {
+    opacity: 1;
+  }
+  to {
+    opacity: 0;
+  }
+}
+@-webkit-keyframes test-leave {
+  from {
+    opacity: 1;
+  }
+  to {
+    opacity: 0;
+  }
+}
diff --git a/packages/vue/__tests__/e2e/transition.html b/packages/vue/__tests__/e2e/transition.html
index ab404d67dc7..7f5fce9e34a 100644
--- a/packages/vue/__tests__/e2e/transition.html
+++ b/packages/vue/__tests__/e2e/transition.html
@@ -1,82 +1,4 @@
 
 
 
-
+