Skip to content

refactor(Popover, Menu): migrate slide animation from CSS to motion component#35763

Open
robertpenner wants to merge 27 commits intomicrosoft:masterfrom
robertpenner:feat/popover-motion
Open

refactor(Popover, Menu): migrate slide animation from CSS to motion component#35763
robertpenner wants to merge 27 commits intomicrosoft:masterfrom
robertpenner:feat/popover-motion

Conversation

@robertpenner
Copy link
Collaborator

@robertpenner robertpenner commented Feb 19, 2026

This replaces the CSS-based slide animation in Popover and Menu with a new surfaceMotion presence motion slot, powered by the Slide Fluent motion component by default.

Motivation

The existing createSlideStyles approach in react-positioning embeds animation logic into static Griffel styles, which limits flexibility:

  • There wasn't a well-documented way for consumers to customize or disable the animation
  • Tied to CSS transitions rather than the motion system
  • Cannot compose with other motion effects

The surfaceMotion slot makes animation a first-class composable concern that consumers can customize, replace with a custom motion component, or disable entirely by passing surfaceMotion={null}.

Changes

react-popover

  • Add surfaceMotion presence motion slot to PopoverProps / PopoverState
  • Create PopoverSurfaceMotion — enter-only animation combining fadeAtom + slideAtom with CSS custom property-based direction
  • Create usePositioningSlideDirection hook — sets slide direction CSS vars from onPositioningEnd placement data and registers them via CSS.registerProperty for smooth interpolation
  • Wire MotionRefForwarder into renderPopover for ref forwarding through the motion wrapper
  • Add useMotionForwardedRef to PopoverSurface for motion ref consumption
  • Remove old createSlideStyles usage from PopoverSurface styles
  • Add stories: Default (with motion), MotionCustom (custom fade+blur), MotionDisabled (surfaceMotion={null})
  • Add unit tests for usePositioningSlideDirection and getPlacementSlideDirections

react-menu

  • Same pattern as react-popover: add surfaceMotion slot, MenuSurfaceMotion, and usePositioningSlideDirection
  • Remove old createSlideStyles usage from MenuPopover styles
  • Add stories: MotionCustom and MotionDisabled

react-positioning

  • Deprecate createSlideStyles — slide animations are now handled by the surface motion components in each package

API Surface

New prop on both PopoverProps and MenuProps:

surfaceMotion?: Slot<PresenceMotionSlotProps<{ mainAxis: number }>>;

Wrapping in Slot<> allows surfaceMotion={null} to disable animation, matching the pattern used by Drawer motion slots.

Backwards Compatibility

  • The surfaceMotion slot is optional and defaults to the built-in slide animation, preserving existing visual behavior.
  • createSlideStyles is deprecated but not removed; existing consumers are unaffected.

Testing

  • Unit tests for getPlacementSlideDirections covering all 4 sides (top/right/bottom/left)
  • Unit tests for usePositioningSlideDirection covering CSS property registration, style setting per placement, and user callback forwarding
  • Storybook stories demonstrating default, custom, and disabled motion configurations

Prerequisites

@robertpenner robertpenner self-assigned this Feb 19, 2026
@robertpenner robertpenner force-pushed the feat/popover-motion branch 2 times, most recently from 25328c8 to 3ca9a82 Compare February 19, 2026 19:30
@robertpenner robertpenner force-pushed the feat/popover-motion branch 3 times, most recently from fb9e16d to eb19b8a Compare February 23, 2026 20:07
@github-actions
Copy link

github-actions bot commented Feb 23, 2026

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
react-charts
AreaChart
406.807 kB
124.502 kB
412.284 kB
126.46 kB
5.477 kB
1.958 kB
react-charts
DeclarativeChart
756.641 kB
217.639 kB
762.112 kB
220.191 kB
5.471 kB
2.552 kB
react-charts
DonutChart
317.248 kB
94.368 kB
322.719 kB
96.951 kB
5.471 kB
2.583 kB
react-charts
FunnelChart
308.798 kB
91.367 kB
314.272 kB
94.001 kB
5.474 kB
2.634 kB
react-charts
GanttChart
389.95 kB
118.062 kB
395.403 kB
119.944 kB
5.453 kB
1.882 kB
react-charts
GaugeChart
316.679 kB
93.808 kB
322.15 kB
96.395 kB
5.471 kB
2.587 kB
react-charts
GroupedVerticalBarChart
397.796 kB
120.68 kB
403.273 kB
122.52 kB
5.477 kB
1.84 kB
react-charts
HeatMapChart
391.997 kB
119.866 kB
397.474 kB
121.814 kB
5.477 kB
1.948 kB
react-charts
HorizontalBarChart
296.975 kB
86.151 kB
302.446 kB
89.138 kB
5.471 kB
2.987 kB
react-charts
Legends
235.929 kB
69.375 kB
242.371 kB
71.587 kB
6.442 kB
2.212 kB
react-charts
LineChart
417.371 kB
126.401 kB
422.848 kB
128.311 kB
5.477 kB
1.91 kB
react-charts
PolarChart
345.864 kB
105.432 kB
351.337 kB
107.388 kB
5.473 kB
1.956 kB
react-charts
SankeyChart
213.905 kB
65.63 kB
220.36 kB
67.871 kB
6.455 kB
2.241 kB
react-charts
ScatterChart
397.198 kB
120.602 kB
402.675 kB
122.499 kB
5.477 kB
1.897 kB
react-charts
VerticalBarChart
434.273 kB
126.284 kB
439.744 kB
128.242 kB
5.471 kB
1.958 kB
react-charts
VerticalStackedBarChart
403.71 kB
121.548 kB
409.187 kB
123.975 kB
5.477 kB
2.427 kB
react-components
react-components: Accordion, Button, FluentProvider, Image, Menu, Popover
237.33 kB
68.535 kB
236.541 kB
68.712 kB
-789 B
177 B
react-components
react-components: entire library
1.291 MB
322.992 kB
1.29 MB
322.834 kB
-1.206 kB
-158 B
react-menu
Menu (including children components)
163.751 kB
49.55 kB
170.213 kB
51.898 kB
6.462 kB
2.348 kB
react-menu
Menu (including selectable components)
166.929 kB
50.14 kB
173.391 kB
52.479 kB
6.462 kB
2.339 kB
react-popover
Popover
127.224 kB
39.264 kB
133.698 kB
41.448 kB
6.474 kB
2.184 kB
react-teaching-popover
TeachingPopover
102.041 kB
30.544 kB
112.393 kB
34.218 kB
10.352 kB
3.674 kB
Unchanged fixtures
Package & Exports Size (minified/GZIP)
react-accordion
Accordion (including children components)
103.471 kB
31.341 kB
react-avatar
Avatar
48.27 kB
15.312 kB
react-avatar
AvatarGroup
17.45 kB
6.995 kB
react-avatar
AvatarGroupItem
61.511 kB
19.296 kB
react-breadcrumb
@fluentui/react-breadcrumb - package
114.911 kB
31.405 kB
react-charts
HorizontalBarChartWithAxis
63 B
83 B
react-charts
Sparkline
91.393 kB
28.708 kB
react-checkbox
Checkbox
33.522 kB
11.407 kB
react-combobox
Combobox (including child components)
105.208 kB
34.168 kB
react-combobox
Dropdown (including child components)
105.832 kB
34.1 kB
react-components
react-components: Button, FluentProvider & webLightTheme
70.397 kB
19.96 kB
react-components
react-components: FluentProvider & webLightTheme
43.612 kB
14.022 kB
react-datepicker-compat
DatePicker Compat
225.038 kB
63.595 kB
react-dialog
Dialog (including children components)
102.12 kB
30.394 kB
react-field
Field
21.925 kB
8.257 kB
react-input
Input
26.246 kB
8.688 kB
react-list
List
87.11 kB
25.762 kB
react-list
ListItem
110.695 kB
32.627 kB
react-message-bar
MessageBar (all components)
23.37 kB
8.615 kB
react-motion
@fluentui/react-motion - createMotionComponent()
4.156 kB
1.818 kB
react-motion
@fluentui/react-motion - createPresenceComponent()
5.908 kB
2.442 kB
react-motion
@fluentui/react-motion - PresenceGroup
1.727 kB
823 B
react-overflow
hooks only
12.117 kB
4.627 kB
react-persona
Persona
55.225 kB
17.245 kB
react-portal-compat
PortalCompatProvider
8.386 kB
2.624 kB
react-positioning
usePositioning
28.889 kB
10.158 kB
react-positioning
useSafeZoneArea
12.445 kB
5 kB
react-progress
ProgressBar
15.7 kB
6.214 kB
react-radio
Radio
30.905 kB
9.611 kB
react-radio
RadioGroup
13.994 kB
5.688 kB
react-select
Select
26.085 kB
9.437 kB
react-slider
Slider
36.322 kB
12.065 kB
react-spinbutton
SpinButton
33.637 kB
11.067 kB
react-swatch-picker
@fluentui/react-swatch-picker - package
104.27 kB
29.925 kB
react-switch
Switch
34.536 kB
10.832 kB
react-table
DataGrid
159.313 kB
44.939 kB
react-table
Table (Primitives only)
40.997 kB
13.172 kB
react-table
Table as DataGrid
130.528 kB
35.943 kB
react-table
Table (Selection only)
68.916 kB
19.309 kB
react-table
Table (Sort only)
67.559 kB
18.924 kB
react-tag-picker
@fluentui/react-tag-picker - package
186.596 kB
55.849 kB
react-tags
InteractionTag
13.666 kB
5.459 kB
react-tags
Tag
29.521 kB
9.389 kB
react-tags
TagGroup
82.211 kB
24.143 kB
react-textarea
Textarea
24.628 kB
8.954 kB
react-timepicker-compat
TimePicker
108.174 kB
35.695 kB
react-toast
Toast (including Toaster)
102.56 kB
30.608 kB
react-tooltip
Tooltip
57.1 kB
19.696 kB
react-tree
FlatTree
147.635 kB
42.134 kB
react-tree
PersonaFlatTree
149.463 kB
42.517 kB
react-tree
PersonaTree
145.523 kB
41.338 kB
react-tree
Tree
143.701 kB
40.972 kB
🤖 This report was generated against cbf6cd22febb874c8e360def57b140ea42291902

@github-actions
Copy link

Pull request demo site: URL

@robertpenner robertpenner marked this pull request as ready for review February 24, 2026 21:23
@robertpenner robertpenner requested a review from a team as a code owner February 24, 2026 21:23
@robertpenner robertpenner changed the title refactor(Popover, Menu): migrate slide animation from CSS to motion component (Feb 19) refactor(Popover, Menu): migrate slide animation from CSS to motion component Feb 24, 2026
@robertpenner robertpenner requested a review from a team as a code owner February 24, 2026 22:30
@robertpenner robertpenner requested a review from a team as a code owner February 25, 2026 00:45
robertpenner and others added 11 commits February 24, 2026 20:52
…ation

- Add surfaceMotion presence motion slot to PopoverProps/PopoverState
- Create PopoverSurfaceMotion using fadeAtom + slideAtom with CSS custom property-based direction
- Create usePositioningSlideDirection hook to set slide direction CSS vars from placement
- Wire MotionRefForwarder into renderPopover for ref forwarding through motion wrapper
- Add useMotionForwardedRef to PopoverSurface for motion ref consumption
- Remove old createSlideStyles usage from PopoverSurface styles
- Add react-motion and react-motion-components-preview dependencies
- Add unit tests for usePositioningSlideDirection and getPlacementSlideDirections
Slide animations are now handled by PopoverSurfaceMotion in react-popover.
Replace deprecated createSlideStyles with the surfaceMotion slot pattern,
matching the approach used in react-popover. This uses fadeAtom + slideAtom
motion components driven by CSS custom properties set from positioning
placement data via onPositioningEnd.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… null

