Skip to content

Fix and refactor UnderlineNav to resolve CLS issues and improve performance/code quality#7506

Open
iansan5653 wants to merge 82 commits into
mainfrom
underline-nav-full-css-spike
Open

Fix and refactor UnderlineNav to resolve CLS issues and improve performance/code quality#7506
iansan5653 wants to merge 82 commits into
mainfrom
underline-nav-full-css-spike

Conversation

@iansan5653

@iansan5653 iansan5653 commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

This refact/bugfix PR reworks UnderlineNav's internal logic to rely mostly on CSS for calculating overflow, eliminating layout shift issues. In addition, it removes the use of the React.Children API in favor of context-based solutions, and replaces the custom overflow menu with an ActionMenu.

See https://github.com/github/primer/issues/6291#issuecomment-3861950186 for context.

The CSS overflow logic works like this:

  1. Overflowing menu items wrap onto subsequent lines, which are clipped by their container using overflow: hidden, creating the illusion that they disappeared
  2. The initial visibility of the overflow menu button and tab icons is controlled by scroll-state container queries to detect overflow using pure CSS
  3. Pure CSS cannot, however, tell us what to render inside the overflow menu. For this, each list item registers itself using context:
    1. The parent component provides register/unregister functions through context
    2. Each item component registers an IntersectionObserver to update itself with the parent when it appears/disappears. They determine whether they are overflowed by checking ref.current.offsetHeight (which will be greater than zero for an item that has wrapped). If they are overflowed, they set aria-hidden/tabIndex="-1" and notify the parent via the registry.
    3. The parent renders menu items for all currently-overflowing items in the overflow menu
    4. If any item has ever overflowed, all tab icons are suppressed via React state for the rest of the lifecycle of the component (this prevents flickering caused by the tab icons creating/removing space)

The benefits are pretty significant:

  1. The browser will calculate overflow for us before we ever even paint on the page - no waiting on a JS bundle or React hydration (this benefit is shared with the lighter-weight approach)
  2. Calculations are nearly pure CSS, so performance is buttery smooth for the whole component lifecycle
  3. We get to clean up tons of complex code, remove several effects, and remove hacky Children APIs entirely
  4. Bonus: Component consumption is much less fragile. Without Children APIs, we no longer need direct children, so consumers can wrap their underline items in fragments and even wrapper components

However, there are some caveats / user-facing changes:

  1. The most significant is that we can no longer ensure that the current item will not overflow into the menu. This is impossible to implement with the new approach; even if we were to extract the current item and render it outside the overflow menu, there's no place to put it since it would just wrap out of view if rendered into the list. If rendered next to the list, it would be pushed too far to the right due to the space caused by wrapping items in the list. I even tried implementing some JS to swap it with the last visible item, but that still can result in overflow if the current item is larger than that item. Instead, I've added some styling to indicate the current item inside the menu:
    screenshot of overflow menu item with bold text and orange border line
  2. The overflow menu button must be right-aligned, as opposed to appearing right next to the last button, because the list of items will always take up as much space as possible before wrapping. Honestly I think this is a nice design improvement.
  3. Instead of moving the first item (last to overflow) into the overflow menu, we will truncate it with an ellipsis instead. This should almost never happen, as the first item would have to have a very long label.

Changelog

New

Changed

Removed

Rollout strategy

  • Patch release
  • Minor release
  • Major release; if selected, include a written rollout or migration plan
  • None; if selected, include a brief description as to why

Testing & Reviewing

Merge checklist

TylerJDev and others added 8 commits February 6, 2026 15:05
…e.module.css

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…e.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@changeset-bot

changeset-bot Bot commented Feb 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d59e127

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@primer/react Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added the integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm label Feb 6, 2026
@github-actions

github-actions Bot commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

👋 Hi, this pull request contains changes to the source code that github/github-ui depends on. If you are GitHub staff, test these changes with github/github-ui using the integration workflow. Or, apply the integration-tests: skipped manually label to skip these checks.

@iansan5653 iansan5653 changed the base branch from main to tylerjdev/overflow-underlinenav February 6, 2026 20:28
@github-actions github-actions Bot requested a deployment to storybook-preview-7506 February 6, 2026 20:31 Abandoned
@github-actions github-actions Bot temporarily deployed to storybook-preview-7506 February 6, 2026 20:40 Inactive
@iansan5653

Copy link
Copy Markdown
Contributor Author

BTW, I think the flex inspector in Chrome's devtools does a really nice job of showing what's going on here / where the tabs are going:

Screen.Recording.2026-02-06.at.5.00.33.PM.mov

@hectahertz hectahertz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Really solid work on this. The CSS approach for overflow detection is a meaningful improvement over the old JS measurement loop, and the code is noticeably cleaner. Happy to see this land 🚀

@iansan5653

iansan5653 commented Mar 11, 2026

Copy link
Copy Markdown
Contributor Author

I spent some time yesterday trying to find a solution which would enable us to force the aria-current item to always be visible.

I think the most promising solution is to use order to reorder items to move the current one to the top level. The naive approach is to assign order: 3 to all the hidden items, order: 1 to the visible items, and order: 2 to the current item if hidden. However, this doesn't actually work because it just moves the current item to the front of the hidden items -- it doesn't create space for it in the visible region.

So we want to move the item in front of all the hidden ones, but also shift enough visible items to be hidden to make room for the current item. This is complex enough, but is complicated by the fact that the logic is circular; it depends on figuring out which items are visible and not visible, but then changes which items are visible and not visible. Any effect-based approach would be very fragile. I think it's possible, but ultimately would cost too much in performance and complexity to be worth it.

We could simplify this approach significantly by just putting the current item first if it would overflow (order: 0), but that breaks the tabbing order and would feel pretty weird as the order of items wouldn't be consistent.

I also explored simply rendering the current item at the top level next to the overflow menu as we did before. The problem here is the same one that forces us to right-align the menu though; the wrapping container for the other menu items has to take up as much width as possible, so there can be a gap between the current item and the main items. The flex inspector shows how there just isn't a place to put an item. The same would apply to any grid based approach.

chrome devtools flex inspector

Finally, I opened an alternative PR to explore the possibility of just getting rid of the overflow menu and scrolling the container instead: #7648

@github-actions

Copy link
Copy Markdown
Contributor

Hi! This pull request has been marked as stale because it has been open with no activity for 60 days. You can comment on the pull request or remove the stale label to keep it open. If you do nothing, this pull request will be closed in 7 days.

@lesliecdubs

Copy link
Copy Markdown
Member

Sorry for the hold-up on this one. Working on finding a reviewer to take it over from @hectahertz 🙏🏻

@iansan5653

Copy link
Copy Markdown
Contributor Author

@copilot

  1. Resolve the merge conflicts in this pull request
  2. When the current item is overflowed, style the overflow menu anchor to look like it's the current item (show the orange decoration on it)
  3. When the current item is overflowed, append ", including current item {name}" to the aria-label of the overflow menu anchor button

@primer-integration

Copy link
Copy Markdown

Integration test results from github/github-ui PR:

Failed  CI   Failed
Passed  VRT   Passed
Passed  Projects   Passed

CI check runs linting, type checking, and unit tests. Check the workflow logs for specific failures.

Need help? If you believe this failure is unrelated to your changes, please reach out to the Primer team for assistance.

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

Labels

Canary Release Apply this label when you want CI to create a canary release of the current PR integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants