The Dropdown Behind the Curtain: A Wizard of Oz Guide to CSS Overflow Clipping

작성자

카테고리:

← 피드로
DEV Community · Izak T · 2026-09-17 개발(SW)

Somewhere over the rainbow, dropdowns render outside their containers. Down here in Kansas, they get cut in half.

Kansas

Dorothy had a setup wizard. Three steps, a modal, and a form where a new team member picks their role 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.

The native <select> has one big advantage, though: the browser draws its dropdown as its own layer, not as part of your normal page boxes. That means no ancestor’s overflow can clip it. It is also why the native control is hard to restyle — the parts developers want to touch live mostly outside CSS’s reach. MDN’s customizable-select docs describe the same tradeoff directly: for years the best option was stripping some OS-level styling with appearance: none and styling only what CSS could touch, and even the new customizable <select> story is still landing as an experimental feature rather than a universal one (Chrome, 2025; 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.

<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>

Enter fullscreen mode Exit fullscreen mode

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. He was just outside the curtain’s edge.

The modal body had this:

.modal-body {
  max-height: 70vh;
  overflow-y: auto;
}

Enter fullscreen mode Exit fullscreen mode

That was the tornado. She didn’t know it yet.

The Yellow Brick Road

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: auto lets 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. In plain language, overflow: auto does two things at once:

  1. It draws an invisible clipping edge around the element’s padding box. Anything painted outside that edge is not drawn at all.
  2. If the element’s in-flow content is taller than the box, it also adds a scrollbar so that content can be slid into view.

That first part is the one people miss. auto clips. So does scroll. So does hidden — and so does the newer clip, which also forbids all scrolling, even programmatic. The only value that does not clip is visible, the default.

The scrollbar is a consolation prize. The browser is saying: “I drew a box. Nothing paints outside this box. But if your in-flow content is taller than the box, here’s a scrollbar so you can slide it into view.”

That phrase — in-flow content — is the whole key, and it 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.

Think of the page as a stack of boxes that the browser measures one after another. A paragraph of text is in-flow: the browser measures it, the box around it notices when the text is too tall, and if that box scrolls, the text scrolls with it.

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.”

(MDN: position)

Read that carefully. It is saying two things at once:

  1. Positioning: absolute is still anchored to a parent. top: 100% means “directly below my positioned ancestor.” Dorothy had this part right.
  2. Flow: absolute is out of flow. It takes up no space in the layout. The modal body does not reserve room for it.

That second part is why the clipping happens the way it does.

The modal body measures its in-flow content first. If that content fits inside max-height: 70vh, the scrollbar may never even appear from the form alone. Then the dropdown opens at top: 100%, painting itself below the visible edge of the modal body. But the modal body still has its clipping edge. So:

  • the dropdown is real,
  • it exists in the DOM and has a calculated position,
  • and it can still try to paint past the modal body’s edge —
  • but the modal body clips at its padding box, so the bottom of the dropdown is cut off.

A subtlety worth knowing: because the dropdown’s containing block is the position: relative wrapper inside the scroll container, the scrollable area can sometimes still include it. You may be able to scroll the modal body and find the missing half of the dropdown. That is not a fix. It is the same bug wearing a scrollbar. The user should not have to scroll a modal body to read a dropdown that opened right beneath its button.

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 a flying monkey — dramatic, and 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 in the modal clips it. It worked.

Except now top: 100% meant “100% of the viewport.” The dropdown rendered at the bottom of the screen, nowhere near its button. She had to measure the button’s position by hand and re-measure it every time anything scrolled or resized:

const rect = buttonEl.getBoundingClientRect()
dropdownStyle.value = {
  top: `${rect.bottom + 4}px`,
  left: `${rect.left}px`,
  width: `${rect.width}px`,
}

Enter fullscreen mode Exit fullscreen mode

That is a real amount of work, and it keeps going. There is also a footgun she did not hit, but could have. Certain properties on an ancestor — including transform, filter, backdrop-filter, perspective, and contain — can change the containing block for fixed descendants. A relevant will-change value can do the same (MDN explains the containing-block rules here). In plain terms: the dropdown stops anchoring to the viewport and anchors to that ancestor instead. If that ancestor also uses overflow: auto, scroll, hidden, or clip, the dropdown can be clipped again (MDN explains overflow clipping here). fixed quietly stops behaving like viewport-fixed positioning. The modal’s open/close animation used transform: scale(). One day the dropdown could have anchored to the wrong thing and nobody would have known why.

This is also why z-index: 9999 sometimes does nothing. z-index only wins fights inside a stacking context — and each context then fights the outside world as one solid block. If the dropdown is trapped inside a context that already lost, it 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.

She installed one. It did the getBoundingClientRect() math, handled scroll and resize, and flipped the dropdown upward when there was no room below. A package and a custom composable later, the role dropdown worked.

The Lion’s courage is not fighting. It is sitting down and out-thinking the Witch instead of swinging aimlessly at every flying monkey she sends.

The Emerald City

Dorothy reached the Wizard. Pulled back the curtain. And found:

.modal-body {
  max-height: 70vh;
  overflow-y: auto;
}

Enter fullscreen mode Exit fullscreen mode

The same three lines 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. The catch is that “the power” was never “use the native <select> forever” — it was “understand what each tool actually does before you reach for the next one.”

Here is what Dorothy actually needed to know, in order of how much it would have saved her.

1. The native <select> never had this problem — but it has other ones

A native <select>‘s dropdown isn’t rendered by your CSS at all. The browser draws it as a separate popup layer, outside the document’s boxes. No ancestor’s overflow can touch it. That is why it can’t be clipped — and also why it is so hard to style.

For a plain text-only dropdown, the ruby slippers really were the <select> Dorothy started with. But the reason teams swap it out is just as real: MDN notes the <select> is hard to style consistently across browsers, and the new customizable <select> path is still experimental and limited in support (Chrome, 2025). If you want a styled dropdown that matches your design system, or richer option content, the native element is not always a satisfying answer.

That is why Dorothy reached for a headless component — and accessibility was never the reason.

The native <select> ships keyboard navigation and screen-reader announcements for free. Headless UI’s Listbox rebuilds that same accessibility with ARIA, then adds what she actually needed: full styling control, richer option content, and value binding beyond plain strings.

The swap was real, not a mistake — and here is the part that closes the loop. <select> escapes clipping because the browser draws it outside your boxes. That is the same freedom <Teleport> gives a styled panel. You do not have to switch back to get it.

2. If you need custom styling, move the positioned ancestor

The oldest trick in the book — CSS-Tricks, 2014:

“In order for an absolutely positioned element to appear outside an element with overflow: hidden, the closest positioned ancestor must live outside that overflow container while still being an ancestor of the dropdown.”

Absolute clips against the scroll container only if the scroll container sits between the dropdown and its positioned ancestor. Put position: relative on something outside the scroll container, remove it from everything inside, and the dropdown anchors above the curtain.

3. If you can’t restructure, teleport — and do not hand-roll fixed math

Vue’s <Teleport> renders a component’s DOM somewhere else — typically <body> — while keeping its reactive state in place. The dropdown leaves the modal’s ancestor tree entirely. No overflow, no stacking context, no transform footgun.

Teleport does not require position: fixed. In this implementation, Floating UI uses position: absolute after the panel is moved to <body>. A fixed strategy could also work there, but it is optional. What failed earlier was leaving the panel inside the scroll container and trying to force it out with fixed alone. The important step is moving the panel outside the clipping tree; Floating UI then measures and updates its position so it stays anchored to the trigger.

This is the route Dorothy’s team actually used — teleport the panel out, keep the trigger in place, and let a positioning library handle the math. The postscript at the end walks through it concretely.

This matters in Vue especially right now. Headless UI’s React side moved to v2 with built-in anchor positioning and portal support, but @headlessui/vue is still on v1.7.23 and does not ship that same escape hatch. The Teleport + floating-ui combo is the practical equivalent we reach for instead of waiting on the framework to catch up.

4. Check whether it is actually clipping today

This is the embarrassing one. After all of it, Dorothy removed the library, put position: absolute back, and opened the modal. The dropdown had max-height: 15rem. The modal body had room. It fit.

The original clipping had happened when the form was taller and the dropdown sat lower. The layout had changed since. Clipping is structural and layout-dependent, so a fix that was right last month can be unnecessary today — which is a reason to recheck when the form changes, not a reason to assume the whole solution was imaginary.

There’s No Place Like Home

Three CSS facts, no wizardry required:

What Dorothy believed What’s true Source overflow: auto reveals content overflow: auto clips at the padding box, then offers a scrollbar for in-flow overflow MDN: overflow Absolute children are “inside” their parent Absolute is anchored to a positioned ancestor but removed from flow — so it takes up no in-flow space, though it can still paint past the container’s edge MDN: position fixed always escapes to the viewport An ancestor with transform, filter, backdrop-filter, perspective, contain, or a relevant will-change value can become its containing block MDN: containing block “just use a native <select>” is always the easy win A native dropdown avoids this clipping class of bug, but styling it fully is why teams reach for a headless component in the first place — and even the new customizable <select> path is still experimental MDN: select styling; MDN: customizable select; Chrome, 2025

And one non-CSS fact: the tools you reach for tell you what you do not understand yet. Dorothy reached for z-index, then overflow: visible, then fixed, then a library. Each was a guess about where the problem lived. None of them was “read what overflow: auto does.”

The curtain was three lines of CSS. Often, it is.

Postscript: What Dorothy Actually Did

Dorothy tried the easy fixes, the clever fixes, and the “what if the modal is smart” fixes. She proved to herself that the clip is structural — and that 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:

  1. Keep the <ListboxButton> inside the form so focus, v-model, and ARIA relationships stay intact.
  2. Render the <ListboxOptions> through a <Teleport to="body"> while the dropdown is open.
  3. Position the teleported panel with a small library like @floating-ui/vue (or hand-rolled getBoundingClientRect() math) so it stays anchored to the button, flips to avoid the viewport edge, and resizes on scroll.

This is the same strategy position: fixed wanted to be, but without the transform/filter containing-block footgun and without the modal body holding the paint permit. The dropdown lives in the body, not behind the curtain.

For Dorothy’s team, the rule became: if a dropdown opens inside a scrollable modal, teleport it and float it. Do not negotiate with overflow.

That rule has a boundary. If a dropdown lives at page level, outside any scroll container, plain absolute positioning is usually fine. The special treatment is for dropdowns that open inside a scrollable modal or panel — that is where the clipping ancestor is waiting.

원문에서 계속 ↗