Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5a9d28b
perf(UnderlineNav): replace JS DOM measurement with CSS overflow + In…
hectahertz Feb 13, 2026
36fdb29
refactor: toggle icons via CSS data attribute, remove iconsVisible fr…
hectahertz Feb 13, 2026
b022987
fix: prevent CLS on UnderlineNav first paint
hectahertz Feb 13, 2026
33731b8
refactor: clean up dead exports, inline styles, and duplicate CSS
hectahertz Feb 13, 2026
bcf91ab
fix: re-enable icons when list grows via ResizeObserver
hectahertz Feb 13, 2026
9652940
refactor: simplify UnderlineNav internals
hectahertz Feb 13, 2026
236d732
refactor: simplify UnderlineNav CSS
hectahertz Feb 13, 2026
9a1a2b5
Add changeset
hectahertz Feb 13, 2026
b76e992
fix(UnderlineNav): address review feedback
hectahertz Feb 14, 2026
33f0eb1
test(UnderlineNav): add IO/RO mock tests for overflow detection
hectahertz Feb 14, 2026
1119347
test(vrt): update snapshots
hectahertz Feb 14, 2026
fc7ba0e
fix(UnderlineNav): use CSS anchor positioning for More button placement
hectahertz Feb 14, 2026
8eb45e1
Revert "test(vrt): update snapshots"
hectahertz Feb 14, 2026
09517d0
fix(UnderlineNav): reserve space for More button via IO rootMargin
hectahertz Feb 14, 2026
d8a240e
chore(UnderlineNav): clean up stale comments and simplify AnchorMarke…
hectahertz Feb 14, 2026
512efe0
fix(UnderlineNav): prevent flicker during resize by deferring overflo…
hectahertz Feb 14, 2026
802ba7e
refactor(UnderlineNav): extract overflow observer into documented hel…
hectahertz Feb 14, 2026
b8ae7dd
fix(UnderlineNav): add stylelint-disable for CSS anchor positioning
hectahertz Feb 14, 2026
2f37e90
docs(UnderlineNav): clarify icon toggle state machine diagram
hectahertz Feb 14, 2026
ac120ed
Merge branch 'main' into hectahertz/perf/underlinenav-intersection-ob…
hectahertz Feb 16, 2026
4fa65eb
fix: clear overflow when items fit without More button reserve
hectahertz Feb 16, 2026
0c70f5c
Merge branch 'main' into hectahertz/perf/underlinenav-intersection-ob…
francinelucca Feb 18, 2026
c02d93c
test(vrt): update snapshots
francinelucca Feb 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/underlinenav-intersection-observer-overflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Improve UnderlineNav overflow performance by replacing synchronous DOM measurements with CSS `overflow: hidden` + `IntersectionObserver`
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
90 changes: 86 additions & 4 deletions packages/react/src/UnderlineNav/UnderlineNav.module.css
Original file line number Diff line number Diff line change
@@ -1,20 +1,102 @@
/* Fixed height on the wrapper prevents vertical CLS on first paint.
Horizontal clipping is handled by overflow: hidden on the <ul>.
The More button is always rendered (visibility: hidden when no overflow)
to prevent horizontal CLS. We intentionally omit overflow: hidden here
so the underline ::after pseudo-element (which extends ~1px below) is not clipped. */
.NavWrapper {
height: var(--control-xlarge-size, 48px);
position: relative;
}

.MenuItemContent {
display: flex;
align-items: center;
justify-content: space-between;
}

/* Container for the "More" button and overflow menu.
Uses CSS anchor positioning to align with the anchor marker at overflowStartIndex.
Falls back to static position (end of flex row) in browsers without support. */
.MoreMenuContainer {
position: absolute;
/* stylelint-disable-next-line declaration-property-value-no-unknown, primer/spacing */
left: anchor(left);
align-self: center;
display: flex;
align-items: center;
}

/* Zero-size anchor markers placed after each nav item in the list.
They provide anchor points for CSS anchor positioning of the More button. */
.AnchorMarker {
width: 0;
padding: 0;
margin: 0;
overflow: hidden;
flex: 0 0 0px;
}

