Skip to content

feat: Animate side navigation collapse and expandable section reveal - #4867

Open
jkuelz wants to merge 8 commits into
mainfrom
dev-v3-jkuelz-side-nav-animations-refactor
Open

feat: Animate side navigation collapse and expandable section reveal#4867
jkuelz wants to merge 8 commits into
mainfrom
dev-v3-jkuelz-side-nav-animations-refactor

Conversation

@jkuelz

@jkuelz jkuelz commented Aug 5, 2026

Copy link
Copy Markdown
Member

Description

Animates SideNavigation's collapsed icon rail transition and ExpandableSection's content reveal, scoped to 1T only as per the animation strategy and to prevent breaking changes.

  • Replaces ExpandableSection's display: none/block content toggle with an animated grid-template-rows reveal. The content stays mounted and inert while collapsed, to allow for smooth transition states that weren't possible with the previous display: none.
  • Refactors SideNavigation's to the same grid-template-rows strategy as expandable sections to enable transitions: text fade, spatial collapse/expand, and staggered enter/exit timing for links, sections, section-groups, link-groups, and expandable-link-groups.
  • Adds a one-theme-with-motion mixin (internal/styles/motion/mixins.scss) alongside the existing with-motion, so animation-only CSS additions can be scoped to one-theme without affecting other themes' timing.
  • Gives one-theme its own values, reusing the existing token pair rather than introducing new ones. These may change with the upcoming animations strategy.

Other behavior changes:

  • Added warnings when a collapsed-rail item (a root-level link, link-group, or expandable-link-group, or a link/link-group/ELG nested inside a root-level section/section-group) has no icon and would therefore be invisible or have no visual indicator in the collapsed rail.
  • Expandable-link-group children are now forced closed when the nav is collapsed, regardless of the ELG's own expanded/collapsed state or icon presence. Previously an ELG's would either collapse their children or show their children depending on whether the parent had an icon.
  • Sections are now forced open when the nav is collapsed, so their icon-bearing children always flatten into the rail — this existed conceptually before but is now explicit and paired with the ELG behavior above.
  • Sections, link-groups, and ELGs use inert (not just visual hiding) to remove their headers/parent links from the tab order and assistive technology when they collapse to zero size in the rail.
  • Reverted an earlier change that added dividers between sections/groups in the collapsed state and returned to using space instead.
  • New ariaLabel plumbing and aria-hidden on section/section-group header text for collapsed-rail accessibility.

Why animations only in 1T?
The initial version of this animation applied to all themes and broke the pre-existing contract for ExpandableSection that collapsed/hidden content is visually removed instantly (display: none) — which means any downstream tests that asserted isDisplayed()/isExisting() immediately after a collapse toggle with no wait, could fail. Scoping the animation to one-theme (opt-in) avoids that regression for existing VR/classic consumers while still shipping the new motion for one-theme.

Related links, issue #, if available: n/a

How has this been tested?

dev pipeline

Review checklist

The following items are to be evaluated by the author(s) and the reviewer(s).

Correctness

  • Changes include appropriate documentation updates.
  • Changes are backward-compatible if not indicated, see CONTRIBUTING.md.
  • Changes do not include unsupported browser features, see CONTRIBUTING.md.
  • Changes were manually tested for accessibility, see accessibility guidelines.

Security

