feat(ui): add animated content primitive (#2212)

This commit is contained in:
Neko
2026-08-04 17:48:17 +08:00
committed by GitHub
parent 4f4d256a49
commit 47c23e6467
5 changed files with 241 additions and 26 deletions
+15
View File
@@ -10,6 +10,21 @@ Source: `packages/ui/src/components/`
## Animations
### AnimatedContent
Behavior-only primitive that animates height, opacity, vertical offset, and an
inner content blur from an external `data-state="open|closed"` lifecycle. The
lifecycle owner must keep the primitive mounted until the closing animation
finishes. It can compose with Reka UI content primitives through `as-child`, but
does not depend on a specific menu, popover, or presence implementation. Visual
styling remains caller-owned.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `as` | `PrimitiveProps['as']?` | `'div'` | Element or component rendered as the animated outer container |
**Slots**: `default`
### TransitionBidirectional
Bidirectional Vue `<Transition>` wrapper with customizable CSS classes.
@@ -0,0 +1,199 @@
<script lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import { Primitive } from 'reka-ui'
/**
* A polymorphic content container animated by an external presence lifecycle.
*
* The lifecycle owner must apply `data-state="open"` or `data-state="closed"`
* to the rendered root and keep it mounted until the closing animation ends.
*/
export interface AnimatedContentProps {
/**
* Element or component rendered as the animated outer container.
*
* @default 'div'
*/
as?: PrimitiveProps['as']
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<AnimatedContentProps>(), {
as: 'div',
})
</script>
<template>
<Primitive
:as="props.as"
class="animated-content"
>
<div class="animated-content__inner">
<slot />
</div>
</Primitive>
</template>
<style>
/*
* Motion references and intentional differences:
*
* - drawesome's MorphBar measures the active panel's `scrollHeight` or
* `scrollWidth`, keeps that measurement current with `ResizeObserver`, and
* transitions the resulting numeric container size:
* `https://github.com/benjitaylor/drawesome/blob/df6ad3efcf24bb05a37b9fcfe8d4fa021aa8c8cd/packages/draw/src/components/MorphBar.tsx#L33-L77`
* `https://github.com/benjitaylor/drawesome/blob/df6ad3efcf24bb05a37b9fcfe8d4fa021aa8c8cd/packages/draw/src/components/MorphBar.module.css#L24-L33`
*
* - drawesome also separates the container's shape transition from the
* content's opacity/filter/scale transition. In particular, its panel starts
* at `blur(8px)` and focuses independently while the container resizes:
* `https://github.com/benjitaylor/drawesome/blob/df6ad3efcf24bb05a37b9fcfe8d4fa021aa8c8cd/packages/draw/src/components/MorphBar.module.css#L139-L177`
*
* - Video.js v10 uses the same separation for popups: the popup host transitions
* opacity/filter/transform/scale, while settings menus add width and height
* only when changing between submenu views:
* `https://github.com/videojs/v10/blob/35616db5a38d193f1fc114da4af68a79f08093f1/packages/skins/src/default/tailwind/components/popup.ts#L3-L17`
* `https://github.com/videojs/v10/blob/35616db5a38d193f1fc114da4af68a79f08093f1/packages/skins/src/default/tailwind/components/menu.ts#L69-L90`
* Its generated CSS shows the concrete popup `blur(4px)` and menu-panel
* `blur(8px)` values:
* `https://github.com/videojs/v10/blob/35616db5a38d193f1fc114da4af68a79f08093f1/packages/skins/src/default/css/components/popup.css#L13-L35`
* `https://github.com/videojs/v10/blob/35616db5a38d193f1fc114da4af68a79f08093f1/packages/skins/src/default/css/components/menus.css#L25-L77`
*
* This primitive follows those projects by resizing the outer box and focusing
* an inner content layer separately. It deliberately does not copy drawesome's
* JavaScript measurement: AIRI's Electron Chromium supports native intrinsic
* size interpolation, so `interpolate-size: allow-keywords` can animate from
* `0` to `auto` without layout observers. The feature query keeps opacity and
* translation as a fallback for other browsers. Browser behavior reference:
* `https://developer.chrome.com/docs/css-ui/animate-to-height-auto`
*
* Presence owners such as Reka keep closing content mounted and expose their
* lifecycle as `data-state="open|closed"`. Consuming that state avoids a second
* Vue source of truth and allows the closing keyframes to finish:
* `https://github.com/unovue/reka-ui/blob/47c433a84ad819eb6becbffc0cc7419e282cf07e/packages/core/src/Menu/MenuContent.vue#L19-L47`
* `https://github.com/unovue/reka-ui/blob/47c433a84ad819eb6becbffc0cc7419e282cf07e/packages/core/src/Menu/MenuContentImpl.vue#L375-L383`
*
* The 220ms/160ms timings, 6px blur, and easing curves are AIRI-specific tuning,
* not copied constants. Blur stays on the inner wrapper so it cannot blur the
* caller's border or shadow. `overflow: clip` clips only descendants during the
* height animation; the root's own box-shadow remains visible from frame one.
*
* This primitive may be teleported by its lifecycle owner. The component-owned
* classes keep the selectors reliable outside the caller's scope.
*/
.animated-content {
overflow: clip;
}
.animated-content[data-state="open"] {
animation: animated-content-fade-expand 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
.animated-content[data-state="closed"] {
animation: animated-content-fade-collapse 160ms ease-in;
}
.animated-content[data-state="open"] .animated-content__inner {
animation: animated-content-inner-focus-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
.animated-content[data-state="closed"] .animated-content__inner {
animation: animated-content-inner-focus-out 160ms ease-in;
}
@supports (interpolate-size: allow-keywords) {
.animated-content {
height: auto;
interpolate-size: allow-keywords;
}
.animated-content[data-state="open"] {
animation-name: animated-content-size-expand;
}
.animated-content[data-state="closed"] {
animation-name: animated-content-size-collapse;
}
}
@keyframes animated-content-fade-expand {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes animated-content-fade-collapse {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-4px);
}
}
@keyframes animated-content-size-expand {
from {
height: 0;
opacity: 0;
transform: translateY(-4px);
}
to {
height: auto;
opacity: 1;
transform: translateY(0);
}
}
@keyframes animated-content-size-collapse {
from {
height: auto;
opacity: 1;
transform: translateY(0);
}
to {
height: 0;
opacity: 0;
transform: translateY(-4px);
}
}
@keyframes animated-content-inner-focus-in {
from {
filter: blur(6px);
}
to {
filter: blur(0);
}
}
@keyframes animated-content-inner-focus-out {
from {
filter: blur(0);
}
to {
filter: blur(6px);
}
}
@media (prefers-reduced-motion: reduce) {
.animated-content[data-state],
.animated-content[data-state] .animated-content__inner {
animation: none;
}
}
</style>
@@ -1,3 +1,4 @@
export { default as AnimatedContent, type AnimatedContentProps } from './animated-content.vue'
export { default as TransitionBidirectional } from './transition-bidirectional.vue'
export {
/**
+25 -25
View File
@@ -925,8 +925,8 @@ catalogs:
specifier: ^10.0.1
version: 10.0.1
reka-ui:
specifier: ^2.9.6
version: 2.9.6
specifier: ^2.10.1
version: 2.10.1
remark-math:
specifier: ^6.0.0
version: 6.0.0
@@ -1607,7 +1607,7 @@ importers:
version: 10.0.1
reka-ui:
specifier: 'catalog:'
version: 2.9.6(vue@3.5.32(typescript@5.9.3))
version: 2.10.1(vue@3.5.32(typescript@5.9.3))
remark-parse:
specifier: 'catalog:'
version: 11.0.0
@@ -1637,7 +1637,7 @@ importers:
version: 1.3.1(typescript@5.9.3)
vaul-vue:
specifier: 'catalog:'
version: 0.4.1(reka-ui@2.9.6(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
version: 0.4.1(reka-ui@2.10.1(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
vue:
specifier: 'catalog:'
version: 3.5.32(typescript@5.9.3)
@@ -2040,7 +2040,7 @@ importers:
version: 10.0.1
reka-ui:
specifier: 'catalog:'
version: 2.9.6(vue@3.5.32(typescript@5.9.3))
version: 2.10.1(vue@3.5.32(typescript@5.9.3))
remark-parse:
specifier: 'catalog:'
version: 11.0.0
@@ -2082,7 +2082,7 @@ importers:
version: 1.3.1(typescript@5.9.3)
vaul-vue:
specifier: 'catalog:'
version: 0.4.1(reka-ui@2.9.6(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
version: 0.4.1(reka-ui@2.10.1(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
vue:
specifier: 'catalog:'
version: 3.5.32(typescript@5.9.3)
@@ -2506,7 +2506,7 @@ importers:
version: 10.0.1
reka-ui:
specifier: 'catalog:'
version: 2.9.6(vue@3.5.32(typescript@5.9.3))
version: 2.10.1(vue@3.5.32(typescript@5.9.3))
remark-parse:
specifier: 'catalog:'
version: 11.0.0
@@ -2536,7 +2536,7 @@ importers:
version: 1.3.1(typescript@5.9.3)
vaul-vue:
specifier: 'catalog:'
version: 0.4.1(reka-ui@2.9.6(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
version: 0.4.1(reka-ui@2.10.1(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
vue:
specifier: 'catalog:'
version: 3.5.32(typescript@5.9.3)
@@ -2777,7 +2777,7 @@ importers:
version: 13.0.0
vaul-vue:
specifier: 'catalog:'
version: 0.4.1(reka-ui@2.9.6(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
version: 0.4.1(reka-ui@2.10.1(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
vue:
specifier: 'catalog:'
version: 3.5.32(typescript@5.9.3)
@@ -2934,7 +2934,7 @@ importers:
version: 1.306.1
reka-ui:
specifier: 'catalog:'
version: 2.9.6(vue@3.5.32(typescript@5.9.3))
version: 2.10.1(vue@3.5.32(typescript@5.9.3))
vue:
specifier: 'catalog:'
version: 3.5.32(typescript@5.9.3)
@@ -3801,7 +3801,7 @@ importers:
version: 1.12.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
'@vishot/mockup-desktop-vue':
specifier: 'catalog:'
version: 0.0.3(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(reka-ui@2.9.6(vue@3.5.32(typescript@5.9.3)))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
version: 0.0.3(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(reka-ui@2.10.1(vue@3.5.32(typescript@5.9.3)))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@vishot/renderer-browser':
specifier: 'catalog:'
version: 0.0.3(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
@@ -3813,7 +3813,7 @@ importers:
version: 4.1.0
reka-ui:
specifier: 'catalog:'
version: 2.9.6(vue@3.5.32(typescript@5.9.3))
version: 2.10.1(vue@3.5.32(typescript@5.9.3))
vue:
specifier: 'catalog:'
version: 3.5.32(typescript@5.9.3)
@@ -3988,7 +3988,7 @@ importers:
version: 1.306.1
reka-ui:
specifier: 'catalog:'
version: 2.9.6(vue@3.5.32(typescript@5.9.3))
version: 2.10.1(vue@3.5.32(typescript@5.9.3))
unspeech:
specifier: catalog:xsai
version: 0.1.14
@@ -4112,7 +4112,7 @@ importers:
version: 1.306.1
reka-ui:
specifier: 'catalog:'
version: 2.9.6(vue@3.5.32(typescript@5.9.3))
version: 2.10.1(vue@3.5.32(typescript@5.9.3))
splitpanes:
specifier: 'catalog:'
version: 4.0.4(vue@3.5.32(typescript@5.9.3))
@@ -4432,7 +4432,7 @@ importers:
version: 10.0.1
reka-ui:
specifier: 'catalog:'
version: 2.9.6(vue@3.5.32(typescript@5.9.3))
version: 2.10.1(vue@3.5.32(typescript@5.9.3))
remark-math:
specifier: 'catalog:'
version: 6.0.0
@@ -4471,7 +4471,7 @@ importers:
version: 1.3.1(typescript@5.9.3)
vaul-vue:
specifier: 'catalog:'
version: 0.4.1(reka-ui@2.9.6(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
version: 0.4.1(reka-ui@2.10.1(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
vue-i18n:
specifier: 'catalog:'
version: 11.3.2(vue@3.5.32(typescript@5.9.3))
@@ -4986,7 +4986,7 @@ importers:
version: 5.2.2(@nuxt/kit@3.20.2(magicast@0.5.2))(vue@3.5.32(typescript@5.9.3))
reka-ui:
specifier: 'catalog:'
version: 2.9.6(vue@3.5.32(typescript@5.9.3))
version: 2.10.1(vue@3.5.32(typescript@5.9.3))
vue:
specifier: 'catalog:'
version: 3.5.32(typescript@5.9.3)
@@ -5164,7 +5164,7 @@ importers:
version: 1.4.0
reka-ui:
specifier: 'catalog:'
version: 2.9.6(vue@3.5.32(typescript@5.9.3))
version: 2.10.1(vue@3.5.32(typescript@5.9.3))
stockfish:
specifier: 'catalog:'
version: 18.0.7
@@ -17262,8 +17262,8 @@ packages:
rehype-stringify@10.0.1:
resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==}
reka-ui@2.9.6:
resolution: {integrity: sha512-K6bL457owpvWONc7hsjFxo3HDC9s6IzhRqShW0w9JSKelPGfRbkHD558UQTn/NH1cvrXVHygKyC7fExFmRketg==}
reka-ui@2.10.1:
resolution: {integrity: sha512-drcOQ4rQtDYAcGCsyQBqQg8QQ+H3B+zDaMJU0h8KPEPMa7g9BHu3zcOi4OB39XJSWizceFoNO0Z9tctSGLOXqg==}
peerDependencies:
vue: '>= 3.4.0'
@@ -25754,11 +25754,11 @@ snapshots:
'@vishot/core@0.0.3': {}
'@vishot/mockup-desktop-vue@0.0.3(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(reka-ui@2.9.6(vue@3.5.32(typescript@5.9.3)))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))':
'@vishot/mockup-desktop-vue@0.0.3(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(reka-ui@2.10.1(vue@3.5.32(typescript@5.9.3)))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@vishot/core': 0.0.3
'@vueuse/core': 14.2.1(vue@3.5.32(typescript@5.9.3))
reka-ui: 2.9.6(vue@3.5.32(typescript@5.9.3))
reka-ui: 2.10.1(vue@3.5.32(typescript@5.9.3))
vue: 3.5.32(typescript@5.9.3)
vue-router: 5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
@@ -32783,7 +32783,7 @@ snapshots:
hast-util-to-html: 9.0.5
unified: 11.0.5
reka-ui@2.9.6(vue@3.5.32(typescript@5.9.3)):
reka-ui@2.10.1(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@floating-ui/dom': 1.7.4
'@floating-ui/vue': 1.1.9(vue@3.5.32(typescript@5.9.3))
@@ -34575,10 +34575,10 @@ snapshots:
vary@1.1.2: {}
vaul-vue@0.4.1(reka-ui@2.9.6(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)):
vaul-vue@0.4.1(reka-ui@2.10.1(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vueuse/core': 10.11.1(vue@3.5.32(typescript@5.9.3))
reka-ui: 2.9.6(vue@3.5.32(typescript@5.9.3))
reka-ui: 2.10.1(vue@3.5.32(typescript@5.9.3))
vue: 3.5.32(typescript@5.9.3)
transitivePeerDependencies:
- '@vue/composition-api'
+1 -1
View File
@@ -339,7 +339,7 @@ catalog:
rehype-katex: ^7.0.1
rehype-parse: ^9.0.1
rehype-stringify: ^10.0.1
reka-ui: ^2.9.6
reka-ui: ^2.10.1
remark-math: ^6.0.0
remark-parse: ^11.0.0
remark-rehype: ^11.1.2