/* Before IO has fired, the More button is hidden to prevent flash.
Since .MoreMenuContainer is position: absolute, this only hides visually. */
.MoreMenuHidden {
visibility: hidden;
}

/* Overflow list: clips items that don't fit; IO detects which are clipped.
Padding-bottom/margin-bottom accommodate the underline ::after pseudo-element. */
.OverflowList {
overflow: hidden;
flex: 1;
min-width: 0;
padding-bottom: var(--base-size-12);
margin-bottom: calc(var(--base-size-12) * -1);
}

/* Divider line before the More button */
.Divider {
display: inline-block;
border-left: var(--borderWidth-thin) solid var(--borderColor-muted);
width: 1px;
margin-right: var(--base-size-4);
height: var(--base-size-24);
}

/* Dropdown overlay for the overflow menu */
.OverflowMenu {
position: absolute;
z-index: 1;
top: 90%;
box-shadow: var(--shadow-resting-medium);
border-radius: var(--borderRadius-medium);
background: var(--overlay-bgColor);
list-style: none;
min-width: 192px;
max-width: 640px;
right: 0;
display: none;
}

.OverflowMenuOpen {
display: block;
}

/* Hide the selected check icon on menu items and reset link decoration */
.MenuItem {
text-decoration: none;

& > span {
display: none;
}
}

