diff --git a/packages/pluggableWidgets/combobox-web/CHANGELOG.md b/packages/pluggableWidgets/combobox-web/CHANGELOG.md
index cb191d46f1..2364b2cb27 100644
--- a/packages/pluggableWidgets/combobox-web/CHANGELOG.md
+++ b/packages/pluggableWidgets/combobox-web/CHANGELOG.md
@@ -8,8 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Fixed
+- We fixed an issue where the combobox menu jumped around and flickered between opening above and below the input.
+
- We fixed an issue where the combobox collapsed to 1px width when placed inside a flex row container with `align-items: center`.
+### Changed
+
+- The combobox menu now keeps a stable position and, when there is not enough space, shrinks and scrolls instead of overflowing the viewport.
+
## [2.8.1] - 2026-04-30
### Fixed
diff --git a/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js b/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js
index f9e2729342..9a366609f9 100644
--- a/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js
+++ b/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js
@@ -172,6 +172,71 @@ test.describe("combobox-web", () => {
await expect(getOptions(comboBox)).toHaveText(["Antartica", "Australia"]);
});
});
+
+ test.describe("menu positioning (floating-ui)", () => {
+ // Regression for WC-3406: the menu used to flip between above/below the input
+ // and jump when its content height changed. It must now settle in one place and,
+ // when space is tight, shrink + scroll within the viewport instead of overflowing.
+
+ test("menu does not jump (top stays stable) while open", async ({ page }) => {
+ const comboBox = page.locator(".mx-name-comboBox2");
+ await expect(comboBox).toBeVisible({ timeout: 10000 });
+
+ await comboBox.click();
+ const menu = page.locator(".mx-name-comboBox2 .widget-combobox-menu").first();
+ await expect(menu).toBeVisible();
+
+ // Let floating-ui position it, then sample the top across several frames.
+ const readTop = () => menu.evaluate(el => el.getBoundingClientRect().top);
+ const first = await readTop();
+ const samples = [];
+ for (let i = 0; i < 5; i++) {
+ await page.waitForTimeout(60);
+ samples.push(await readTop());
+ }
+
+ // No oscillation: every later sample equals the first (sub-pixel tolerance).
+ for (const top of samples) {
+ expect(Math.abs(top - first)).toBeLessThanOrEqual(1);
+ }
+ });
+
+ test("menu shrinks and stays within the viewport when space is tight", async ({ page }) => {
+ const comboBox = page.locator(".mx-name-comboBox2");
+ await expect(comboBox).toBeVisible({ timeout: 10000 });
+
+ // Shrink the viewport so there is little room below the input, forcing the
+ // size() middleware to cap the menu height.
+ await page.setViewportSize({ width: 1024, height: 360 });
+
+ await comboBox.click();
+ const menu = page.locator(".mx-name-comboBox2 .widget-combobox-menu").first();
+ await expect(menu).toBeVisible();
+
+ const box = await menu.boundingBox();
+ const viewport = page.viewportSize();
+
+ // Menu bottom stays on screen (respecting the 8px viewport padding).
+ expect(box.y + box.height).toBeLessThanOrEqual(viewport.height);
+ // Menu height is capped below the 320px default because space is limited.
+ expect(box.height).toBeLessThan(320);
+ });
+
+ test("menu width matches the input width", async ({ page }) => {
+ const comboBox = page.locator(".mx-name-comboBox2");
+ await expect(comboBox).toBeVisible({ timeout: 10000 });
+
+ const inputContainer = comboBox.locator(".widget-combobox-input-container").first();
+ await comboBox.click();
+ const menu = page.locator(".mx-name-comboBox2 .widget-combobox-menu").first();
+ await expect(menu).toBeVisible();
+
+ const inputBox = await inputContainer.boundingBox();
+ const menuBox = await menu.boundingBox();
+
+ expect(Math.abs(menuBox.width - inputBox.width)).toBeLessThanOrEqual(1);
+ });
+ });
});
function getOptions(combobox) {
diff --git a/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js-snapshots/comboBoxAssociationOpen-chromium-linux.png b/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js-snapshots/comboBoxAssociationOpen-chromium-linux.png
index 79e6efe270..e1d33e63da 100644
Binary files a/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js-snapshots/comboBoxAssociationOpen-chromium-linux.png and b/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js-snapshots/comboBoxAssociationOpen-chromium-linux.png differ
diff --git a/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js-snapshots/comboBoxEnumFooter-chromium-linux.png b/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js-snapshots/comboBoxEnumFooter-chromium-linux.png
index 07b619a52a..3b64441886 100644
Binary files a/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js-snapshots/comboBoxEnumFooter-chromium-linux.png and b/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js-snapshots/comboBoxEnumFooter-chromium-linux.png differ
diff --git a/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/.openspec.yaml b/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/.openspec.yaml
new file mode 100644
index 0000000000..c0db885c7c
--- /dev/null
+++ b/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: tdd-refactor
+created: 2026-07-09
diff --git a/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/design.md b/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/design.md
new file mode 100644
index 0000000000..03cf100387
--- /dev/null
+++ b/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/design.md
@@ -0,0 +1,94 @@
+## Test Cases
+
+
+
+### Reproduction Tests
+
+- Menu wiring uses floating-ui, not the old style hook - `useMenuStyle` is gone and the
+ wrapper is positioned via floating refs (unit)
+ - **Given**: `SingleSelection` rendered open with a mocked `useFloatingMenu`
+ - **When**: component mounts and the menu opens
+ - **Then**: the menu wrapper (`.widget-combobox-menu`) receives the floating ref and the
+ `floatingStyles` from the hook; no import of `useMenuStyle` remains in the module graph
+
+- Menu placement does not oscillate near the viewport bottom (e2e)
+ - **Given**: a Combobox positioned so there is less space below than the menu's natural
+ height, with enough options to fill the menu
+ - **When**: the menu is opened and left open
+ - **Then**: the menu settles in a single placement within one animation frame and its
+ `top` (bounding rect) does not change across subsequent frames (no flip-flop)
+
+- Menu does not jump when the option count changes while open (e2e)
+ - **Given**: an open Combobox near the viewport bottom with a text filter
+ - **When**: the user types to reduce, then clear, the number of matching options
+ - **Then**: the menu re-anchors smoothly and does not flip between above/below the input
+
+### Edge Cases
+
+- Menu shrinks and scrolls instead of overflowing when space is tight (e2e)
+ - **Given**: a Combobox with many options and limited space below it
+ - **When**: the menu opens
+ - **Then**: the menu height is capped to the available space (`maxHeight <= availableHeight`),
+ the option list scrolls internally, and the menu bottom stays within the viewport
+ (respecting the 8px padding)
+
+- Menu width matches the input width (unit + e2e)
+ - **Given**: a Combobox of a known input-container width, menu open
+ - **When**: floating-ui's `size` middleware applies
+ - **Then**: the menu wrapper width equals the reference (input container) width
+
+- Menu header and footer remain visible when the menu shrinks (e2e)
+ - **Given**: a Combobox configured with `menuHeaderContent` and `menuFooterContent`, in a
+ tight space so the menu shrinks
+ - **When**: the menu opens
+ - **Then**: header and footer are both fully visible; only the option list scrolls; footer
+ is not clipped below the fold
+
+- Menu does not flash before it is positioned (e2e)
+ - **Given**: a Combobox opened
+ - **When**: the first render occurs before floating-ui reports `isPositioned`
+ - **Then**: the menu is `visibility: hidden` until positioned (no visible jump-from-origin)
+
+### Regression Tests
+
+- alwaysOpen (`keepMenuOpen`) renders inline and does not use floating positioning (unit)
+ - **Given**: `SingleSelection` and `MultiSelection` rendered with `keepMenuOpen`
+ - **When**: the menu renders
+ - **Then**: the wrapper style is `position: relative` (inline block), it does NOT carry the
+ floating ref/`floatingStyles`, and `useFloatingMenu` is called with `open = false`
+
+- MultiSelection wires floating identically to SingleSelection (unit)
+ - **Given**: `MultiSelection` rendered open (not alwaysOpen)
+ - **When**: it mounts
+ - **Then**: reference ref is on the input container, floating ref + styles are on the menu
+ wrapper — same contract as `SingleSelection`
+
+- Lazy-loading scroll still works (unit)
+ - **Given**: a Combobox with `lazyLoading` enabled and `hasMore` options, menu open
+ - **When**: the option list is scrolled
+ - **Then**: the `onScroll` handler fires and the `widget-combobox-menu-lazy-scroll` class is
+ applied to the list — unchanged from current behavior
+
+- Existing Single/Multi/Static selection specs pass with the new mock (unit)
+ - **Given**: the existing `SingleSelection.spec`, `MultiSelection.spec`, `StaticSelection.spec`
+ - **When**: run against the floating-ui implementation with `useFloatingMenu` mocked
+ - **Then**: all previously-passing assertions still pass (menu render, item selection,
+ open/close, a11y attributes)
+
+## Notes
+
+
+
+- Real flip/shrink math is not unit-testable in jsdom (no layout engine). The no-jump and
+ shrink outcomes are the reporter's core complaint, so they are covered by real Playwright
+ e2e in `e2e/Combobox.spec.js` (stable `top` near the bottom edge, capped `maxHeight`, width
+ == input) plus the manual showcase for Takuma / Ana.
+- Transformed/`contain`/`filter` ancestor + `position: fixed` mis-anchoring is a known
+ limitation (no portal this pass) — not covered by a test, documented in proposal.
diff --git a/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/proposal.md b/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/proposal.md
new file mode 100644
index 0000000000..3cd38ff33c
--- /dev/null
+++ b/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/proposal.md
@@ -0,0 +1,77 @@
+## Why
+
+The Combobox dropdown menu jumps around while open (WC-3406). Opening the menu near the
+bottom of the viewport, or changing the number of visible options (e.g. by typing to
+filter), makes the menu flicker between rendering above and below the input instead of
+settling in one place.
+
+Reported by Roman Vyakhirev with a screen recording. The reporter also built a
+proof-of-concept branch (`feat/combobox-better-menu`) migrating the menu to floating-ui,
+which this change ports and hardens.
+
+Expected: once open, the menu picks a stable placement (below the input by default, above
+only when there genuinely isn't room) and does not oscillate when its content height
+changes.
+
+## Root Cause
+
+Menu positioning is hand-rolled in `hooks/useMenuStyle.ts`. `getMenuPosition()` chooses
+top-vs-bottom placement from the _measured_ menu height, and that same `menuHeight` is a
+dependency of the positioning `useEffect`. So: place menu → height changes → effect
+re-runs → placement recomputed from the new height → height changes again. The debounced
+`setStyle` (32ms) only smears the oscillation over time; it does not remove the feedback
+loop. There is also no re-anchoring on scroll/resize.
+
+## What Changes
+
+Replace the hand-rolled positioning with floating-ui (`@floating-ui/react`), following the
+reporter's approach but resolving the edge cases his branch left open.
+
+- **New dependency**: `@floating-ui/react` (`^0.26.27`) added to `combobox-web`.
+- **New hook** `hooks/useFloatingMenu.ts`: `useFloating` with `strategy: "fixed"`,
+ `placement: "bottom-start"`, `whileElementsMounted: autoUpdate`, and middleware
+ `offset(4)` → `flip({ crossAxis: false, fallbackStrategy: "bestFit", padding: 8 })` →
+ `size({ padding: 8, apply })`. `apply` sets the floating width to the reference width and
+ caps `maxHeight` at `min(availableHeight, 320)`. Menu stays hidden until
+ `isPositioned` to avoid a first-frame flash.
+- **Delete** `hooks/useMenuStyle.ts` (and stop importing `usePositionObserver` / `debounce`
+ for this purpose).
+- **Wire floating refs** through `SingleSelection` / `MultiSelection` →
+ `Single/MultiSelectionMenu` → `ComboboxMenuWrapper`: reference ref on the input
+ container, floating ref + `floatingStyles` on the menu wrapper.
+- **Height model (SCSS)**: `.widget-combobox-menu` becomes `display: flex; flex-direction:
+column` and is the element floating-ui height-caps. `.widget-combobox-menu-list` changes
+ from a hard `max-height: 320px` to `max-height: 100%; flex: 1; min-height: 0` so the list
+ fills the wrapper and scrolls, while header/footer share the capped height. This is what
+ makes the "shrink when space is tight" behavior work with header/footer present.
+- **Strip stale wrapper CSS**: remove `position: absolute`, `display: inline`,
+ `margin: 4px 0`, `width: 100%`, `left: unset` from `.widget-combobox-menu` — these fight
+ the inline styles floating-ui writes (the `margin` in particular caused a residual
+ offset). The 4px gap is preserved via `offset(4)`.
+- **alwaysOpen (`keepMenuOpen`) unchanged in behavior**: this mode renders inline with
+ `position: relative` and must NOT use floating positioning. Both selections call
+ `useFloatingMenu(alwaysOpen ? false : isOpen)` and the wrapper attaches no floating ref /
+ styles in the relative branch. (Fixes an inconsistency in the POC where Single passed
+ `isOpen || keepMenuOpen` and Multi passed `isOpen`.)
+
+## Impact
+
+- **Widget**: `combobox-web` only. No public prop/XML changes — purely internal positioning
+ and styling. Not breaking for app developers.
+- **Behavior change (intended)**: near a viewport edge the menu now shrinks and scrolls
+ rather than overflowing; default placement is below the input. This shrink behavior is
+ the part the reporter flagged for design review — this branch is meant to be **shown to
+ Takuma / Ana before merge**, not merged blind.
+- **Must NOT break**:
+ - `keepMenuOpen` / alwaysOpen inline rendering.
+ - Menu width still matches the input width.
+ - Lazy-loading scroll (`widget-combobox-menu-lazy-scroll`) and its scroll handler.
+ - Menu header/footer content still visible and not clipped when the menu shrinks.
+ - Existing Single/Multi/Static selection unit tests (updated to mock `useFloatingMenu`).
+- **Known limitations (documented, out of scope)**:
+ - No React portal — with `position: fixed`, a transformed/filtered/`contain` ancestor can
+ still mis-anchor the menu. Same vulnerability as the old code; not a regression.
+ - No `shift` middleware (width is pinned to the anchor, so horizontal overflow can't
+ occur in practice).
+ - RTL menu _placement_ is handled for free by `bottom-start`; pre-existing RTL item
+ styling (`margin-right`) is left untouched.
diff --git a/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/tasks.md b/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/tasks.md
new file mode 100644
index 0000000000..2001f5e874
--- /dev/null
+++ b/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/tasks.md
@@ -0,0 +1,64 @@
+## 1. Test Setup
+
+
+
+- [x] 1.1 Add `@floating-ui/react` (`^0.26.27`) to `combobox-web` package.json dependencies
+- [x] 1.2 Add `src/hooks/__mocks__/useFloatingMenu.ts` returning stable `refs.setReference` /
+ `refs.setFloating`, a marker `floatingStyles`, and `isPositioned: true`
+- [x] 1.3 Write failing unit test: `SingleSelection` open → menu wrapper gets the floating ref +
+ `floatingStyles`; `useMenuStyle` no longer imported
+- [x] 1.4 Write failing unit test: `MultiSelection` wires floating identically to Single
+- [x] 1.5 Test alwaysOpen bypass: wrapper `position: relative`, floating ref NOT attached, floating
+ styles ignored — via `ComboboxMenuWrapper.spec.tsx` (keepMenuOpen isn't wired to runtime
+ `Combobox` props, so the presentational wrapper is the correct public interface to test)
+- [x] 1.6 Regression: lazy-loading `onScroll` + `widget-combobox-menu-lazy-scroll` covered by the
+ existing (still-passing) lazy-loading specs
+
+## 2. Implementation
+
+
+
+- [x] 2.1 Create `src/hooks/useFloatingMenu.ts`: `useFloating` with `strategy: "fixed"`,
+ `placement: "bottom-start"`, `whileElementsMounted: autoUpdate`, middleware
+ `offset(4)` → `flip({ crossAxis: false, fallbackStrategy: "bestFit", padding: 8 })` →
+ `size({ padding: 8, apply })`; `apply` sets width = reference width and
+ `maxHeight = min(availableHeight, 320)`; hide until `isPositioned`
+- [x] 2.2 `ComboboxMenuWrapper`: accept `floatingRef` + `floatingStyles`; apply on the
+ `.widget-combobox-menu` div; alwaysOpen branch keeps `position: relative`, no floating ref
+- [x] 2.3 Thread props through `SingleSelectionMenu` / `MultiSelectionMenu`
+- [x] 2.4 `SingleSelection` + `MultiSelection`: call `useFloatingMenu(alwaysOpen ? false : isOpen)`,
+ set `refs.setReference` on `ComboboxWrapper`, pass `refs.setFloating` + `floatingStyles`
+ to the menu (consistent across both — fixes the POC Single/Multi inconsistency)
+- [x] 2.5 `ComboboxWrapper`: already `forwardRef` — confirm reference ref lands on the
+ input container div
+
+## 3. Refactoring / Cleanup
+
+
+
+- [x] 3.1 Delete `src/hooks/useMenuStyle.ts`; remove now-dead `usePositionObserver` / `debounce`
+ usage for menu positioning
+- [x] 3.2 SCSS `.widget-combobox-menu`: remove `position: absolute`, `display: inline`,
+ `margin: 4px 0`, `width: 100%`, `left: unset`; add `display: flex; flex-direction: column`
+- [x] 3.3 SCSS `.widget-combobox-menu-list`: `max-height: 320px` → `max-height: 100%; flex: 1;
+min-height: 0` so the list fills + scrolls within the (shrunk) wrapper
+- [ ] 3.4 Verify header/footer share the capped height and are not clipped when shrunk
+ (needs live app — part of manual verification / 4.5 showcase)
+
+## 4. Verification
+
+- [x] 4.1 All new + updated unit tests pass (`SingleSelection`, `MultiSelection`,
+ `StaticSelection` specs green with the mock) — 28 passed, 5 snapshots regenerated
+- [x] 4.2 Full `combobox-web` test suite passes; lint clean (touched files: no issues);
+ snapshots updated
+- [x] 4.3 Add Playwright e2e in `e2e/Combobox.spec.js`: menu `top` stable while open,
+ height capped + within viewport when space is tight, menu width == input width
+ (run against the live test project — not runnable in this env)
+- [x] 4.4 Add CHANGELOG.md entry under `## [Unreleased] > ### Fixed` + `### Changed`
+- [ ] 4.5 Showcase branch to Takuma / Ana for the shrink-behavior sign-off before merge
+
+## Notes
+
+- Real flip/shrink math is not unit-testable in jsdom — covered by 4.3 (e2e/manual), not units.
+- Known limitation (no portal): `position: fixed` inside a transformed/`contain`/`filter`
+ ancestor can mis-anchor. Same as old code; documented in proposal, not fixed here.
diff --git a/packages/pluggableWidgets/combobox-web/openspec/config.yaml b/packages/pluggableWidgets/combobox-web/openspec/config.yaml
new file mode 100644
index 0000000000..bd4abbf66f
--- /dev/null
+++ b/packages/pluggableWidgets/combobox-web/openspec/config.yaml
@@ -0,0 +1 @@
+schema: tdd-refactor
diff --git a/packages/pluggableWidgets/combobox-web/package.json b/packages/pluggableWidgets/combobox-web/package.json
index ea4e574d44..df8f325029 100644
--- a/packages/pluggableWidgets/combobox-web/package.json
+++ b/packages/pluggableWidgets/combobox-web/package.json
@@ -47,6 +47,7 @@
"verify": "rui-verify-package-format"
},
"dependencies": {
+ "@floating-ui/react": "^0.26.27",
"classnames": "^2.5.1",
"downshift": "^7.6.2",
"match-sorter": "^8.1.0"
diff --git a/packages/pluggableWidgets/combobox-web/src/__tests__/MultiSelection.spec.tsx b/packages/pluggableWidgets/combobox-web/src/__tests__/MultiSelection.spec.tsx
index a2d67ee5cf..a665983f33 100644
--- a/packages/pluggableWidgets/combobox-web/src/__tests__/MultiSelection.spec.tsx
+++ b/packages/pluggableWidgets/combobox-web/src/__tests__/MultiSelection.spec.tsx
@@ -1,4 +1,5 @@
import "@testing-library/jest-dom";
+jest.mock("../hooks/useFloatingMenu");
import { fireEvent, render, RenderResult, waitFor } from "@testing-library/react";
import { resetIdCounter } from "downshift";
import { ListValue } from "mendix";
@@ -94,6 +95,16 @@ describe("Combo box (Association)", () => {
const { container } = render(