Skip to content

fix: view transition animations - #10684

Open
snowystinger wants to merge 14 commits into
mainfrom
sidepanel-animation-followup
Open

snowystinger wants to merge 14 commits into
mainfrom
sidepanel-animation-followup

Conversation

@snowystinger

@snowystinger snowystinger commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

The general idea, create two animations, side-panel and side-nav-item.
side-panel animated the panel's inner content div as a single "unit". The panel can expand or collapse.
side-nav-item handles each row individually. Items can expand or collapse.

Because the application document flow should animate automatically with the SidePanel expand/collapse, there is a width css transition that is not a view transition but is in lock step with the view transition's animation. This way the SidePanel takes up real space during the animation.

Both sets of transitions use default: none which is passed to the React ViewTransition and opts that element out of any transitions they aren't part of, you can fiddle with this demo to get a feel for what it does.
default-none-demo.html

There are a couple other things which help this work:

  • width/height: 100% + object-fit: none — You can think of the snapshot like an img tag, this prevents distortion as the container changes shape and doesn't alter the aspect ratio. So rows fade and get clipped by the shrinking box instead of squashing.
  • object-position anchors the edge that stays put for the animation: left top, flipped to right top in RTL (the pseudo-elements live on :root and can't inherit the panel's direction, which is why we need the side-panel-rtl type layered on top).
  • animation: none on ::view-transition-old/new(root) — Shows the "application document flow animation" from underneath, rather than cross-fade.

Finally, there is the clipping. Ordinarily, a ViewTransition, escapes all overflow hidden/containing/everything because it's in a layer on top of the entire page. And we can't change that until view-transition-group: contain is supported in more places than just Chrome.
So instead, we apply a clip-path which is measured as the intersection of the side nav and the side panel. This is recomputed every animation frame and is applied so that the view transition snapshot picks it up as well, making it appear that the contents are contained within the SidePanel and SideNav.
This clip path is most of the logic in this PR.

A closing note, ViewTransitions are non-interruptable. So you cannot click the button fast to open/close repeatedly. You have to wait for each animation.

📝 Test Instructions:

Go to SidePanel (NavPanel) docs, put Chrome's animation tab speed to 10%, then expand/collapse the SidePanel and the SideNav Items and make sure they don't leak out.

Additional fixes:

  • css module styles now work in parcel3 storybook
  • margin bottom of expand button has been increased to match design, it's now 12px

@github-actions github-actions Bot added the S2 label Sep 30, 2026
@rspbot

rspbot commented Sep 30, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Sep 30, 2026

Copy link
Copy Markdown

@snowystinger snowystinger mentioned this pull request Sep 30, 2026
7 tasks
Base automatically changed from SidePanel-followup to main September 30, 2026 20:59
# Conflicts:
#	packages/@react-spectrum/s2/src/SideNav.tsx
#	packages/@react-spectrum/s2/stories/SideNav.stories.tsx
@rspbot

rspbot commented Oct 7, 2026

Copy link
Copy Markdown

@snowystinger
snowystinger marked this pull request as ready for review October 7, 2026 05:43
@rspbot

rspbot commented Oct 7, 2026

Copy link
Copy Markdown

# Conflicts:
#	packages/@react-spectrum/s2/src/SideNav.module.css
#	packages/@react-spectrum/s2/src/SideNav.tsx
#	packages/@react-spectrum/s2/src/SideNavPanel.tsx
#	packages/@react-spectrum/s2/test/SideNavPanel.test.tsx
#	packages/dev/s2-docs/pages/s2/SideNavPanel.mdx
@rspbot

rspbot commented Oct 19, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Oct 20, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Oct 22, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Oct 22, 2026

Copy link
Copy Markdown
## API Changes

react-aria-components