/* More button styles migrated from styles.ts (was moreBtnStyles) */
.MoreButton {
margin: 0; /* reset Safari extra margin */
border: 0;
background: transparent;
font-weight: var(--base-text-weight-normal);
box-shadow: none;
padding-top: var(--base-size-4);
padding-bottom: var(--base-size-4);
padding-left: var(--base-size-8);
padding-right: var(--base-size-8);
padding: var(--base-size-4) var(--base-size-8);

& > [data-component='trailingVisual'] {
margin-left: 0;
Expand Down
168 changes: 165 additions & 3 deletions packages/react/src/UnderlineNav/UnderlineNav.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {describe, expect, it, vi} from 'vitest'
import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'
import type React from 'react'
import {render, screen} from '@testing-library/react'
import {render, screen, act} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
CodeIcon,
Expand Down Expand Up @@ -78,7 +78,8 @@ describe('UnderlineNav', () => {
it('renders icons correctly', () => {
const {getByRole} = render(<ResponsiveUnderlineNav />)
const nav = getByRole('navigation')
expect(nav.getElementsByTagName('svg').length).toEqual(7)
const list = nav.querySelector('[role="list"], ul, ol')!
expect(list.getElementsByTagName('svg').length).toEqual(7)
})

it('fires onSelect on click', async () => {
Expand Down Expand Up @@ -253,3 +254,164 @@ describe('Keyboard Navigation', () => {
expect(nextItem).toHaveFocus()
})
})

describe('Overflow detection (IntersectionObserver + ResizeObserver)', () => {
const originalIO = window.IntersectionObserver
const originalRO = window.ResizeObserver

let ioCallback: IntersectionObserverCallback
let ioInstance: IntersectionObserver
let roCallback: ResizeObserverCallback
let roInstance: ResizeObserver
let observedElements: Element[]

beforeEach(() => {
observedElements = []

window.IntersectionObserver = vi.fn(function (callback: IntersectionObserverCallback) {
ioCallback = callback
ioInstance = {
observe: vi.fn((el: Element) => observedElements.push(el)),
unobserve: vi.fn(),
disconnect: vi.fn(),
takeRecords: vi.fn().mockReturnValue([]),
root: null,
rootMargin: '',
thresholds: [],
}
return ioInstance
}) as unknown as typeof IntersectionObserver

window.ResizeObserver = vi.fn(function (callback: ResizeObserverCallback) {
roCallback = callback
roInstance = {
observe: vi.fn(),
disconnect: vi.fn(),
unobserve: vi.fn(),
}
return roInstance
}) as unknown as typeof ResizeObserver
})

afterEach(() => {
window.IntersectionObserver = originalIO
window.ResizeObserver = originalRO
})

// Suppress unused variable warnings for roCallback/roInstance
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _getRoRefs = () => ({roCallback, roInstance})

function fireIntersection(entries: Array<{target: Element; intersectionRatio: number}>) {
act(() => {
ioCallback(
entries.map(e => ({...e, isIntersecting: e.intersectionRatio > 0}) as unknown as IntersectionObserverEntry),
ioInstance,
)
})
}

/** Fire IO twice to complete the icon-toggle cycle:
* 1st call: icons visible + overflow => hides icons (early return)
* 2nd call: icons hidden + still overflow => sets overflowStartIndex */
function fireOverflow(visibleCount: number) {
const makeEntries = () =>
observedElements.map((el, i) => ({
target: el,
intersectionRatio: i < visibleCount ? 1 : 0,
}))
// Phase 1: triggers icon hiding
fireIntersection(makeEntries())
// Phase 2: with icons hidden, sets overflow
fireIntersection(makeEntries())
}

it('observes all list items', () => {
render(<ResponsiveUnderlineNav />)
// 9 nav items in the fixture
expect(observedElements).toHaveLength(9)
})

it('shows the "More" button when items overflow', () => {
render(<ResponsiveUnderlineNav />)
fireOverflow(5)
expect(screen.getByRole('button', {name: /More/})).toBeVisible()
})

it('marks overflowing items as aria-hidden', () => {
render(<ResponsiveUnderlineNav />)
fireOverflow(5)

const list = screen.getByRole('list')
const listItems = Array.from(list.querySelectorAll(':scope > li:not([data-anchor-marker])'))

// Items at indices 5-8 should be aria-hidden
for (let i = 5; i < listItems.length; i++) {
expect(listItems[i]).toHaveAttribute('aria-hidden', 'true')
}

// Items at indices 0-4 should not be aria-hidden
for (let i = 0; i < 5; i++) {
expect(listItems[i]).not.toHaveAttribute('aria-hidden')
}
})

it('removes the "More" button when all items become visible', () => {
render(<ResponsiveUnderlineNav />)
fireOverflow(5)
expect(screen.getByRole('button', {name: /More/})).toBeVisible()

// All items visible again
fireIntersection(
observedElements.map(el => ({
target: el,
intersectionRatio: 1,
})),
)

// "More" button should no longer be in the document
expect(screen.queryByRole('button', {name: /More/})).toBeNull()
})

it('swaps aria-current item from overflow into visible range', () => {
// "Settings" is at index 7, which will be in the overflow range
render(<ResponsiveUnderlineNav selectedItemText="Settings" />)
fireOverflow(5)

// The aria-current item should be visible (swapped into the last visible position)
const currentLink = screen.getByRole('link', {name: /Settings/})
expect(currentLink.closest('[aria-hidden]')).toBeNull()
expect(currentLink.getAttribute('aria-current')).toBe('page')
})

it('sets data-icons-visible to false when overflow occurs', () => {
const {container} = render(<ResponsiveUnderlineNav />)
const nav = container.querySelector('nav')!

// Initially icons are visible
expect(nav.getAttribute('data-icons-visible')).toBe('true')

// Single IO fire with overflow triggers icon hiding
fireIntersection(
observedElements.map((el, i) => ({
target: el,
intersectionRatio: i < 5 ? 1 : 0,
})),
)

// Icons should be hidden as first attempt to reduce overflow
expect(nav.getAttribute('data-icons-visible')).toBe('false')
})

it('ensures overflow menu has at least 2 items (never just 1)', () => {
render(<ResponsiveUnderlineNav />)

// Only 1 item would overflow (last item), but the rule says minimum 2
fireOverflow(8)

const list = screen.getByRole('list')
const hiddenItems = Array.from(list.querySelectorAll(':scope > li:not([data-anchor-marker])[aria-hidden="true"]'))
// Should have 2 hidden items (pulled one more into overflow to avoid single-item menu)
expect(hiddenItems.length).toBeGreaterThanOrEqual(2)
})
})
Loading
Loading