Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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/proud-candles-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": patch
---

`UnderlineNav`: Fix icon flickering on some screen sizes before initial render/hydration
49 changes: 40 additions & 9 deletions packages/react/src/UnderlineNav/UnderlineNav.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@
/* Progressive enhancement: Detect overflow using scroll-based animations.
The idiomatic way would be a scroll-state container query but browser support
is slightly better for animations. */
animation: detect-overflow linear;
animation-timeline: scroll(self block);
animation:
detect-overflow linear,
delay-visibility forwards 0ms 5ms;
animation-timeline: scroll(self block), auto;

--UnderlineNav_moreButton-visibility: hidden;
--UnderlineNav_icons-display: inline;

&[data-hide-icons='true'] {
--UnderlineNav_icons-display: none;
}
--UnderlineNav_hide-icons-play-state: paused;

&[data-has-overflow='true'] {
--UnderlineNav_moreButton-visibility: visible;
Expand All @@ -21,12 +19,45 @@
0%,
100% {
--UnderlineNav_moreButton-visibility: visible;
--UnderlineNav_icons-display: none;
--UnderlineNav_hide-icons-play-state: running;
}
}

.ItemsList [data-component='icon'] {
display: var(--UnderlineNav_icons-display);
/* Unlike the more button, the icons are removed from the layout when hidden. This can cause the container to no
longer overflow, which would cause a flickering loop if we just drove the visibility directly from the scroll state.
So instead we drive an animation that can only play forwards so the icons stay hidden for the life of the page, even
if the container is no longer overflowing. We can't just make the scroll-driven animation itself play forwards,
because scroll-driven animations are conditionally applied and revert when not scrollable. */
animation-name: hide-icons;
animation-fill-mode: forwards;
animation-timing-function: linear;
animation-play-state: var(--UnderlineNav_hide-icons-play-state);
animation-duration: 0.1ms; /* must be greater than 0 */

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Interestingly, this can't be arbitrarily small; 0.0001ms is treated as 0 in Chrome. 0.1ms seems to work very consistently though.

I did try making the duration dynamic, instead of the play state, and setting the duration to 0 to immediately resolve the animation when I want to toggle it. That didn't work though; the style didn't 'stick' if the duration was increased again.

}

/* Slow devices will still flicker on initial paint as we figure out if we have room to render icons or not,
so we delay the tabs visibility by a very short time to allow things to settle. */
@keyframes delay-visibility {
0%,
99.9% {
visibility: hidden;
}

100% {
visibility: visible;
}
}

@keyframes hide-icons {
0% {
display: inline;
}

0.1%,
100% {
display: none;
}
}
Comment on lines +52 to 61

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm like 99% sure this is incorrect. Browsers will animate the property but (obviously) not smoothly; they'll discretely step from one value to the next.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This turned out to be due to not setting an initial value for the variable. Which feels like a browser bug, but it's easy to solve by initializing it.


.MoreButtonContainer {
Expand Down
5 changes: 2 additions & 3 deletions packages/react/src/UnderlineNav/UnderlineNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,8 @@ export const UnderlineNav = forwardRef(
ref={navRef}
data-variant={variant}
data-overflow-mode="wrap"
// Force icons to stay hidden, avoiding flickering as icons create/remove overflow
// Ensure icons are hidden and button is shown on browsers that don't support scroll-driven animations
data-hide-icons={hasEverOverflowed ? 'true' : undefined}
// Ensure button is shown (after initial render) on browsers that don't support scroll-driven animations
data-has-overflow={isOverflowing ? 'true' : undefined}
>
<UnderlineItemList ref={listRef} role="list" className={classes.ItemsList}>
Expand All @@ -115,7 +114,7 @@ export const UnderlineNav = forwardRef(
variant="invisible"
data-component="overflow-menu-button"
data-current={overflowingCurrentItem ? 'true' : undefined}
aria-label={overflowingCurrentItem ? `More items, including current item` : undefined}
aria-label={overflowingCurrentItem ? 'More items, including current item' : undefined}
>
<span>
More<VisuallyHidden as="span"> items</VisuallyHidden>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

.UnderlineItemList {
flex-wrap: wrap;
overflow: hidden;
flex: 1;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

overflow: hidden had no effect here because there's no explicit height set on this container. And it's unnecessary since the parent already has that applied.

flex: 1 fixes a separate bug where sometimes the button wouldn't float all the way to the right. It makes the tab containers take up more space to push the overflow button further out. This prevents it from looking like it's floating in space.

}
}
}
Expand Down
Loading