Skip to content
QSWEQB
mediumConceptEntryMidSenior

How do you decide between flexbox and grid, and why does z-index so often not do what you expect?

Flexbox distributes items along one axis and lets content decide the sizes; grid defines tracks up front and places items into them, aligning in both axes. z-index looks broken because it only orders siblings inside one stacking context, and ordinary properties like transform and opacity create new ones.

4 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate decide on one axis versus two rather than on personal preference
  • Whether they can say who owns the sizing in each model, the items or the container
  • That they know wrapped flex lines do not align with one another and grid rows do
  • Whether they explain z-index as ordering within a stacking context rather than as a global depth number
  • Can they name properties other than position that silently create a stacking context

Answer

Two different questions, not two styles

The choice stops being a matter of taste once you notice the two modules answer different questions. Flexbox answers "I have some things, how should they share this one line?" — the item count may be unknown, the sizes come from the content, and the container's job is to distribute leftover space or absorb a shortfall. Grid answers "I have a shape, where do things go in it?" — the tracks exist before the content does, and items are placed into named or numbered lines whether or not anything fills them.

That is a question about who owns the sizing. In flexbox the items own it: flex-basis starts from the content, flex-grow divides the surplus, flex-shrink allocates the deficit. In grid the container owns it — you write grid-template-columns and items live inside what you declared. So a toolbar with a logo, a flexible spacer and three buttons is flexbox, because the buttons should be exactly as wide as their labels; a page shell of header, sidebar, content and footer is grid, because the regions are a fixed intention the content must fit.

The alignment difference that settles most arguments

Flex lines are independent. When flex-wrap: wrap pushes items onto a second line, that line does its own sizing, so the third item on line one and the third item on line two have no relationship and will not line up. Grid tracks are shared, so every item in column three is the same width by construction, and every item in a row shares its height.

This is why a card list looks wrong in flexbox the moment the cards hold different amounts of text, and why forcing a two-dimensional layout into flexbox produces percentage widths, negative margins and a calc() that compensates for the gap. Grid states it declaratively, and one line handles responsiveness with no media query:

.cards {
  display: grid;
  /* Fit as many >=16rem columns as there is room for, then share the rest. */
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
  gap: 1rem;
}

gap works in both modules now, so it is no longer a reason to pick one. If a child's internals must align with the parent grid's tracks — a card whose title and footer line up across the whole row regardless of body length — that is subgrid, available in all three major engines since Chrome 117 shipped it in 2023.

The genuinely mixed case is a wrapping list of chips or tags, where both the item count and each width are content-derived, so flexbox is right even though the result occupies two dimensions.

Why z-index reads as broken

z-index is not a global depth. It orders an element among its siblings within the stacking context that contains it, and stacking contexts nest. If an ancestor forms a stacking context, everything inside it is painted as a single unit at that ancestor's position in the parent order, so a descendant with z-index: 9999 cannot escape past an element that outranks its ancestor. The huge number sorts it against its siblings and nothing else.

What makes this feel arbitrary is that a stacking context is created by far more than position and z-index. The root element makes one, as does any positioned element with a z-index other than auto, any position: fixed element, any flex or grid child with a z-index, and — the ones that catch people — opacity below 1, transform, filter, perspective, clip-path, mix-blend-mode, isolation: isolate, contain: paint, and will-change naming any of those properties. So a card that fades in with an opacity transition, or a panel given transform: translateZ(0) years ago to smooth an animation, has quietly become a ceiling for every overlay rendered inside it.

Within one context the paint order is fixed, and knowing it explains most surprises: the context element's own background first, then descendants with negative z-index, then in-flow non-positioned blocks, then floats, then inline content, then positioned descendants with z-index: auto or 0, then positive z-index in ascending order. A positioned element with no z-index therefore still paints above a non-positioned sibling, which is why removing a z-index sometimes changes nothing. And on a position: static element z-index is ignored entirely, the single exception being flex and grid items, where it applies.

Escalating the number is the wrong instinct

The specific move that separates a competent answer from a fluent one is refusing to raise the number. When an overlay paints in the wrong place, the diagnostic is to walk up the DOM looking for the nearest ancestor that forms a stacking context and ask whether that element is ordered correctly against its siblings, because that is the comparison the browser is making. Then you have real options: render the overlay outside the offending subtree through a portal, promote it into the top layer with a <dialog> opened via showModal() or the popover attribute, where it paints above the page regardless of z-index, or add isolation: isolate to a subtree so its internal values are scoped. Incrementing to 999999 works until the next person does the same, at which point nobody can reason about the file.

Worth keeping separate in your head: overflow: hidden does not create a stacking context. It clips, so a dropdown that is cut off rather than painted underneath is a containment problem with a different fix, and conflating the two sends you looking in the wrong place.

Ask which context the browser is comparing in before you touch a number, because z-index sorts siblings and an ancestor's stacking context is a ceiling no descendant value can lift.

Likely follow-ups

  • A row of flex items with long unbroken text overflows its container instead of shrinking. What is happening and what do you change?
  • Build a card list that fits as many 16rem columns as the viewport allows, with no media queries.
  • A dropdown with a z-index of 9999 renders behind a card with a z-index of 2. Walk me through how you would find the cause.
  • When would you use isolation to scope a subtree instead of adjusting z-index values?

Related questions

Further reading

flexboxcss-gridstacking-contextz-indexlayout