Skip to content

Commit c9bf352

Browse files
authored
Ensure --button-width and --input-width are always up to date (#3786)
This PR fixes an issue where the `--button-width` and `--input-width` CSS variables weren't always up to date. We compute these values initially when the component mounts based on the `getBoundingClientRect` of the button and input elements. To ensure we catch changes in size, we setup a `ResizeObserver` that watches for changes to the button and input elements. Unfortunately, `ResizeObserver` doesn't fire when the size changes due to CSS properties such as `transform` or `scale`. As far as I can tell, there isn't a single event or Observer we can use to catch all possible changes. One solution to this problem would be to delay the computation of the sizes until after all transitions have completed and then we could even introduce a small delay to ensure everything is in its final state. However, you could literally use `hover:scale-110` on the Listbox button which would mean that the size changes whenever you hover over the button. To fix this in a more generic way, we setup a `requestAnimationFrame` loop that checks the size of the button and input elements on each frame. If the size has changed, we update the CSS variables. Note: we will only re-render if the size has actually changed, so this shouldn't cause unnecessary re-renders. The internal hook we use (`useElementSize`) also now receives an `enabled` option such that we only run this `requestAnimationFrame` loop when the component is enabled. For components such as the `Combobox`, `Listbox` and `Menu` that means that we only start measuring when the corresponding dropdown is in an open state. Hopefully we can fix this kind of issue with an Observer in the future (e.g.: `PerformanceObserver` with `LayoutShift` (https://developer.mozilla.org/en-US/docs/Web/API/LayoutShift)) but this is still experimental today. Fixes: #3612 Fixes: #3598
1 parent 0f27e7f commit c9bf352

File tree

6 files changed

+31
-19
lines changed

6 files changed

+31
-19
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Support `<summary>` as a focusable element inside `<details>` ([#3389](https://github.com/tailwindlabs/headlessui/pull/3389))
1515
- Fix `Maximum update depth exceeded` crash when using `transition` prop ([#3782](https://github.com/tailwindlabs/headlessui/pull/3782))
1616
- Ensure pressing `Tab` in the `ComboboxInput`, correctly syncs the input value ([#3785](https://github.com/tailwindlabs/headlessui/pull/3785))
17+
- Ensure `--button-width` and `--input-width` have the latest value ([#3786](https://github.com/tailwindlabs/headlessui/pull/3786))
1718

1819
## [2.2.7] - 2025-07-30
1920

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,8 +1337,8 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
13371337
style: {
13381338
...theirProps.style,
13391339
...style,
1340-
'--input-width': useElementSize(inputElement, true).width,
1341-
'--button-width': useElementSize(buttonElement, true).width,
1340+
'--input-width': useElementSize(visible, inputElement, true).width,
1341+
'--button-width': useElementSize(visible, buttonElement, true).width,
13421342
} as CSSProperties,
13431343
onWheel: activationTrigger === ActivationTrigger.Pointer ? undefined : handleWheel,
13441344
onMouseDown: handleMouseDown,

packages/@headlessui-react/src/components/listbox/listbox.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -794,7 +794,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
794794
style: {
795795
...theirProps.style,
796796
...style,
797-
'--button-width': useElementSize(buttonElement, true).width,
797+
'--button-width': useElementSize(visible, buttonElement, true).width,
798798
} as CSSProperties,
799799
...transitionDataAttributes(transitionData),
800800
})

packages/@headlessui-react/src/components/menu/menu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -581,7 +581,7 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
581581
style: {
582582
...theirProps.style,
583583
...style,
584-
'--button-width': useElementSize(buttonElement, true).width,
584+
'--button-width': useElementSize(visible, buttonElement, true).width,
585585
} as CSSProperties,
586586
...transitionDataAttributes(transitionData),
587587
})

packages/@headlessui-react/src/components/popover/popover.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -851,7 +851,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
851851
style: {
852852
...theirProps.style,
853853
...style,
854-
'--button-width': useElementSize(button, true).width,
854+
'--button-width': useElementSize(visible, button, true).width,
855855
} as React.CSSProperties,
856856
...transitionDataAttributes(transitionData),
857857
})

packages/@headlessui-react/src/hooks/use-element-size.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useMemo, useReducer } from 'react'
1+
import { useState } from 'react'
2+
import { disposables } from '../utils/disposables'
23
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
34

45
function computeSize(element: HTMLElement | null) {
@@ -7,26 +8,36 @@ function computeSize(element: HTMLElement | null) {
78
return { width, height }
89
}
910

10-
export function useElementSize(element: HTMLElement | null, unit = false) {
11-
let [identity, forceRerender] = useReducer(() => ({}), {})
12-
13-
// When the element changes during a re-render, we want to make sure we
14-
// compute the correct size as soon as possible. However, once the element is
15-
// stable, we also want to watch for changes to the element. The `identity`
16-
// state can be used to recompute the size.
17-
let size = useMemo(() => computeSize(element), [element, identity])
11+
export function useElementSize(enabled: boolean, element: HTMLElement | null, unit = false) {
12+
let [size, setSize] = useState(() => computeSize(element))
1813

1914
useIsoMorphicEffect(() => {
2015
if (!element) return
16+
if (!enabled) return
17+
18+
let d = disposables()
19+
20+
// requestAnimationFrame loop to catch any visual changes such as a
21+
// `transform: scale` which wouldn't trigger a ResizeObserver
22+
d.requestAnimationFrame(function run() {
23+
d.requestAnimationFrame(run)
24+
25+
setSize((current) => {
26+
let newSize = computeSize(element)
27+
28+
if (newSize.width === current.width && newSize.height === current.height) {
29+
// Return the old object to avoid re-renders
30+
return current
31+
}
2132

22-
// Trigger a re-render whenever the element resizes
23-
let observer = new ResizeObserver(forceRerender)
24-
observer.observe(element)
33+
return newSize
34+
})
35+
})
2536

2637
return () => {
27-
observer.disconnect()
38+
d.dispose()
2839
}
29-
}, [element])
40+
}, [element, enabled])
3041

3142
if (unit) {
3243
return {

0 commit comments

Comments
 (0)