The Mouse Wheel Is Quietly Editing Your Number Inputs
Most UI bugs are cosmetic. A couple of them change numbers the user did not intend to change, and if those numbers are leverage, order size, or a price, the bug is no longer cosmetic.
These are two we hit, both caused by browser and library behaviour that is technically documented and practically invisible. The fixes are small. Finding them was not.
Key Takeaways
- A focused <input type="number"> increments or decrements when you scroll the wheel over it. On a money field this silently rewrites the value.
- Fix it with blur(), not preventDefault(): blurring cancels the increment while leaving page and modal scrolling completely intact.
- A Radix popover portalled to document.body cannot hold focus inside a Radix dialog, because the dialog's focus scope pulls focus back out. The field looks alive and eats every keystroke.
- Pass the dialog's own element as the popover's portal container so it becomes part of the trapped subtree.
- That fix invalidates hand-rolled click-outside handlers: once content is portalled, it is no longer a DOM descendant of the wrapper you were measuring against.
1. The wheel is an editing gesture, and nobody told the user
Native number inputs have a behaviour that dates back to the spec: when the field is focused, scrolling the mouse wheel over it steps the value up or down by its step attribute.
On a quantity picker in a shopping cart, that is a mild annoyance. On our forms it is a different thing entirely, because the fields in question are leverage, trading amount, profit-share percentage, and listing price. The user clicks into a field, scrolls the page to read the rest of the form, and the number they already set has changed. Nothing announces it. The form still validates. The bot starts with a value the user never chose.
The naive fix is to attach a wheel handler on each input and call preventDefault(). That works, and it also breaks scrolling: the user can no longer scroll the page while the cursor happens to be over a number field, which feels broken in a completely different way.
The better fix uses the precondition in the spec. The increment only happens when the field is focused, so removing focus is enough:
const onWheel = (e) => {
const active = document.activeElement;
if (
active instanceof HTMLInputElement &&
active.type === "number" &&
(active === e.target || active.contains(e.target))
) {
active.blur(); // kills the increment, leaves scrolling intact
}
};
document.addEventListener("wheel", onWheel, { passive: true, capture: true });
Three deliberate choices in that snippet:
blur()rather thanpreventDefault(). We are not cancelling the scroll, we are removing the condition that makes it an edit. The page keeps scrolling normally.capture: true. The listener observes the event on the way down, so it still fires even if some inner component stops propagation.passive: true. Safe precisely because we never callpreventDefault(), and it keeps the listener off the scrolling critical path.
One global listener, mounted once, covers every number input in the app, including ones that do not exist yet. The alternative, patching each field, fails the moment someone adds a new form and forgets.
2. A popover inside a dialog that cannot be typed into
The second one presented as an impossible bug report: a search box inside a dropdown, inside a modal, that visibly focused and then ignored everything typed into it.
The cause is two correct behaviours colliding.
Radix popovers portal their content to document.body by default, which is right on a normal page, because it escapes overflow and stacking contexts. Radix dialogs trap focus inside their own subtree, which is also right, and is what makes a modal a modal.
Put them together and the popover's content is, in DOM terms, outside the dialog. The dialog's focus scope checks every incoming focus event roughly like this:
container.contains(target) ? allow : focus(lastFocusedElement)
The search box is not contained by the dialog, so every focus event landing on it was immediately undone. The field rendered, showed a caret for an instant, and lost focus before a keystroke could register. It looked alive and swallowed everything.
The fix is to stop lying about where the popover lives. Radix's portal accepts a container, so we render an empty, zero-height div inside the dialog and portal the popover into that:
// inside the dialog
<div ref={setDropdownHost} />
...
<PopoverContent container={dropdownHost}>…</PopoverContent>
Two details that are easy to get wrong:
- State, not a ref. The host node does not exist on first render, and the portal needs a re-render once it does. A
useRefwould hold the node but never trigger that render. - The host must not affect layout. It is empty and zero-height, so it can sit anywhere convenient in the dialog's markup without shifting anything.
3. The fix that quietly breaks the code next to it
This is the part I would flag hardest, because it is where a clean fix becomes a regression.
Before moving to a portalled popover, the dropdown had a hand-rolled click-outside handler: keep a ref on the wrapper, listen for document clicks, close if the click was not inside.
Once content is portalled, it is no longer a DOM descendant of that wrapper, even though it is visually inside it. So the handler now sees clicks on the dropdown's own search box as clicks outside, and closes the thing the user is trying to use.
Removing that handler was not cleanup, it was part of the fix. The general rule:
Portalling breaks every assumption based on DOM containment. Click-outside handlers,
closest()lookups, event delegation, and CSS descendant selectors all silently change meaning. When you portal something, go find the code that assumed containment.
What these two have in common
Both bugs sit in the gap between "the library is behaving correctly" and "the user is having a bad time." Nothing threw. Nothing logged. In both cases the component looked right in a screenshot.
The general lessons I would keep:
- Know which gestures the platform treats as edits. The wheel over a focused number input is one; so is a middle-click paste on Linux. If a field holds money, audit them.
- Prefer removing a precondition over cancelling an event.
blur()beatpreventDefault()because it targeted the cause instead of suppressing the symptom, and it left unrelated behaviour untouched. - Solve it once, globally, when the failure mode is "someone forgets." A per-field fix is a fix with an expiry date.
- When you portal, audit for containment assumptions. The DOM tree and the visual tree stop agreeing, and any code that conflated them is now wrong.