PresenceMotionSlotProps does not include null in its type, so passing
surfaceMotion={null} to disable motion was rejected by TypeScript. Wrapping
it in Slot<> adds null to the union, matching the pattern already used by
Drawer's motion slots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Demonstrates disabling the Popover transition animation by passing
surfaceMotion={null}.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MotionCustom demonstrates replacing the default slide animation with a
custom fade-in/blur-out motion using createPresenceComponent and motion
atoms. MotionDisabled demonstrates passing surfaceMotion={null} to disable
the transition entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
robertpenner and others added 15 commits February 24, 2026 20:52
…fix JSX type error

The custom @fluentui/react-jsx-runtime jsx function uses React.ElementType<Props>
which does not include ForwardRefExoticComponent. TypeScript falls back to Props={}
and then requires the explicit JSX attributes to satisfy all required props, causing
TS2741 when children is required but only provided as JSX children (not an attribute).

Making children optional resolves the false positive across react-menu, react-popover,
react-dialog, and react-message-bar without changing runtime behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… files

The @jsxImportSource @fluentui/react-jsx-runtime pragma is only needed when
Slot API components are used in JSX. After switching to MotionRefForwarder
these render files no longer use Slot, so the pragma (and the accompanying
@jsxRuntime automatic directive) are removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…deDirection tests

Replace `as any` casts with a proper SlideDirectionEvent type alias derived
from PositioningProps['onPositioningEnd'], using variable annotations to let
TypeScript infer the CustomEvent generic via contextual typing. Replace
mockWindow `as any` with `as unknown as Window & typeof globalThis`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…aceBase

React Compiler flags mutation of values returned from hooks. Instead of
assigning to state.root.ref after the fact, hoist useMotionForwardedRef()
and pass the pre-merged ref directly into usePopoverSurfaceBase_unstable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… into react-positioning

Move `usePositioningSlideDirection` and `getPlacementSlideDirections` from their
duplicate local copies in `react-menu` and `react-popover` into `react-positioning`,
where the positioning concerns belong. Also centralise the `--fui-positioning-slide-direction-*`
CSS custom property names as exported constants (`POSITIONING_SLIDE_DIRECTION_VAR_X/Y`).

Consumers (`useMenu`, `usePopover`, `MenuSurfaceMotion`, `PopoverSurfaceMotion`) now
import from `@fluentui/react-positioning` instead of maintaining local copies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
<state.surfaceMotion> is a SlotComponentType created by presenceMotionSlot()
that requires @fluentui/react-jsx-runtime to resolve SLOT_ELEMENT_TYPE_SYMBOL.
The pragma was removed in b22f3b0, causing surfaceMotion to be passed as a
plain object to React.createElement, which breaks rendering.

assertSlots<MenuSlots>(state) is a runtime no-op (MenuSlots is empty), but
satisfies the @nx/workspace-no-missing-jsx-pragma lint rule which requires
both <state.xxx> JSX usage and an assertSlots() call to consider the pragma
as needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…opover

Same fix as renderMenu: <state.surfaceMotion> requires @fluentui/react-jsx-runtime
to resolve the SlotComponentType from presenceMotionSlot().

Adds PopoverSlots (empty) and components to PopoverState to support the
assertSlots() call, which iterates Object.keys(state.components) at runtime
in dev mode. PopoverBaseState omits components since the base hook does not
need it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…opover unmount

The surfaceMotion presence component keeps the popover mounted briefly
during its exit lifecycle, so focus may still be inside the popover when
`open` becomes false. Expand the focus-restoration check to also cover
that case, ensuring the trigger receives focus before the DOM element is
removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant