From 3bafe61372753bdc42e7619dd1b1bae44fde4070 Mon Sep 17 00:00:00 2001 From: Orr Gottlieb Date: Fri, 22 May 2026 14:27:15 +0100 Subject: [PATCH 1/4] feat(animations): refresh popovers, Toast, Modal, Skeleton with motion tokens and reduced-motion support Introduces a motion-safe/motion-reduce mixin foundation in @vibe/style and applies it consistently across Toast, Modal, Skeleton, Tab, and the shared DialogContent primitive (which powers Tooltip, Menu submenus, and Dropdown). Replaces hardcoded durations and easing with motion tokens, normalizes keyframes to clean from/to pairs, and tightens scale-pop ratios for a more refined feel. All non-essential animations now respect prefers-reduced-motion. Co-Authored-By: Claude Opus 4.7 --- .../DialogContent/DialogContent.module.scss | 187 ++++++++++-------- .../components/Modal/Modal/Modal.module.scss | 131 ++++++------ .../components/Skeleton/Skeleton.module.scss | 31 +-- .../src/components/Tabs/Tab/Tab.module.scss | 6 +- .../src/components/Toast/Toast.module.scss | 72 +++---- packages/style/src/mixins/_motion.scss | 15 ++ packages/style/src/mixins/index.scss | 1 + 7 files changed, 253 insertions(+), 190 deletions(-) create mode 100644 packages/style/src/mixins/_motion.scss diff --git a/packages/components/dialog/src/Dialog/components/DialogContent/DialogContent.module.scss b/packages/components/dialog/src/Dialog/components/DialogContent/DialogContent.module.scss index 9638037113..915665b938 100644 --- a/packages/components/dialog/src/Dialog/components/DialogContent/DialogContent.module.scss +++ b/packages/components/dialog/src/Dialog/components/DialogContent/DialogContent.module.scss @@ -1,4 +1,5 @@ @import "~@vibe/style/dist/mixins"; +@import "~@vibe/style/dist/mixins/motion"; .contentWrapper { outline: 0; @@ -53,121 +54,147 @@ } // Animations +// +// Two flavors driven by Dialog's `animationType` prop: +// - opacity-and-slide: fade + 16px translate from origin (default for popovers) +// - expand: scale-from-origin + fade (used by Tooltip, Menu submenus) +// +// Both are gated on `prefers-reduced-motion: no-preference`. Reduced-motion +// users get the popover at rest immediately with no transition. $translate-minus-px: calc(var(--space-16) * -1); .opacitySlideAppear { - opacity: 0; + @include motion-safe { + opacity: 0; - &.top { - transform: translateY(var(--space-16)); - } + &.top { + transform: translateY(var(--space-16)); + } - &.right { - transform: translateX($translate-minus-px); - } + &.right { + transform: translateX($translate-minus-px); + } - &.bottom { - transform: translateY($translate-minus-px); - } + &.bottom { + transform: translateY($translate-minus-px); + } - &.left { - transform: translateX(var(--space-16)); + &.left { + transform: translateX(var(--space-16)); + } } } .opacitySlideAppearActive { - transition: opacity 0.2s ease, transform 0.2s ease-out; - opacity: 1; pointer-events: none; - &.top, - &.bottom { - transform: translateY(0); - } + @include motion-safe { + transition: opacity var(--motion-productive-long) var(--motion-timing-enter), + transform var(--motion-productive-long) var(--motion-timing-enter); + opacity: 1; - &.right, - &.left { - transform: translateX(0); + &.top, + &.bottom { + transform: translateY(0); + } + + &.right, + &.left { + transform: translateX(0); + } } } .expandAppear, .expandExit { - transition: transform 0.1s $expand-animation-timing; - &.top, - &.topStart, - &.topEnd { - transform-origin: bottom center; - transform: scale(0.8); - &.edgeBottom { - transform-origin: bottom left; - } - &.edgeTop { - transform-origin: bottom right; + @include motion-safe { + transition: transform var(--motion-productive-long) var(--motion-timing-enter), + opacity var(--motion-productive-long) var(--motion-timing-enter); + opacity: 0; + + &.top, + &.topStart, + &.topEnd { + transform-origin: bottom center; + transform: scale(0.92); + &.edgeBottom { + transform-origin: bottom left; + } + &.edgeTop { + transform-origin: bottom right; + } } - } - &.right, - &.rightStart, - &.rightEnd { - transform-origin: left; - transform: scale(0.8); - &.edgeBottom { - transform-origin: top left; - } - &.edgeTop { - transform-origin: bottom left; + &.right, + &.rightStart, + &.rightEnd { + transform-origin: left; + transform: scale(0.92); + &.edgeBottom { + transform-origin: top left; + } + &.edgeTop { + transform-origin: bottom left; + } } - } - &.bottom, - &.bottomStart, - &.bottomEnd { - transform-origin: top; - transform: scale(0.8); - &.edgeBottom { - transform-origin: top left; + &.bottom, + &.bottomStart, + &.bottomEnd { + transform-origin: top; + transform: scale(0.92); + &.edgeBottom { + transform-origin: top left; + } + &.edgeTop { + transform-origin: top right; + } } - &.edgeTop { - transform-origin: top right; - } - } - &.left, - &.leftStart, - &.leftEnd { - transform-origin: right; - transform: scale(0.8); - &.edgeBottom { - transform-origin: top right; - } - &.edgeTop { - transform-origin: bottom right; + &.left, + &.leftStart, + &.leftEnd { + transform-origin: right; + transform: scale(0.92); + &.edgeBottom { + transform-origin: top right; + } + &.edgeTop { + transform-origin: bottom right; + } } } } .expandExit { - transition: transform 0.1s $expand-animation-timing; + @include motion-safe { + transition: transform var(--motion-productive-medium) var(--motion-timing-exit), + opacity var(--motion-productive-medium) var(--motion-timing-exit); + } } .expandAppearActive { - transition: transform 0.1s $expand-animation-timing; pointer-events: none; - &.top, - &.topStart, - &.topEnd, - &.bottom, - &.bottomStart, - &.bottomEnd, - &.right, - &.rightStart, - &.rightEnd, - &.left, - &.leftStart, - &.leftEnd { - transform: scale(1); + @include motion-safe { + transition: transform var(--motion-productive-long) var(--motion-timing-enter), + opacity var(--motion-productive-long) var(--motion-timing-enter); + opacity: 1; + + &.top, + &.topStart, + &.topEnd, + &.bottom, + &.bottomStart, + &.bottomEnd, + &.right, + &.rightStart, + &.rightEnd, + &.left, + &.leftStart, + &.leftEnd { + transform: scale(1); + } } } diff --git a/packages/core/src/components/Modal/Modal/Modal.module.scss b/packages/core/src/components/Modal/Modal/Modal.module.scss index 6483378b2f..29f16568de 100644 --- a/packages/core/src/components/Modal/Modal/Modal.module.scss +++ b/packages/core/src/components/Modal/Modal/Modal.module.scss @@ -1,3 +1,6 @@ +@import "~@vibe/style/dist/mixins"; +@import "~@vibe/style/dist/mixins/motion"; + $full-view-margin: 40px; .container { @@ -113,129 +116,131 @@ $full-view-margin: 40px; } } -// FROM state — must match 0% keyframes exactly to prevent flash on mount. +// FROM state — must match keyframes exactly to prevent flash on mount. +// Only applied under motion-safe, otherwise the modal mounts directly at rest. .containerEnter { - .overlay { - opacity: 0; - } + @include motion-safe { + .overlay { + opacity: 0; + } - .centerPop { - opacity: 0; - transform: translate(-50%, -50%) scale(0.8); - } + .centerPop { + opacity: 0; + transform: translate(-50%, -50%) scale(0.94); + } - .anchorPop { - opacity: 0; - transform: translate(calc(-50% + var(--modal-start-x, 0px)), calc(-50% + var(--modal-start-y, 0px))) scale(0.8); - } + .anchorPop { + opacity: 0; + transform: translate(calc(-50% + var(--modal-start-x, 0px)), calc(-50% + var(--modal-start-y, 0px))) + scale(0.94); + } - .fullView { - opacity: 0.3; - transform: translateY(30px); + .fullView { + opacity: 0.3; + transform: translateY(var(--space-24)); + } } } .containerEnterActive { - .overlay { - opacity: 1; - transition: opacity 100ms cubic-bezier(0, 0, 0.4, 1); - } + @include motion-safe { + .overlay { + opacity: 1; + transition: opacity var(--motion-productive-medium) var(--motion-timing-enter); + } - .centerPop { - animation: centerPopIn 150ms cubic-bezier(0, 0, 0.4, 1) forwards; - } + .centerPop { + animation: centerPopIn var(--motion-expressive-short) var(--motion-timing-emphasize) forwards; + } - .anchorPop { - animation: anchorPopIn 200ms cubic-bezier(0, 0, 0.4, 1) forwards; - } + .anchorPop { + animation: anchorPopIn var(--motion-expressive-short) var(--motion-timing-emphasize) forwards; + } - .fullView { - animation: fullViewIn 250ms cubic-bezier(0, 0, 0.4, 1) forwards; + .fullView { + animation: fullViewIn var(--motion-expressive-short) var(--motion-timing-enter) forwards; + } } } .containerExitActive { - .overlay { - opacity: 0; - transition: opacity 100ms cubic-bezier(0.6, 0, 1, 1); - } + @include motion-safe { + .overlay { + opacity: 0; + transition: opacity var(--motion-productive-medium) var(--motion-timing-exit); + } - .centerPop { - animation: centerPopOut 100ms cubic-bezier(0.6, 0, 1, 1) forwards; - } + .centerPop { + animation: centerPopOut var(--motion-productive-long) var(--motion-timing-exit) forwards; + } - .anchorPop { - animation: anchorPopOut 150ms cubic-bezier(0.6, 0, 1, 1) forwards; - } + .anchorPop { + animation: anchorPopOut var(--motion-productive-long) var(--motion-timing-exit) forwards; + } - .fullView { - animation: fullViewOut 100ms cubic-bezier(0.6, 0, 1, 1) forwards; + .fullView { + animation: fullViewOut var(--motion-productive-medium) var(--motion-timing-exit) forwards; + } } } @keyframes centerPopIn { - 0% { + from { opacity: 0; - transform: translate(-50%, -50%) scale(0.8); + transform: translate(-50%, -50%) scale(0.94); } - 50%, - 100% { + to { opacity: 1; transform: translate(-50%, -50%) scale(1); } } @keyframes centerPopOut { - 0%, - 50% { + from { opacity: 1; transform: translate(-50%, -50%) scale(1); } - 100% { + to { opacity: 0; - transform: translate(-50%, -50%) scale(0.8); + transform: translate(-50%, -50%) scale(0.94); } } @keyframes anchorPopIn { - 0%, - 40% { + from { opacity: 0; - transform: translate(calc(-50% + var(--modal-start-x, 0px)), calc(-50% + var(--modal-start-y, 0px))) scale(0.8); + transform: translate(calc(-50% + var(--modal-start-x, 0px)), calc(-50% + var(--modal-start-y, 0px))) + scale(0.94); } - 100% { + to { opacity: 1; transform: translate(-50%, -50%) scale(1); } } @keyframes anchorPopOut { - 0% { + from { opacity: 1; transform: translate(-50%, -50%) scale(1); } - 60%, - 100% { + to { opacity: 0; - transform: translate(calc(-50% + var(--modal-start-x, 0px)), calc(-50% + var(--modal-start-y, 0px))) scale(0.8); + transform: translate(calc(-50% + var(--modal-start-x, 0px)), calc(-50% + var(--modal-start-y, 0px))) + scale(0.94); } } @keyframes fullViewIn { - 0% { + from { opacity: 0.3; - transform: translateY(30px); + transform: translateY(var(--space-24)); } - 33% { - opacity: 1; - } - - 100% { + to { opacity: 1; transform: translateY(0); } @@ -249,6 +254,6 @@ $full-view-margin: 40px; to { opacity: 0; - transform: translateY(30px); + transform: translateY(var(--space-24)); } } diff --git a/packages/core/src/components/Skeleton/Skeleton.module.scss b/packages/core/src/components/Skeleton/Skeleton.module.scss index 2d2f09b818..3f31c2ac59 100644 --- a/packages/core/src/components/Skeleton/Skeleton.module.scss +++ b/packages/core/src/components/Skeleton/Skeleton.module.scss @@ -1,20 +1,25 @@ +@import "~@vibe/style/dist/mixins"; +@import "~@vibe/style/dist/mixins/motion"; @import "SkeletonVariables"; @mixin shine-animation() { overflow: hidden; - animation-duration: 0.8s; - animation-direction: alternate; - animation-iteration-count: infinite; - animation-name: shine; - animation-timing-function: steps(10, end); - - @keyframes shine { - 0% { - opacity: 0.4; - } - 100% { - opacity: 1; - } + + @include motion-safe { + animation: shine 0.8s steps(10, end) infinite alternate; + } + + @include motion-reduce { + opacity: 0.7; + } +} + +@keyframes shine { + 0% { + opacity: 0.4; + } + 100% { + opacity: 1; } } diff --git a/packages/core/src/components/Tabs/Tab/Tab.module.scss b/packages/core/src/components/Tabs/Tab/Tab.module.scss index 250aeee5e2..92a3801731 100644 --- a/packages/core/src/components/Tabs/Tab/Tab.module.scss +++ b/packages/core/src/components/Tabs/Tab/Tab.module.scss @@ -1,4 +1,5 @@ @import "~@vibe/style/dist/mixins"; +@import "~@vibe/style/dist/mixins/motion"; @import "../../../styles/states"; @import "../../../styles/typography"; @@ -66,7 +67,10 @@ .tabWrapper.active:after { transform: scaleX(1); - transition: transform var(--motion-productive-medium) var(--motion-timing-enter); + + @include motion-safe { + transition: transform var(--motion-productive-medium) var(--motion-timing-enter); + } } .tabWrapper.disabled .tabInner { diff --git a/packages/core/src/components/Toast/Toast.module.scss b/packages/core/src/components/Toast/Toast.module.scss index b2f051443b..b57d5a2221 100644 --- a/packages/core/src/components/Toast/Toast.module.scss +++ b/packages/core/src/components/Toast/Toast.module.scss @@ -1,3 +1,5 @@ +@import "~@vibe/style/dist/mixins"; +@import "~@vibe/style/dist/mixins/motion"; @import "../../styles/keyframes"; .toast { @@ -17,7 +19,11 @@ border-radius: var(--border-radius-small); color: var(--fixed-light-color); transform: translateX(-50%); - transition: background-color 80ms cubic-bezier(0.6, 0, 0.4, 1), width 200ms cubic-bezier(0, 0, 0.4, 1); + + @include motion-safe { + transition: background-color var(--motion-productive-short) var(--motion-timing-transition), + width var(--motion-expressive-short) var(--motion-timing-enter); + } &.typeNormal { background-color: var(--primary-color); @@ -69,9 +75,14 @@ .actionButton.withTransition { opacity: 0; - animation: bounceIn 150ms cubic-bezier(0, 0, 0.4, 1); - animation-delay: 300ms; - animation-fill-mode: forwards; + + @include motion-safe { + animation: bounceIn var(--motion-productive-long) var(--motion-timing-emphasize) 300ms forwards; + } + + @include motion-reduce { + opacity: 1; + } } .content { @@ -85,19 +96,19 @@ } .enterActive { - animation-iteration-count: 1; - animation-fill-mode: forwards; - animation-name: slideIn; - animation-duration: 350ms; - animation-timing-function: cubic-bezier(0.6, 0, 0.4, 1); + @include motion-safe { + animation: slideIn var(--motion-expressive-short) var(--motion-timing-emphasize) forwards; + } } .exitActive { - animation-iteration-count: 1; - animation-fill-mode: forwards; - animation-name: slideOut; - animation-duration: 350ms; - animation-timing-function: cubic-bezier(0.6, 0, 0.4, 1); + @include motion-safe { + animation: slideOut var(--motion-productive-long) var(--motion-timing-exit) forwards; + } + + @include motion-reduce { + opacity: 0; + } } .closeButton { @@ -105,42 +116,37 @@ } @keyframes slideIn { - 0% { - transform: translate(-50%, -100px); - } - - 40% { - transform: translate(-50%, 16px); + from { + transform: translate(-50%, -100%); + opacity: 0; } - 100% { - transform: translate(-50%, 0px); + to { + transform: translate(-50%, 0); + opacity: 1; } } @keyframes slideOut { - 0% { + from { transform: translate(-50%, 0); + opacity: 1; } - 100% { - transform: translate(-50%, -100px); + to { + transform: translate(-50%, -100%); opacity: 0; } } @keyframes bounceIn { - 0% { - transform: scale(0.8); + from { + transform: scale(0.85); opacity: 0; } - 50% { - opacity: 1; - } - - 100% { - opacity: 1; + to { transform: scale(1); + opacity: 1; } } diff --git a/packages/style/src/mixins/_motion.scss b/packages/style/src/mixins/_motion.scss new file mode 100644 index 0000000000..19f598ba79 --- /dev/null +++ b/packages/style/src/mixins/_motion.scss @@ -0,0 +1,15 @@ +// Wrap any non-essential animation or movement transition in `motion-safe` +// so users with `prefers-reduced-motion: reduce` get a static experience. +// `motion-reduce` is for the rare case where you need an explicit alternative +// (e.g. swap a slide for an instant opacity change). +@mixin motion-safe { + @media (prefers-reduced-motion: no-preference) { + @content; + } +} + +@mixin motion-reduce { + @media (prefers-reduced-motion: reduce) { + @content; + } +} diff --git a/packages/style/src/mixins/index.scss b/packages/style/src/mixins/index.scss index 194c1c6243..fd0e412325 100644 --- a/packages/style/src/mixins/index.scss +++ b/packages/style/src/mixins/index.scss @@ -1,3 +1,4 @@ @import "common"; +@import "motion"; @import "states"; @import "typography"; From 8e58e40b7a2285dec0f057f667f2e6f6bc1d97d6 Mon Sep 17 00:00:00 2001 From: Orr Gottlieb Date: Fri, 22 May 2026 14:54:01 +0100 Subject: [PATCH 2/4] feat(menu): animate submenu open/close with expand variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MenuItemSubMenu rendered DialogContentContainer directly with no transition wrapper, so submenus appeared and disappeared instantly. Wrap the submenu in CSSTransition with a placement-aware fade + scale-from-origin animation that mirrors Tooltip's expand variant — keyed off useFloating's resolved placement so the submenu grows out of the parent item's edge. Respects prefers-reduced-motion via the shared motion-safe mixin. Replaces the visibility-toggle render strategy with mount/unmount; updates the corresponding test to assert presence rather than visibility style. Co-Authored-By: Claude Opus 4.7 --- .../MenuItemSubMenu.module.scss | 46 +++++++++++++++ .../MenuItemSubMenu/MenuItemSubMenu.tsx | 56 +++++++++++++------ .../__tests__/MenuItemSubMenu.test.tsx | 12 ++-- 3 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/MenuItemSubMenu.module.scss diff --git a/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/MenuItemSubMenu.module.scss b/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/MenuItemSubMenu.module.scss new file mode 100644 index 0000000000..5b36be290c --- /dev/null +++ b/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/MenuItemSubMenu.module.scss @@ -0,0 +1,46 @@ +@import "~@vibe/style/dist/mixins"; +@import "~@vibe/style/dist/mixins/motion"; + +// Submenu open/close animation. Mirrors the `expand` variant in DialogContent — +// fade + scale-from-origin keyed off the floating-ui placement so the submenu +// grows out of the parent menu item's edge. + +.appear, +.exit { + @include motion-safe { + transition: transform var(--motion-productive-long) var(--motion-timing-enter), + opacity var(--motion-productive-long) var(--motion-timing-enter); + opacity: 0; + transform: scale(0.92); + + &.rightStart { + transform-origin: top left; + } + &.rightEnd { + transform-origin: bottom left; + } + &.leftStart { + transform-origin: top right; + } + &.leftEnd { + transform-origin: bottom right; + } + } +} + +.exit { + @include motion-safe { + transition: transform var(--motion-productive-medium) var(--motion-timing-exit), + opacity var(--motion-productive-medium) var(--motion-timing-exit); + } +} + +.appearActive, +.enterActive { + @include motion-safe { + transition: transform var(--motion-productive-long) var(--motion-timing-enter), + opacity var(--motion-productive-long) var(--motion-timing-enter); + opacity: 1; + transform: scale(1); + } +} diff --git a/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/MenuItemSubMenu.tsx b/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/MenuItemSubMenu.tsx index ef5e0a4b5a..e1b5fa904f 100644 --- a/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/MenuItemSubMenu.tsx +++ b/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/MenuItemSubMenu.tsx @@ -1,9 +1,13 @@ import React, { useMemo, useRef } from "react"; +import cx from "classnames"; +import { CSSTransition } from "react-transition-group"; +import { camelCase } from "es-toolkit"; import { DialogContentContainer } from "@vibe/dialog"; import { useFloating, flip, type Placement } from "@floating-ui/react-dom"; import { type MenuChild } from "../../../Menu/MenuConstants"; import { type MenuItemSubMenuProps } from "./MenuItemSubMenu.types"; -import { useIsomorphicLayoutEffect } from "@vibe/shared"; +import { useIsomorphicLayoutEffect, getStyle } from "@vibe/shared"; +import styles from "./MenuItemSubMenu.module.scss"; const DEFAULT_FALLBACK_PLACEMENTS: Placement[] = ["right-end", "left-start", "left-end"]; @@ -16,6 +20,7 @@ const MenuItemSubMenu = ({ submenuPosition }: MenuItemSubMenuProps) => { const childRef = useRef(null); + const transitionRef = useRef(null); useIsomorphicLayoutEffect(() => { if (!autoFocusOnMount || !open || !childRef?.current) { @@ -49,24 +54,39 @@ const MenuItemSubMenu = ({ return null; } + const placementClassName = getStyle(styles, camelCase(actualPlacement)); + return ( -
- {subMenu && open && ( - - {React.cloneElement(subMenu, { - ...subMenu?.props, - isVisible: open, - isSubMenu: true, - onClose, - ref: childRef, - useDocumentEventListeners: !autoFocusOnMount - })} - - )} +
+ +
+ + {React.cloneElement(subMenu, { + ...subMenu?.props, + isVisible: open, + isSubMenu: true, + onClose, + ref: childRef, + useDocumentEventListeners: !autoFocusOnMount + })} + +
+
); }; diff --git a/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/__tests__/MenuItemSubMenu.test.tsx b/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/__tests__/MenuItemSubMenu.test.tsx index 923d5224d3..2a2e8947f3 100644 --- a/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/__tests__/MenuItemSubMenu.test.tsx +++ b/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/__tests__/MenuItemSubMenu.test.tsx @@ -24,24 +24,24 @@ const MockMenuChild = React.forwardRef(({ onClose }: MenuProps, ref: React.Forwa Object.assign(MockMenuChild, { isMenu: true }); describe("MenuItemSubMenu", () => { - it("should render correctly with visibility hidden when not open", () => { + it("should not render submenu content when not open", () => { const mockAnchor = document.createElement("div"); - const { container } = render( + const { queryByText } = render( ); - expect(container.firstChild).toHaveStyle("visibility: hidden"); + expect(queryByText("Items")).not.toBeInTheDocument(); }); - it("should render correctly and become visible when open is true", () => { + it("should render submenu content when open is true", () => { const mockAnchor = document.createElement("div"); - const { container } = render( + const { getByText } = render( ); - expect(container.firstChild).toHaveStyle("visibility: visible"); + expect(getByText("Items")).toBeInTheDocument(); }); it("should call onClose when requested to close", () => { From ab961eb44d5c0b6e740d2510fb3c086d425ad198 Mon Sep 17 00:00:00 2001 From: Orr Gottlieb Date: Fri, 22 May 2026 18:14:47 +0100 Subject: [PATCH 3/4] fix(menu): use CSS animations for submenu open transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Submenus only animated on close — open was instant. CSSTransition with mountOnEnter swaps the start/active classes in quick succession on a freshly-mounted node, and the browser can land both in the same paint cycle, skipping the "from" state entirely. Replace the transition-based approach with CSS keyframe animations on the active classes. Animations fire reliably on mount regardless of frame timing. Placement classes are now applied statically to the wrapper so the transform-origin is set before the animation runs. Co-Authored-By: Claude Opus 4.7 --- .../MenuItemSubMenu.module.scss | 67 ++++++++++++------- .../MenuItemSubMenu/MenuItemSubMenu.tsx | 12 ++-- 2 files changed, 45 insertions(+), 34 deletions(-) diff --git a/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/MenuItemSubMenu.module.scss b/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/MenuItemSubMenu.module.scss index 5b36be290c..42e4f624af 100644 --- a/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/MenuItemSubMenu.module.scss +++ b/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/MenuItemSubMenu.module.scss @@ -4,43 +4,58 @@ // Submenu open/close animation. Mirrors the `expand` variant in DialogContent — // fade + scale-from-origin keyed off the floating-ui placement so the submenu // grows out of the parent menu item's edge. +// +// Uses CSS animations instead of transitions because CSSTransition+mountOnEnter +// can skip the "from" frame when the class swap lands in a single paint — +// keyframes fire reliably on a freshly mounted element. -.appear, -.exit { - @include motion-safe { - transition: transform var(--motion-productive-long) var(--motion-timing-enter), - opacity var(--motion-productive-long) var(--motion-timing-enter); +@keyframes submenuExpandIn { + from { opacity: 0; transform: scale(0.92); - - &.rightStart { - transform-origin: top left; - } - &.rightEnd { - transform-origin: bottom left; - } - &.leftStart { - transform-origin: top right; - } - &.leftEnd { - transform-origin: bottom right; - } + } + to { + opacity: 1; + transform: scale(1); } } -.exit { - @include motion-safe { - transition: transform var(--motion-productive-medium) var(--motion-timing-exit), - opacity var(--motion-productive-medium) var(--motion-timing-exit); +@keyframes submenuExpandOut { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.92); } } +.rightStart { + transform-origin: top left; +} + +.rightEnd { + transform-origin: bottom left; +} + +.leftStart { + transform-origin: top right; +} + +.leftEnd { + transform-origin: bottom right; +} + .appearActive, .enterActive { @include motion-safe { - transition: transform var(--motion-productive-long) var(--motion-timing-enter), - opacity var(--motion-productive-long) var(--motion-timing-enter); - opacity: 1; - transform: scale(1); + animation: submenuExpandIn var(--motion-productive-long) var(--motion-timing-enter) forwards; + } +} + +.exitActive { + @include motion-safe { + animation: submenuExpandOut var(--motion-productive-medium) var(--motion-timing-exit) forwards; } } diff --git a/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/MenuItemSubMenu.tsx b/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/MenuItemSubMenu.tsx index e1b5fa904f..1ee7c50966 100644 --- a/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/MenuItemSubMenu.tsx +++ b/packages/core/src/components/Menu/MenuItem/components/MenuItemSubMenu/MenuItemSubMenu.tsx @@ -1,5 +1,4 @@ import React, { useMemo, useRef } from "react"; -import cx from "classnames"; import { CSSTransition } from "react-transition-group"; import { camelCase } from "es-toolkit"; import { DialogContentContainer } from "@vibe/dialog"; @@ -66,15 +65,12 @@ const MenuItemSubMenu = ({ nodeRef={transitionRef} timeout={{ appear: 150, enter: 150, exit: 100 }} classNames={{ - appear: cx(styles.appear, placementClassName), - appearActive: cx(styles.appearActive, placementClassName), - enter: cx(styles.appear, placementClassName), - enterActive: cx(styles.enterActive, placementClassName), - exit: cx(styles.exit, placementClassName), - exitActive: cx(styles.exit, placementClassName) + appearActive: styles.appearActive, + enterActive: styles.enterActive, + exitActive: styles.exitActive }} > -
+
{React.cloneElement(subMenu, { ...subMenu?.props, From 97ae8b0827b6b61f58c0487e10a72278c0c841d7 Mon Sep 17 00:00:00 2001 From: Orr Gottlieb Date: Sat, 23 May 2026 09:08:01 +0100 Subject: [PATCH 4/4] test(docs): await submenu exit animation in Menu interactions The submenu now animates closed via CSSTransition with unmountOnExit, so the element stays mounted for ~100ms during the exit animation. Wrap the post-close `not.toBeInTheDocument()` assertions in `waitFor` so they await unmount instead of asserting synchronously. Co-Authored-By: Claude Opus 4.7 --- .../components/Menu/Menu.interactions.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/docs/src/pages/components/Menu/Menu.interactions.ts b/packages/docs/src/pages/components/Menu/Menu.interactions.ts index 5e031c5f0f..8a06e56d2c 100644 --- a/packages/docs/src/pages/components/Menu/Menu.interactions.ts +++ b/packages/docs/src/pages/components/Menu/Menu.interactions.ts @@ -1,4 +1,4 @@ -import { userEvent, within } from "@storybook/test"; +import { userEvent, waitFor, within } from "@storybook/test"; import { getByRole, getByText, @@ -45,12 +45,14 @@ const showSubSubMenusOnHover = async canvas => { await userEvent.hover( getByText(menuElement, TWO_DEPTHS_MENU_TEXTS.TOP_MENU_NON_SUB_MENU_ITEM, { ignore: HIDDEN_ELEMENT_SELECTOR }) ); - expect( - canvas.queryByText(TWO_DEPTHS_MENU_TEXTS.SUB_MENU_ITEM, { ignore: HIDDEN_ELEMENT_SELECTOR }) - ).not.toBeInTheDocument(); - expect( - canvas.queryByText(TWO_DEPTHS_MENU_TEXTS.SUB_SUB_MENU_ITEM, { ignore: HIDDEN_ELEMENT_SELECTOR }) - ).not.toBeInTheDocument(); + await waitFor(() => { + expect( + canvas.queryByText(TWO_DEPTHS_MENU_TEXTS.SUB_MENU_ITEM, { ignore: HIDDEN_ELEMENT_SELECTOR }) + ).not.toBeInTheDocument(); + expect( + canvas.queryByText(TWO_DEPTHS_MENU_TEXTS.SUB_SUB_MENU_ITEM, { ignore: HIDDEN_ELEMENT_SELECTOR }) + ).not.toBeInTheDocument(); + }); }; const showSubSubMenusWithKeyboard = async canvas => { @@ -86,14 +88,18 @@ const showSubSubMenusWithKeyboard = async canvas => { //close sub-sub-menu - using left arrow await pressNavigationKey(NavigationCommand.LEFT_ARROW); - expect( - canvas.queryByText(menuElement, TWO_DEPTHS_MENU_TEXTS.SUB_SUB_MENU_ITEM, { ignore: HIDDEN_ELEMENT_SELECTOR }) - ).not.toBeInTheDocument(); + await waitFor(() => { + expect( + canvas.queryByText(menuElement, TWO_DEPTHS_MENU_TEXTS.SUB_SUB_MENU_ITEM, { ignore: HIDDEN_ELEMENT_SELECTOR }) + ).not.toBeInTheDocument(); + }); expectActiveElementToHavePartialText(TWO_DEPTHS_MENU_TEXTS.SUB_MENU_ITEM); //close sub-menu - using escape await userEvent.keyboard("{escape}"); - expect(canvas.queryByText(menuElement, TWO_DEPTHS_MENU_TEXTS.SUB_MENU_ITEM)).not.toBeInTheDocument(); + await waitFor(() => { + expect(canvas.queryByText(menuElement, TWO_DEPTHS_MENU_TEXTS.SUB_MENU_ITEM)).not.toBeInTheDocument(); + }); expectActiveElementToHavePartialText(TWO_DEPTHS_MENU_TEXTS.TOP_MENU_SUB_MENU_ITEM); };