Somewhere over the rainbow, dropdowns render outside their containers. Down here in Kansas, they get cut in half.
TL;DR An
absolute-positioned dropdown inside a scrollable modal gets cut:position: absolutecarries the panel past the modal body's padding-box edge, andoverflow-y: autoclips everything painted past it. Neither rule causes the bug alone. The fix: teleport the panel out of the scroll container and float it. This post does it in Vue (<Teleport>+ Floating UI) — every framework has the same move (e.g. React:createPortal). The Popover API and CSS anchor positioning are the platform's version of it.
Kansas
Dorothy had a four-step setup form. On the second step, the team roster, adding a new team member opens a focused modal — and there their role is picked from a dropdown: Scarecrow, Tin Man, or Lion.
It was a plain <select>. It worked. It was also ugly — gray, boxy, and the dropdown list looked like Windows 95 no matter what she did to it. MDN puts it politely: the <select> element is noted for being hard to style consistently across browsers. A fully styleable <select> is finally landing — Chromium and Safari 27 ship it, Firefox does not yet — but landing and landed in every browser your users run are different things — and Dorothy had a deadline.
The native <select> has one big advantage, though: the browser draws its dropdown as its own layer, outside your boxes — no ancestor's overflow can clip it. That is also why it is hard to restyle: the parts developers want to touch live mostly outside CSS's reach (MDN: Customizable select elements).
So Dorothy did what any developer with a design system and a deadline would do. She swapped the native <select> for a Headless UI component — a Listbox with a styled button and a beautiful, dark, rounded options panel.
<div class="relative">
<Listbox v-model="role">
<ListboxButton class="select w-full">{{ role || 'Pick a role' }}</ListboxButton>
<ListboxOptions class="absolute z-50 w-full mt-1 rounded-lg shadow-lg max-h-60 overflow-auto">
<ListboxOption v-for="r in roles" :key="r" :value="r">{{ r }}</ListboxOption>
</ListboxOptions>
</Listbox>
</div>
The relative wrapper anchors the options to the button — the standard pattern.
She opened it. It was gorgeous.
Then the tornado hit.
The Tornado
The role dropdown sat near the bottom of the modal. When it opened, only the first option was fully visible. Scarecrow showed. Tin Man was cut halfway. Lion was gone — not hidden behind something, just not there.
Dorothy checked z-index. She raised it to 9999. Nothing. She checked the DOM — all three options were rendered, sitting right where they should be. The browser just wasn't drawing them.
Think of it like a stage. The DOM is everything backstage — all three actors exist, standing in their spots. Painting is what the audience sees through the curtain. Lion was on stage — just past the curtain's edge, where nothing gets through.
┌─ modal ───────────────────────────────┐
│ Add Team Member │
│ ┌─ modal body (overflow-y: auto) ───┐ │
│ │ Name [ Ada Lovelace ] │ │
│ │ Role [ Scarecrow ▾ ] │ │
│ │ ┌─────────────────────┐ │ │
│ │ │ ✔ Scarecrow │ │ │ ← painted
│ │ │ Tin Man ▓▓▓▓▓▓▓▓▓ │ │ │ ← half-drawn
│ │════════╪═════════════════════│════│ │ ← the clipping edge
│ │ │ Lion │ │ │ ← in the DOM, never painted
│ │ └─────────────────────┘ │ │
│ └───────────────────────────────────┘ │
│ [ Cancel ] [ Add Member ] │
└───────────────────────────────────────┘
The modal body had this:
.modal-body {
max-height: 70vh;
overflow-y: auto;
}
That overflow-y: auto was the last ingredient of the tornado. (The max-height beside it is innocent — it limits the box's height but draws no clipping edge.) She didn't know it yet. But back in Kansas, Uncle Henry — who was a farmer — had a saying about the spring storms: no tornado is ever one thing; the storm is what happens when the factors line up. She had been searching for the one line to blame; the saying told her there was no one line to blame — only factors that lined up: the absolute positioning written back in Kansas, and the overflow-y: auto on the modal body. Each one alone was harmless. Together, they lined up.
The Yellow Brick Road
Remembering Uncle Henry's saying, Dorothy set off to find the Wizard — the one fix that would make the dropdown appear. Along the way she met three companions — the same three names she'd been fighting to see in that dropdown, now walking beside her — each with one piece of the answer.
The Scarecrow: "If I only had a brain (that understood overflow)"
The Scarecrow's problem was a mental model. Dorothy's too. She believed:
"
overflow: autolets the user scroll to see what doesn't fit. It reveals content. It doesn't hide anything."
Half right. Here is what the CSS spec via MDN actually says about auto:
"Overflow content is clipped at the element's padding box. When overflowing, the element box is a scroll container displaying scroll bars."
That sentence is doing more work than it sounds like. overflow: auto is two behaviors in one value:
- It draws an invisible clipping edge around the element's padding box. Anything painted outside that edge is not drawn at all.
- If the element's in-flow content is taller than the box, it adds a scrollbar so that content can be slid into view.
The first one is what people miss. auto clips. So do scroll, hidden, and the newer clip; only visible does not.
The scrollbar is a consolation prize. And the fine print — in-flow content — is the whole key, which is why the Tin Man shows up next.
The Tin Man: "If I only had a heart (in the document flow)"
In-flow just means the element takes up space in the normal layout; an absolutely positioned element is out of flow. MDN's definition of position: absolute puts it plainly:
"The element is removed from the normal document flow, and no space is created for the element in the page layout. The element is positioned relative to its closest positioned ancestor."
Read that carefully. The Witch sends two monkeys to make this clip, and each has the job the Winged Monkeys had in the book:
-
The first monkey is
position: absolute— the carrier. In the book, one squad's job was hauling prisoners to the Witch's castle — Toto and the Lion included. Our first monkey's job is the dropdown: he grabs it, pins it to the closest positioned ancestor directly below the button, and lifts it out of the flow — no room reserved, nothing pushed down. Nothing stops him — he can carry it anywhere, even past an edge. -
The second monkey is
overflow-y: autoon the modal body — the wrecker. The other squad's job was leaving wreckage: the Scarecrow's straw in a tree, the Tin Woodman dented on rocks. Our second monkey's job is the clipping: he draws the scroll area with its invisible edge, and anything painted past it is cut — dented at the edge, or gone from the painting.
Reread the Tornado and you can watch them work: the Scarecrow showed, still inside the edge; the Tin Man was dented — half-drawn at the clip line; the Lion was carried off past the edge, in the DOM and never painted. Absolute positioning places it there; overflow does the clipping. Neither one causes the problem alone — the bug needs both monkeys.
(One bug, two faces: past the edge, the browser paints nothing — the Tin Man dented, the Lion gone. And the dropdown never left: scroll the modal body and the missing half appears, because the browser counts it as part of the container's scrollable area. Same bug wearing a scrollbar — not a fix.)
But the two monkeys don't strike every time. The modal hugs its content, so when the dropdown opens as the last field, it always sits at the curtain's edge and gets cut — every resolution, every monitor. Move it up the form, with fields below it, and the same two rules cut nothing. The cut doesn't care which screen you're on; it cares where in the form the dropdown lives.
As Godstime Aburu writes in Smashing Magazine: "Clipping and positioning are separate systems. They just happen to collide in ways that look completely random until you understand both."
The Lion: "If I only had the courage (to stop guessing)"
Dorothy tried fixes. Each one felt brave. Each one turned out to be another Winged Monkey — reinforcements the Witch sent after her first two, dressed up as fixes: dramatic, helpful-looking, and still working for the Wicked Witch, not her.
Attempt 1 — overflow: visible on the modal body.
The dropdown escaped! And then the modal grew taller than the viewport and the footer buttons disappeared off the bottom of the screen. visible does not clip, but it also does not scroll. She had traded one clipping problem for a different one.
Attempt 2 — position: fixed on the dropdown.
MDN says position: fixed is positioned "relative to its initial containing block, which is the viewport." The viewport is not inside the modal body, so nothing clips it. It worked — except top: 100% now meant "100% of the viewport," and the dropdown rendered at the bottom of the screen, nowhere near its button. Doing it by hand is a real job — code like this, re-run on every scroll and resize:
const rect = buttonEl.getBoundingClientRect()
dropdownStyle.value = { top: `${rect.bottom + 4}px`, left: `${rect.left}px`, width: `${rect.width}px` }
It also has a footgun she dodged: a transformed, filtered, or contained ancestor can hijack the containing block for fixed descendants (MDN: containing block) — and the modal's open/close animation used transform: scale().
Back in the Tornado, her first move was z-index: 9999 — and this is why it was always going to do nothing: z-index only wins fights inside a stacking context. Trapped in a context that already lost, the dropdown can carry the biggest number on the page and still paint underneath everything. The fix is not a bigger number; it is finding which ancestor built the walls.
Attempt 3 — A positioning library.
Teleporting the panel out of the modal solves the clipping — and creates a new job. A panel in <body> is nobody's child: someone must measure the button, follow every scroll and resize, and cap the height underneath. Doing that by hand is Attempt 2's math all over again, so Dorothy reached for Floating UI and wrapped it, with the Teleport, in one small component. A package, a composable, and a wrapper later, the role dropdown worked.
The Lion's courage is not fighting; it is out-thinking the Witch instead of swinging at every flying monkey.
The Emerald City
Dorothy reached the Wizard. Pulled back the curtain. And found:
.modal-body {
max-height: 70vh;
overflow-y: auto;
}
The same two rules from the beginning. There was no great and powerful fix. There was a scroll container with a clipping edge, and an absolutely positioned dropdown that had been painting past it.
"Pay no attention to the man behind the curtain" is exactly what a scroll container says. The container is the curtain. Everything behind its padding-box edge is calculated, positioned, real — and never drawn.
The Ruby Slippers
Glinda's line is the whole lesson: you had the power all along — understand what each tool actually does before you reach for the next one.
For a plain text-only dropdown, the ruby slippers really were the <select> Dorothy started with: the browser draws it outside your boxes — the freedom she ended up rebuilding by hand. Teams still swap it for real reasons (MDN: hard to style consistently), and Headless UI's Listbox rebuilds the accessibility for free while adding the styling she needed. The swap was real, not a mistake.
Two more things she learned the hard way:
If you need custom styling, move the positioned ancestor. The oldest trick in the book (CSS-Tricks: Popping Hidden Overflow): the closest positioned ancestor must live outside the overflow container while still being an ancestor of the dropdown. Absolute only clips against the scroll container if the container sits between the dropdown and that ancestor.
Check whether it is actually clipping today. The dropdown got cut because it opened below the modal body's visible edge; add fields beneath it and the same absolute code stops clipping. Nothing fixed itself — the geometry did. And "it fits on my screen" proves nothing: the cut cares where in the form the dropdown lives, not which screen it's on. That is why Dorothy's team wrote the rule structurally instead of hoping the form layout stayed lucky.
What Dorothy Actually Did
The clip is structural, and the only reliable, no-surprises answer is to take the dropdown out of the scroll container's DOM tree entirely. In a Vue + Headless UI app, that means:
- Keep the
<ListboxButton>inside the form so focus,v-model, and ARIA relationships stay intact. - Render the
<ListboxOptions>through a<Teleport to="body">while the dropdown is open. - Anchor the teleported panel with
@floating-ui/vue, usingstrategy: 'fixed'— safe because the panel lives in<body>, where no transformed ancestor can hijack its containing block, and necessary because Headless UI (the 1.7.x line Dorothy's team ships) callsscrollIntoView()on the active option when the panel opens; withabsolutethe panel briefly renders at the document's origin, and that call drags the whole document through the modal'soverflow: hiddenscroll lock.
Concretely, the shared wrapper looks like this — trigger and panel stay in their Headless UI context; only the panel's mounting point changes:
<Listbox v-slot="{ open }" v-model="role">
<FloatingDropdown :open="open">
<template #trigger>
<ListboxButton class="select w-full">{{ role || 'Pick a role' }}</ListboxButton>
</template>
<template #panel="{ panelClass, panelStyle }">
<ListboxOptions static :class="panelClass" :style="panelStyle">
<ListboxOption v-for="r in roles" :key="r" :value="r">{{ r }}</ListboxOption>
</ListboxOptions>
</template>
</FloatingDropdown>
</Listbox>
The library hands you the mechanics; it does not hand you the product rules. Dorothy's team wrote three down, and each became a middleware choice:
- Never flip. A panel that stays attached to its button beats one that repositions itself.
- Never shift vertically — horizontally only, or the panel detaches from the trigger while the modal body scrolls.
-
Cap the height to the space underneath with
size(), which publishes that space as a CSS variable the panel folds into its ownmax-height.
Order matters — offset before shift — and the trigger needs its own visibility check, or the panel lingers, orphaned, after the button scrolls away.
The rule: if a dropdown opens inside a scrollable modal, teleport it and float it. Do not negotiate with overflow. Page-level dropdowns, outside any scroll container, are fine with plain absolute.
Since Dorothy Built Her Form
The platform is catching up. The Popover API is broadly supported, and CSS anchor positioning crossed Baseline in January 2026 when Firefox 147 supplied the last engine. Together they do natively, in CSS, what Dorothy's stack did in JavaScript: the panel renders in the top layer above every clip, opens below its trigger and never flips, caps its height to the space underneath, hides when the trigger scrolls away, and gets light-dismiss and Esc for free.
But every one of those features is positioning. A dropdown is two problems wearing one component, and on behavior — listbox role, arrow keys, typeahead, focus management — the platform is silent. That is the work Headless UI was doing all along, and it does not migrate to CSS: the W3C's Select-Only Combobox pattern and its listbox siblings describe what a custom select owes its users, and hand-rolling it inherits the pattern's own warning — no ARIA is better than bad ARIA.
There's No Place Like Home
And one non-CSS fact: the tools you reach for tell you what you do not understand yet. None of Dorothy's guesses was "read what overflow: auto does."
When she finally did, the answer stopped being a guess. The companions' gifts were the ruby slippers — the understanding had been there all along.
The curtain was two rules of CSS. Often, it is.
Top comments (0)