/react-aria-components:GridListItem

 GridListItem <T> {
   allowsArrowNavigation?: boolean
   children?: ChildrenOrFunction<GridListItemRenderProps>
   className?: ClassNameOrFunction<GridListItemRenderProps> = 'react-aria-GridListItem'
   download?: boolean | string
   focusMode?: 'child' | 'row'
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   onAction?: () => void
-  onBlur?: (FocusEvent<HTMLDivElement>) => void
   onClick?: (MouseEvent<FocusableElement>) => void
-  onFocus?: (FocusEvent<HTMLDivElement>) => void
-  onFocusChange?: (boolean) => void
   onHoverChange?: (boolean) => void
   onHoverEnd?: (HoverEvent) => void
   onHoverStart?: (HoverEvent) => void
   onPress?: (PressEvent) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   onPressUp?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, GridListItemRenderProps>
   routerOptions?: RouterOptions
   style?: StyleOrFunction<GridListItemRenderProps>
   target?: HTMLAttributeAnchorTarget
   textValue?: string
   value?: T
 }

/react-aria-components:GridListItemProps

 GridListItemProps <T = {}> {
   allowsArrowNavigation?: boolean
   children?: ChildrenOrFunction<GridListItemRenderProps>
   className?: ClassNameOrFunction<GridListItemRenderProps> = 'react-aria-GridListItem'
   download?: boolean | string
   focusMode?: 'child' | 'row'
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   onAction?: () => void
-  onBlur?: (FocusEvent<HTMLDivElement>) => void
   onClick?: (MouseEvent<FocusableElement>) => void
-  onFocus?: (FocusEvent<HTMLDivElement>) => void
-  onFocusChange?: (boolean) => void
   onHoverChange?: (boolean) => void
   onHoverEnd?: (HoverEvent) => void
   onHoverStart?: (HoverEvent) => void
   onPress?: (PressEvent) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   onPressUp?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, GridListItemRenderProps>
   routerOptions?: RouterOptions
   style?: StyleOrFunction<GridListItemRenderProps>
   target?: HTMLAttributeAnchorTarget
   textValue?: string
   value?: T
 }

@react-spectrum/ai

/@react-spectrum/ai:Attachment

 Attachment {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   children: ReactNode
   density?: 'compact' | 'regular' | 'spacious' = 'regular'
   download?: boolean | string
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   isInvalid?: boolean
   onAction?: () => void
-  onBlur?: (FocusEvent<HTMLDivElement>) => void
-  onFocus?: (FocusEvent<HTMLDivElement>) => void
-  onFocusChange?: (boolean) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, TagRenderProps>
   routerOptions?: RouterOptions
   size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
   styles?: StyleString
   target?: HTMLAttributeAnchorTarget
   textValue?: string
   uploadProgress?: number
   value?: T
   variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet' = 'primary'
 }

/@react-spectrum/ai:AttachmentProps

 AttachmentProps {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   children: ReactNode
   density?: 'compact' | 'regular' | 'spacious' = 'regular'
   download?: boolean | string
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   isInvalid?: boolean
   onAction?: () => void
-  onBlur?: (FocusEvent<HTMLDivElement>) => void
-  onFocus?: (FocusEvent<HTMLDivElement>) => void
-  onFocusChange?: (boolean) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, TagRenderProps>
   routerOptions?: RouterOptions
   size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
   styles?: StyleString
   target?: HTMLAttributeAnchorTarget
   textValue?: string
   uploadProgress?: number
   value?: T
   variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet' = 'primary'
 }

@react-spectrum/s2

/@react-spectrum/s2:Card

 Card {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   allowsArrowNavigation?: boolean
   children: ReactNode | (CardRenderProps) => ReactNode
   density?: 'compact' | 'regular' | 'spacious' = 'regular'
   download?: boolean | string
   focusMode?: 'child' | 'row'
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   onAction?: () => void
-  onBlur?: (FocusEvent<HTMLDivElement>) => void
-  onFocus?: (FocusEvent<HTMLDivElement>) => void
-  onFocusChange?: (boolean) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   routerOptions?: RouterOptions
   size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
   styles?: StylesProp
   target?: HTMLAttributeAnchorTarget
   textValue?: string
   value?: T
   variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet' = 'primary'
 }