Testing

  • Changes are covered with new/existing unit tests?
  • Changes are covered with new/existing integration tests?

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@jkuelz
jkuelz requested a review from mxschll August 5, 2026 06:36
@jkuelz
jkuelz marked this pull request as ready for review August 5, 2026 06:36
@jkuelz
jkuelz requested a review from a team as a code owner August 5, 2026 06:36
@jkuelz
jkuelz force-pushed the dev-v3-jkuelz-side-nav-icon-layout branch from efc4551 to 0e9a964 Compare August 6, 2026 05:26
@jkuelz
jkuelz force-pushed the dev-v3-jkuelz-side-nav-animations-refactor branch from b7b961d to b21c945 Compare August 6, 2026 05:41
@jkuelz
jkuelz force-pushed the dev-v3-jkuelz-side-nav-icon-layout branch from 0e9a964 to 8bb3e7c Compare August 6, 2026 05:43
@jkuelz
jkuelz force-pushed the dev-v3-jkuelz-side-nav-animations-refactor branch from b21c945 to 68c46e2 Compare August 6, 2026 05:52
}
__internalRootRef={__internalRootRef}
>
<CSSTransition in={expanded} timeout={30} classNames={{ enter: styles['content-enter'] }} nodeRef={ref}>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't use CSSTransition for reveal animations because onExit fires one render after expanded becomes false... so when !expanded=true AND !exitInProgress=true, the formula yields true momentarily causing a flash when padding was removed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't fully understand. Is this an issue with CSSTransition?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After looking into this again, the issue was not unique to CSSTransition as I originally thought. It was more about how it was configured. The original had a timeout of 30ms which is far shorter than any animation, causing the 'exited' state to render true while the animations were still in progress, resulting in a mixed transient flash state.

I found that we already had an internal wrapper around React Transition Group’s Transition component, so I used that instead. It exposes the transition status, handles reduced motion, and uses the actual transitionend event when no timeout is provided. This lets the "settled" state reset synchronously from expanded on collapse and move to entered only after the root’s grid-template-rows transition finishes, without adding another custom listener here.

header={header}
variant={variant === 'stacked' ? 'stacked' : 'default'}
disableContentPaddings={disableContentPaddings || !expanded}
disableContentPaddings={true}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved padding to ExpandableSection’s .content-inner-body-container, which sits inside the clip and collapses naturally with the grid track. Previously, container's content padding lived outside ExpandableSection's grid transition, so toggling it based on expanded state (as before) caused a visible 1-frame padding jump instead of animating smoothly.

Comment thread src/expandable-section/internal.tsx Outdated
}: InternalExpandableSectionProps) {
const ref = useRef<HTMLDivElement>(null);
const contentInnerRef = useRef<HTMLDivElement>(null);
// Starts settled when initially expanded or under reduced motion, since neither case

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or under reduced motion
Where is that part handled?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I think I moved it to css at one point but then didn't add back here. Fixed. The collapse-effect now settles immediately when expanding under reduced motion (previously contentSettled would never become true in that case, since handleContentTransitionEnd only fires on a real transitionend, which never happens when grid-template-rows doesn't transition).

}
}

