Follow-up to #1748 / #1774.
When opening a Select via keyboard and navigating with arrow keys, several keystrokes are consumed before the first menu item appears focused. The menu items are there and selectable, but the focus ring does not appear until after a number of arrow presses.
Summary
The Select menu's dropdown options were always present in the DOM (just hidden via display: none), which caused Headless UI to sort them by DOM position while they were invisible. Since hidden elements don't participate in layout, the sort produced unreliable results — so the first several arrow-down presses couldn't locate the correct option to highlight. The fix is to only render the dropdown into the DOM when the menu is actually open, so Headless UI always sorts against visible, laid-out elements.
Root Cause
The original issue description (as per below) pointed at tabIndex on the Floating UI container as the culprit — that diagnosis turned out to be misleading. The real cause is deeper in how Headless UI's internal state machine interacts with the portal.
What was actually happening
The ListboxOptions (and all SelectOption children) were always mounted in the DOM, hidden only via a display: none style on the wrapping Floating UI container div. This means on page load, before the menu is ever opened, HUI's option registration cycle runs:
- Each
ListboxOption registers itself with the machine via a batched microtask (RegisterOptions)
- The machine schedules a
SortOptions action via requestAnimationFrame to sort the registered options by their DOM position (sortByDomNode / compareDocumentPosition)
- The sort runs against elements that are inside a
display: none subtree — compareDocumentPosition can return 0 for all comparisons in this state, making the sort a no-op or producing an undefined order
When the menu is then opened and the user presses arrow-down, goToOption({ focus: Next }) fires. Because activeOptionIndex is null, HUI calls g(state) which re-runs sortByDomNode — again against elements whose DOM context was established while hidden. calculateActiveIndex gets a list it can't reliably order, so the resulting index is wrong or null. The first few arrow-down presses keep returning null or an incorrect index, with no data-active applied — until enough state has settled that the sort resolves correctly.
This explains why pressing Enter after a single arrow-down selected the correct item (HUI's logical state eventually resolved) but the visual data-active attribute didn't appear until press 4: the machine state was updating correctly but isActive was returning false for the wrong option index.
The Fix
Conditionally render the portal only when the menu is open ({open && createPortal(...)}) and remove the display toggling style. This means options only mount, register, and sort when the menu is actually open and the container is visible in the DOM — so compareDocumentPosition has a real layout to work with, and goToOption always finds a correctly-sorted option list.
The display: none / always-mounted approach was likely chosen to avoid the portal unmounting/remounting on every open/close, but it inadvertently broke HUI's DOM-order-dependent focus management.
Side note on the tabIndex change
The tabIndex={-1} on the floating container is still correct and worth keeping. Without it, getFloatingProps() leaves the container participating in sequential focus order, which can cause its own navigation issues — it just wasn't the cause of the 4-press lag.
Initial Issue Analysis and Fix (incorrect):
Root Cause
The Select menu is rendered inside a portal using Floating UI. The floating container <div> receives getFloatingProps() from @floating-ui/react's useInteractions, which spreads interaction props — including a tabIndex — onto the div. This places the container and/or the ListboxOptions element into the browser's sequential focus order. Arrow key presses are consumed navigating through these invisible focusable elements before Headless UI's virtual list navigation kicks in for the items.
### Fix
Add tabIndex={-1} explicitly to the floating container <div> in Select.component.tsx so it can still receive programmatic focus (required by Floating UI) but is excluded from sequential keyboard navigation.
Acceptance criteria
Follow-up to #1748 / #1774.
When opening a
Selectvia keyboard and navigating with arrow keys, several keystrokes are consumed before the first menu item appears focused. The menu items are there and selectable, but the focus ring does not appear until after a number of arrow presses.Summary
The Select menu's dropdown options were always present in the DOM (just hidden via
display: none), which caused Headless UI to sort them by DOM position while they were invisible. Since hidden elements don't participate in layout, the sort produced unreliable results — so the first several arrow-down presses couldn't locate the correct option to highlight. The fix is to only render the dropdown into the DOM when the menu is actually open, so Headless UI always sorts against visible, laid-out elements.Root Cause
The original issue description (as per below) pointed at
tabIndexon the Floating UI container as the culprit — that diagnosis turned out to be misleading. The real cause is deeper in how Headless UI's internal state machine interacts with the portal.What was actually happening
The
ListboxOptions(and allSelectOptionchildren) were always mounted in the DOM, hidden only via adisplay: nonestyle on the wrapping Floating UI container div. This means on page load, before the menu is ever opened, HUI's option registration cycle runs:ListboxOptionregisters itself with the machine via a batched microtask (RegisterOptions)SortOptionsaction viarequestAnimationFrameto sort the registered options by their DOM position (sortByDomNode/compareDocumentPosition)display: nonesubtree —compareDocumentPositioncan return 0 for all comparisons in this state, making the sort a no-op or producing an undefined orderWhen the menu is then opened and the user presses arrow-down,
goToOption({ focus: Next })fires. BecauseactiveOptionIndexis null, HUI callsg(state)which re-runssortByDomNode— again against elements whose DOM context was established while hidden.calculateActiveIndexgets a list it can't reliably order, so the resulting index is wrong or null. The first few arrow-down presses keep returning null or an incorrect index, with nodata-activeapplied — until enough state has settled that the sort resolves correctly.This explains why pressing Enter after a single arrow-down selected the correct item (HUI's logical state eventually resolved) but the visual
data-activeattribute didn't appear until press 4: the machine state was updating correctly butisActivewas returning false for the wrong option index.The Fix
Conditionally render the portal only when the menu is open (
{open && createPortal(...)}) and remove thedisplaytoggling style. This means options only mount, register, and sort when the menu is actually open and the container is visible in the DOM — socompareDocumentPositionhas a real layout to work with, andgoToOptionalways finds a correctly-sorted option list.The
display: none/ always-mounted approach was likely chosen to avoid the portal unmounting/remounting on every open/close, but it inadvertently broke HUI's DOM-order-dependent focus management.Side note on the
tabIndexchangeThe
tabIndex={-1}on the floating container is still correct and worth keeping. Without it,getFloatingProps()leaves the container participating in sequential focus order, which can cause its own navigation issues — it just wasn't the cause of the 4-press lag.Initial Issue Analysis and Fix (incorrect):
Root Cause
The
Selectmenu is rendered inside a portal using Floating UI. The floating container<div>receivesgetFloatingProps()from@floating-ui/react'suseInteractions, which spreads interaction props — including atabIndex— onto the div. This places the container and/or theListboxOptionselement into the browser's sequential focus order. Arrow key presses are consumed navigating through these invisible focusable elements before Headless UI's virtual list navigation kicks in for the items.### Fix
Add
tabIndex={-1}explicitly to the floating container<div>inSelect.component.tsxso it can still receive programmatic focus (required by Floating UI) but is excluded from sequential keyboard navigation.Acceptance criteria