/@react-spectrum/s2:AssetCard

 AssetCard {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   allowsArrowNavigation?: boolean
   children: ReactNode | (CardRenderProps) => ReactNode
   download?: boolean | string
   focusMode?: 'child' | 'row'
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   onAction?: () => void
-  onBlur?: (FocusEvent<HTMLDivElement>) => void
-  onFocus?: (FocusEvent<HTMLDivElement>) => void
-  onFocusChange?: (boolean) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   routerOptions?: RouterOptions
   size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
   styles?: StylesProp
   target?: HTMLAttributeAnchorTarget
   textValue?: string
   value?: T
   variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet' = 'primary'
 }

/@react-spectrum/s2:UserCard

 UserCard {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   allowsArrowNavigation?: boolean
   children: ReactNode | (CardRenderProps) => ReactNode
   download?: boolean | string
   focusMode?: 'child' | 'row'
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   onAction?: () => void
-  onBlur?: (FocusEvent<HTMLDivElement>) => void
-  onFocus?: (FocusEvent<HTMLDivElement>) => void
-  onFocusChange?: (boolean) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   routerOptions?: RouterOptions
   size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
   styles?: StylesProp
   target?: HTMLAttributeAnchorTarget
   textValue?: string
   value?: T
   variant?: 'primary' | 'secondary' | 'tertiary'
 }

/@react-spectrum/s2:ProductCard

 ProductCard {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   allowsArrowNavigation?: boolean
   children: ReactNode | (CardRenderProps) => ReactNode
   download?: boolean | string
   focusMode?: 'child' | 'row'
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   onAction?: () => void
-  onBlur?: (FocusEvent<HTMLDivElement>) => void
-  onFocus?: (FocusEvent<HTMLDivElement>) => void
-  onFocusChange?: (boolean) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   routerOptions?: RouterOptions
   size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
   styles?: StylesProp
   target?: HTMLAttributeAnchorTarget
   textValue?: string
   value?: T
   variant?: 'primary' | 'secondary' | 'tertiary'
 }

/@react-spectrum/s2:ListViewItem

 ListViewItem {
   allowsArrowNavigation?: boolean
   children: ReactNode
   download?: boolean | string
   focusMode?: 'child' | 'row'
   hasChildItems?: boolean
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   onAction?: () => void
-  onBlur?: (FocusEvent<HTMLDivElement>) => void
-  onFocus?: (FocusEvent<HTMLDivElement>) => void
-  onFocusChange?: (boolean) => void
   onHoverChange?: (boolean) => void
   onHoverEnd?: (HoverEvent) => void
   onHoverStart?: (HoverEvent) => void
   onPress?: (PressEvent) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   onPressUp?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   routerOptions?: RouterOptions
   target?: HTMLAttributeAnchorTarget
   textValue?: string
   value?: T
 }

/@react-spectrum/s2:SideNavHeader

 SideNavHeader {
-  children?: ReactNode
-  id?: string
+  props: SideNavHeaderProps
+  returnVal: undefined
 }

/@react-spectrum/s2:SideNavPanel

 SideNavPanel {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   children?: ReactNode
   defaultCollapsed?: boolean
   isCollapsed?: boolean
   onCollapsedChange?: (boolean) => void
   styles?: StylesPropWithHeight
+  width?: number = 208
 }

/@react-spectrum/s2:CardProps

 CardProps {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   allowsArrowNavigation?: boolean
   children: ReactNode | (CardRenderProps) => ReactNode
   density?: 'compact' | 'regular' | 'spacious' = 'regular'
   download?: boolean | string
   focusMode?: 'child' | 'row'
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   onAction?: () => void
-  onBlur?: (FocusEvent<HTMLDivElement>) => void
-  onFocus?: (FocusEvent<HTMLDivElement>) => void
-  onFocusChange?: (boolean) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   routerOptions?: RouterOptions
   size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
   styles?: StylesProp
   target?: HTMLAttributeAnchorTarget
   textValue?: string
   value?: T
   variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet' = 'primary'
 }