.link-group-children-inner {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the transition is gated, why do we need to add this rule outside of the one theme rule? Will this cause a regression for VR, e.g. focus ring clipping?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The focus ring clipping was mostly an issue on expandable section because of how it does its padding/margin, but you're right to flag this here too. These can't be gated to one-theme because we no longer rely on conditional rendering that used to fully unmount LinkGroup's children in every theme. So VR now needs this CSS unconditionally to visually hide the always-mounted content when collapsed.

That said, overflow-y: clip was permanent, in every theme and every motion state, with nothing ever switching it back to visible once expanded. That meant any active/hover/focus state on the first/last child that visually extends past its own box was clipped, permanently, any time the group was expanded. Fixed by adding the same settled-state pattern expandable-section uses.

Comment thread style-dictionary/one-theme/motion.ts Outdated
const tokens: StyleDictionary.MotionDictionary = {
motionDurationFast: { default: '110ms', disabled: '0ms' },
motionDurationModerate: { default: '150ms', disabled: '0ms' },
motionDurationSlow: { default: '200ms', disabled: '0ms' },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This values are different from VR. Changing these tokens here will change animations for all components that use these tokens in One Theme. Is this intentional?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeas this was intentional, and seen as a two way door. I needed to increase the durations to make the animations more impactful than what we have defined in VR, which are generally very quick and snappy. I decided to change them here so that all animations still work together as a system/unit (scaled them up equally ~20ms each). I did a brief gut check on a few components, but I'll admit I didn't go and manually test every instance of motion one by one. I figured these will likely be changing again with our animation story anyway, so it felt like a low risk change to make. and changes this small are usually not very noticeable to the eye.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we decouple the navigation motion from these? We'll have more motion in place. Maybe we should think of motion tokens the same way we do for component specific color tokens. Wdyt?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think ideally any component specific motion tokens are more of composites vs individual values. However, this requires a change to theme-builder to be able to parse multiple { } values inside a token value. For now, I created more specific tokens that are used in these two components.

An error occurred while trying to automatically change base from dev-v3-jkuelz-side-nav-icon-layout to main August 7, 2026 03:22
jkuelz added 6 commits August 6, 2026 23:24
Replace the display:none content toggle in ExpandableSection with a grid-template-rows reveal, and animate the SideNavigation collapse/expand: text fades while icons stay centered, spatial and opacity transitions are staggered with tunable local timing vars, groups get collapsed-state spacing, and hidden content is inert for accessibility.

WIP checkpoint on dev-v3-jkuelz-side-nav-animations-refactor so the existing icon-layout PR stays clean.
@jkuelz
jkuelz force-pushed the dev-v3-jkuelz-side-nav-animations-refactor branch from 4538729 to 073e543 Compare August 7, 2026 06:29
An error occurred while trying to automatically change base from dev-v3-jkuelz-side-nav-icon-layout to main August 7, 2026 06:36
An error occurred while trying to automatically change base from dev-v3-jkuelz-side-nav-icon-layout to main August 7, 2026 06:36
An error occurred while trying to automatically change base from dev-v3-jkuelz-side-nav-icon-layout to main August 7, 2026 06:37
@jkuelz jkuelz closed this Aug 7, 2026
@jkuelz jkuelz reopened this Aug 7, 2026
@jkuelz
jkuelz changed the base branch from dev-v3-jkuelz-side-nav-icon-layout to main August 7, 2026 07:04
</div>
<SideNavigation
// header={{
// href: '#/',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to keep this block?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, good catch, this was just used during testing. Removed.

@@ -1,30 +0,0 @@
/*

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did we move this to src/expandable-section/styles.scss? It would be good to have a separation if possible to make it more maintainable in the future.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I discovered that the motion.scss file had drifted from the intended implementation: it targeted old selectors, which had I guess at some point been removed from the main file, making them obsolete and regress on the animations that should've been present in VR.
In addition to restoring those animations, I moved them to the main file because keeping those transition declarations beside the selectors which enable them makes their relationship explicit and avoids another selector/transition divergence in the future.


// One-theme only: animated grid-template-rows reveal instead of the true/false display:none. VR keeps
// instantaneous display toggling, to prevent breaking reliance on synchronous isDisplayed()/isExisting() checks.
@include theming.one-theme-only {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If our linter rules allow it, can we wrap all one theme specific styles in one big @include theming.one-theme-only? Makes it easier for us to keep track and maintain these.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Linter doesn't allow it :/

Comment thread src/expandable-section/internal.tsx Outdated

const handleContentTransitionEnd = useCallback(
(event: React.TransitionEvent<HTMLDivElement>) => {
if (event.propertyName === 'grid-template-rows' && expanded) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think since we can have nested expandable sections (not sure if we should have, that's a different topic), this condition fires for children too.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed and added tests.

@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.66%. Comparing base (e3fc2fc) to head (14ce6b3).
⚠️ Report is 6 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4867      +/-   ##
==========================================
+ Coverage   97.64%   97.66%   +0.02%     
==========================================
  Files         957      958       +1     
  Lines       31195    31299     +104     
  Branches    11500    11557      +57     
==========================================
+ Hits        30459    30567     +108     
+ Misses        729      686      -43     
- Partials        7       46      +39     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants