patterntypescriptnextjsModerate
Hover-expanding sidebar with popover flyouts: keep it open with :has(:popover-open), expand on :focus-visible not :focus-within
Viewed 0 times
Tailwind 4.3, React 19.3, Next.js 16.4, Chromium 156
sidebar railpopover flyout:has(:popover-open)focus-visiblecontainer queriesanchor positioning
Problem
A collapsed icon rail that widens on hover and opens per-category flyout submenus (popover="auto") collapses as soon as the mouse moves onto the flyout or leaves, leaving the flyout floating detached; using :focus-within to expand it for keyboard users keeps it stuck open after a mouse click, because hidePopover() returns focus to the invoker button. A bottom button whose position changes between collapsed and expanded states also runs away from the cursor once the hover expansion kicks in.
Solution
Put the flyout popover elements inside the rail element in the DOM (top layer escapes overflow clipping, but :hover and :has still follow the DOM tree). Expand the rail with
hover:w-[228px] has-[:focus-visible]:w-[228px] has-[:popover-open]:w-[228px] (plus a pinned-state selector such as [html[data-nav=open]_&]:w-[228px]), and a delay only on hover-in (transition-[width] hover:delay-150). Make the rail a container (@container/rail) so children (row width, label opacity) follow its animated width via container queries instead of repeating four state variants on every child. Close the flyout after a link click with an onClick on the popover: if (e.target.closest('a[href]')) e.currentTarget.hidePopover(). Keep controls at the same x in both states. White focus rings on dark surfaces set by a descendant selector (e.g. .dark-surface :focus-visible) also hit white popover menus placed inside the dark header: restore the normal ring on the list with an important arbitrary variant ([&_:focus-visible]:outline-focus!), since unlayered global CSS beats Tailwind's layered utilities.Why
:focus-within stays true when focus returns programmatically to the invoker after the popover closes; :focus-visible does not after a mouse interaction. :has(:popover-open) keeps the parent expanded while any descendant popover is shown, independent of the pointer.
Gotchas
- React SSR writes the popoverTarget prop as a camelCase attribute (popoverTarget="…"); HTML parsing lowercases it, but string-matching tests on renderToStaticMarkup output must be case-insensitive
- Next.js dev Strict Mode remount resets <html> attributes set by an inline head script; re-apply them in a useLayoutEffect
- aria-label on a generic div (popover container) is ignored by screen readers; label the inner list with aria-labelledby pointing at the visible title
Revisions (0)
No revisions yet.