/@react-spectrum/s2:AssetCardProps

 AssetCardProps {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   allowsArrowNavigation?: boolean
   children: ReactNode | (CardRenderProps) => ReactNode
   download?: boolean | string
   focusMode?: 'child' | 'row'
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   onAction?: () => void
-  onBlur?: (FocusEvent<HTMLDivElement>) => void
-  onFocus?: (FocusEvent<HTMLDivElement>) => void
-  onFocusChange?: (boolean) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   routerOptions?: RouterOptions
   size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
   styles?: StylesProp
   target?: HTMLAttributeAnchorTarget
   textValue?: string
   value?: T
   variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet' = 'primary'
 }

/@react-spectrum/s2:ProductCardProps

 ProductCardProps {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   allowsArrowNavigation?: boolean
   children: ReactNode | (CardRenderProps) => ReactNode
   download?: boolean | string
   focusMode?: 'child' | 'row'
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   onAction?: () => void
-  onBlur?: (FocusEvent<HTMLDivElement>) => void
-  onFocus?: (FocusEvent<HTMLDivElement>) => void
-  onFocusChange?: (boolean) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   routerOptions?: RouterOptions
   size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
   styles?: StylesProp
   target?: HTMLAttributeAnchorTarget
   textValue?: string
   value?: T
   variant?: 'primary' | 'secondary' | 'tertiary'
 }

/@react-spectrum/s2:UserCardProps

 UserCardProps {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   allowsArrowNavigation?: boolean
   children: ReactNode | (CardRenderProps) => ReactNode
   download?: boolean | string
   focusMode?: 'child' | 'row'
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   onAction?: () => void
-  onBlur?: (FocusEvent<HTMLDivElement>) => void
-  onFocus?: (FocusEvent<HTMLDivElement>) => void
-  onFocusChange?: (boolean) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   routerOptions?: RouterOptions
   size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
   styles?: StylesProp
   target?: HTMLAttributeAnchorTarget
   textValue?: string
   value?: T
   variant?: 'primary' | 'secondary' | 'tertiary'
 }

/@react-spectrum/s2:ListViewItemProps

 ListViewItemProps {
   allowsArrowNavigation?: boolean
   children: ReactNode
   download?: boolean | string
   focusMode?: 'child' | 'row'
   hasChildItems?: boolean
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   onAction?: () => void
-  onBlur?: (FocusEvent<HTMLDivElement>) => void
-  onFocus?: (FocusEvent<HTMLDivElement>) => void
-  onFocusChange?: (boolean) => void
   onHoverChange?: (boolean) => void
   onHoverEnd?: (HoverEvent) => void
   onHoverStart?: (HoverEvent) => void
   onPress?: (PressEvent) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   onPressUp?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   routerOptions?: RouterOptions
   target?: HTMLAttributeAnchorTarget
   textValue?: string
   value?: T
 }

/@react-spectrum/s2:SideNavPanelProps

 SideNavPanelProps {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   children?: ReactNode
   defaultCollapsed?: boolean
   isCollapsed?: boolean
   onCollapsedChange?: (boolean) => void
   styles?: StylesPropWithHeight
+  width?: number = 208
 }

@rspbot

rspbot commented Oct 22, 2026

Copy link
Copy Markdown

Agent Skills Changes

Modified (12)
Install

React Spectrum S2:

npx skills add https://d1pzu54gtk2aed.cloudfront.net/pr/b67ac115921a8b55bba734a408ddaeec87f3b760/

React Aria:

npx skills add https://d5iwopk28bdhl.cloudfront.net/pr/b67ac115921a8b55bba734a408ddaeec87f3b760/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants