Position and Stack

Tailwind CSS Fundamentals

Chapter 5 · Positioning, Stacking and Overflow

Flexbox and grid arrange things in rows and columns. Positioning is for the exceptions: a badge on the corner of a card, a header that stays at the top while you scroll, a dialog that floats above the page. These effects look simple and fail in confusing ways (a sticky header that does not stick, a dialog hidden behind the navigation), so this chapter measures the failures as well as the successes, and says what causes each.

Every example here was run
CSS is copied from Tailwind CSS 4.3.3's output. Positions, scroll results and "which element is on top" answers were measured in headless Chrome (a viewport 869px wide, the page scrolled by script). Where I describe standard CSS behaviour that I did not measure, I say so.

The position Values and the Offsets

ClassCSSThe element…
staticposition: staticsits in the normal flow (the default); offsets and z-index do nothing
relativeposition: relativestays in the flow, can be nudged, and becomes the reference box for absolute children
absoluteposition: absoluteleaves the flow and is placed against its nearest positioned ancestor
fixedposition: fixedleaves the flow and is placed against the viewport
stickyposition: stickyflows normally until it would scroll past its offset, then sticks

The offsets say how far from each edge. They use the spacing scale from chapter 2: top-0 (top: 0px), right-4 (calc(var(--spacing) * 4)), fractions left-1/2 (calc(1 / 2 * 100%)), top-full (100%), a negative -top-2, and brackets for anything else. inset-0 is all four sides at once (inset: 0px); inset-x-0 is inset-inline: 0px and inset-y-0 is inset-block: 0px; inset-1/2 and inset-px work too. For right-to-left languages there are logical versions: start-0 is inset-inline-start and end-4 is inset-inline-end.

Relative: Nudged, but Its Space Is Kept

relative top-4 left-4 moves an element 16px down and right from where it would have been, but the layout still treats it as being in its original place. Three 40px-high boxes, A, B and C, with B nudged:

<div class="h-10 w-20">A</div> <div class="relative top-4 left-4 h-10 w-20">B</div> <div class="h-10 w-20">C</div>
A at (0, 0) B at (16, 56) C at (0, 80)

B's normal position would be (0, 40); it was drawn 16px further down and right at (16, 56). C is at 80, exactly where it would be if B had not moved: B's slot is still reserved. In practice you rarely nudge things with relative. Its main job is the next one.

Absolute: Placed Against a Reference Box

An absolutely positioned element is taken out of the flow, so its neighbours close up around it, and its offsets are measured from its nearest positioned ancestor: the closest ancestor whose position is anything but static. If there is none, it falls back to the page itself. I put the same badge (absolute top-0 right-0 size-8) in two parents of the same size:

<div class="h-24 w-72"> <!-- not positioned --> <div class="absolute top-0 right-0 size-8"></div> </div> <div class="relative h-24 w-72"> <!-- the reference box --> <div class="absolute top-0 right-0 size-8"></div> </div>
parent has no position: badge right edge 869, top 0 (parent: right 328, top 220) parent is relative: badge right edge 328, top 336 (parent: right 328, top 336)

With no positioned ancestor the badge jumped to the top-right corner of the page (869px is the width of the viewport here), nowhere near its parent. With relative on the parent it sat exactly in the parent's top-right corner. That is the pattern to memorise: put relative (with no offsets) on the container, absolute plus offsets on the child. It is the recipe for badges, close buttons, captions over images, and the "stretched link" that makes a whole card clickable: an empty <a class="absolute inset-0"> inside a relative card covers the card exactly (I measured a link of 300 × 150px in a 300 × 150px card).

Centring with absolute. Chapter 4 promised a third centring recipe. There are two, and both put a 100 × 50px box at offset (250, 125) in a 600 × 300px parent, the exact centre:

<div class="relative h-[300px] w-[600px]"> <div class="absolute inset-0 m-auto h-[50px] w-[100px]"></div> </div> <div class="relative h-[300px] w-[600px]"> <div class="absolute left-1/2 top-1/2 h-[50px] w-[100px] -translate-x-1/2 -translate-y-1/2"></div> </div>
inset-0 m-auto: (250, 125) left-1/2 top-1/2 -translate: (250, 125)

The first works because an absolute box with all four offsets at 0, a fixed size and margin: auto splits the leftover space equally on every side. The second moves the box's top-left corner to the centre and then pulls it back by half its own size with translate (-translate-x-1/2 compiles to --tw-translate-x: calc(calc(1 / 2 * 100%) * -1) and translate: var(--tw-translate-x) var(--tw-translate-y)), which also works when the box's size is unknown. Transforms are chapter 11.

Fixed and Sticky

Fixed elements ignore scrolling. A fixed top-2 right-2 square had its right edge at 861px and its top at 8px; after the page was scrolled down by 300px it was still at 861 and 8, while an ordinary element on the same page had moved up by exactly 300px. Use it for things that must stay on screen: a cookie bar, a "back to top" button, a modal's backdrop. (By standard CSS rules a fixed element is relative to the viewport unless an ancestor has a transform, filter or similar, which makes that ancestor the reference instead; I did not measure that.)

Sticky is the more useful one for headers and table headings: the element flows normally, then sticks at its offset once scrolled that far. sticky top-0 inside a 600px-high container, measured as the page scrolled:

SituationHeader's top edge
Scrolled 200px into its container0 (stuck to the top of the viewport)
Scrolled until 20px of the container is left-20 (it leaves with its container: sticky never escapes its parent)
Same, but the container has overflow-hidden-200: it did not stick at all
Same, but the container has overflow-clip0 (stuck)
Sticky stops working inside overflow-hidden
A sticky element sticks relative to its nearest ancestor that can scroll. Any overflow value other than visible (including hidden, auto and scroll) makes the element a scroll container, so the header tries to stick inside that box, which does not scroll, and seems to do nothing. This is the usual reason "sticky does not work": an overflow-hidden on a wrapper added to clip something else. Remove it, or use overflow-clip, which clips in the same way but does not create a scroll container (the measured result above: 0 instead of -200). The difference is visible from script too: setting scrollTop = 50 on a 96px box with 240px of content took effect with overflow-hidden (it stayed at 50) and did nothing with overflow-clip (still 0).

Stacking: z-index and Stacking Contexts

When positioned elements overlap, z-* decides which is on top: z-0, z-10, z-20 … z-50 (z-index: 10 and so on), z-auto, a negative -z-10 (calc(10 * -1)) and anything else in brackets, z-[100]. It only has an effect on positioned elements (anything but static) and on flex and grid items. Two behaviours cause nearly all the puzzles.

1. Later positioned elements paint over earlier ones, unless z-index says otherwise. A sticky header with no z-index was covered by a relative card that scrolled up under it: at the header's position I asked the browser which element was on top and it answered the card. Adding z-10 to the header made the header the answer. So: give a sticky or fixed header a z-10 (or higher) or content with relative will slide over it.

2. z-index only competes inside a stacking context. Some elements start a new stacking context: everything inside is stacked as a group, and the group takes one place in its parent's stacking order. isolate (isolation: isolate) does exactly this, and so do a positioned element with a z-index other than auto, anything with a transform or opacity below 1, and a few others. I made a red box z-10 overlap a sibling that held a blue child with z-50, and asked which was on top where they overlapped:

Blue child's parentOn top where they overlap
plain positioned box (no stacking context)the blue child (z-50 beats z-10)
the same box with isolatethe red box: the child's z-50 only counts inside its parent

A high z-index cannot lift an element out of its parent's stacking context. If something stays hidden behind another thing however big its z-index, look at its ancestors for isolate, transform, opacity, a z-* on a positioned parent, and so on. Used deliberately, isolate is useful: it stops a component's internal z-indexes from leaking out and fighting the rest of the page.

Overflow, Scrolling and Snapping

ClassCSSResult
overflow-visibleoverflow: visiblecontent spills out of the box (the default)
overflow-hiddenoverflow: hiddenclipped; still scrollable by script; creates a scroll container
overflow-clipoverflow: clipclipped; no scrolling at all; no scroll container
overflow-autooverflow: autoscrollbars only when the content is bigger
overflow-scrolloverflow: scrollscrollbars always
overflow-x-auto, overflow-y-hiddenoverflow-x: auto, overflow-y: hiddenone axis at a time

Measured on a 96px-high box with 240px of content: overflow-hidden reported scrollHeight 240 and clientHeight 96 (the content is there, just clipped). overflow-auto took 15px of width for a scrollbar (offsetWidth 192, clientWidth 177) when the content was taller, and none (192 and 192) when it was short; the scrollbar's width depends on the browser and operating system, and this was Chrome on Windows. The common use of overflow-x-auto is a wide table or code block inside a narrow column, so the box scrolls rather than the whole page. Also useful: overscroll-contain (overscroll-behavior: contain) stops a scrolled-to-the-end list from scrolling the page behind it, and in 4.3 scrollbar-thin, scrollbar-none and scrollbar-auto set scrollbar-width.

Scroll snapping makes a scrolling row (a carousel) settle neatly on items. The container gets snap-x snap-mandatory (scroll-snap-type: x var(--tw-scroll-snap-strictness) and mandatory) and each item gets snap-start (scroll-snap-align: start; there are also snap-center and snap-end):

<div class="flex w-[400px] overflow-x-auto snap-x snap-mandatory"> <div class="w-[300px] shrink-0 snap-start">1</div> <div class="w-[300px] shrink-0 snap-start">2</div> <div class="w-[300px] shrink-0 snap-start">3</div> <div class="w-[300px] shrink-0 snap-start">4</div> </div>
scrolling to 120px: with snap-mandatory 0 without snap 120 scrolling to 200px: with snap-mandatory 300 without snap 200

I scrolled the row to 120px and to 200px by script: the snapping row ended at the start of the nearest item (0 and 300), the plain one stayed where it was. snap-proximity is the gentler version that only snaps when you are close. Two related classes: scroll-smooth animates scrolling to anchors (scroll-behavior: smooth), and scroll-mt-16 (scroll-margin-top) leaves room above a link target so a sticky header does not cover it; scroll-p-4 sets scroll-padding on the scrolling container.

Images, Hidden Text and Click-Through

Fitting an image to a box. The object-* classes set object-fit: object-cover fills the box and crops the overflow, object-contain shows the whole image with empty space, object-fill stretches it, object-none keeps its size, and object-scale-down picks the smaller of none and contain. object-center, object-top and object-bottom choose which part of a cropped image stays visible. The usual card image is aspect-video w-full object-cover (chapter 4 for the ratio). I confirmed the CSS each class generates (for example object-fit: cover) but did not measure how an image crops.

Text for screen readers only. sr-only hides something visually but leaves it available to assistive technology: an icon-only button needs a name. Its generated CSS is a 1px box that is clipped away:

<button><svg …></svg><span class="sr-only">Close dialog</span></button>
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border-width: 0; } measured: 1 x 1px, position absolute, clip-path inset(50%) .not-sr-only measured: position static, clip-path none, normal size

not-sr-only undoes it, which is how a "skip to content" link (hidden until it receives keyboard focus) is built: sr-only focus:not-sr-only (variants are chapter 6).

Clicks passing through. A decorative overlay on top of a button swallows the clicks. I put a translucent overlay over a button and asked which element is at the button's corner: without pointer-events-none the answer was the overlay; with it, the button. Put pointer-events-none on anything purely decorative that sits over something clickable (pointer-events: none), and pointer-events-auto on a child that must be clickable again.

A Modal Overlay, and Why It Gets Trapped

The standard modal is a full-screen backdrop with the dialog centred on it:

<div class="fixed inset-0 z-50 grid place-items-center bg-black/50"> <div class="h-32 w-64 bg-white">dialog</div> </div>
viewport 869 x 605 overlay: left 0, top 0, 869 x 605 (exactly the viewport) dialog centre (434.5, 302.5) (exactly the viewport's centre)

fixed inset-0 stretches the overlay over the viewport, z-50 puts it above ordinary content, and grid place-items-center (chapter 4) centres the dialog. Now the trap. A fixed header with z-40 sits at the top of the page, and the modal's markup lives inside a container with relative z-0 (a stacking context). I asked which element was on top at the header's position:

Where the overlay's markup isOn top at the header
inside a relative z-0 parentthe header (the overlay's z-50 only counts inside the parent, which has level 0)
same, after removing z-0 from the parentthe overlay (the parent is no longer a stacking context)
moved to be a direct child of <body>the overlay

That is why component libraries render dialogs and menus at the very end of <body> (often called a portal): out there, nothing can trap them under a parent's stacking context. If you must keep the dialog in place, make sure no ancestor starts a stacking context with a lower level than the things you want to cover.

Hands-On Exercises

Exercise 1

Build a 300 × 150px card with a small red badge in its top-right corner (8px from each edge) and a link that stretches over the whole card. Measure the badge's distance from the card's corner. Then remove the one class that makes the badge belong to the card, and say where the badge goes.

📄 View solution
Exercise 2

A page wrapper has overflow-hidden and a sticky header that does not stick. Measure the header's top edge after scrolling 200px into the wrapper, fix it without losing the clipping, and measure again. Then show that a relative card scrolling under the sticky header covers it, and fix that with one class.

📄 View solution
Exercise 3

Build a modal overlay (fixed inset-0 z-50, centred dialog) inside a parent that has relative z-0, with a z-40 fixed header outside it. Show that the header is on top of the overlay, then fix it two different ways, proving each with the element found at the top of the screen. Confirm the overlay fills the viewport and the dialog is centred.

📄 View solution

Chapter 5 Quick Reference

  • relative keeps its slot and is the reference box; absolute leaves the flow and is placed against the nearest non-static ancestor (none: the page); fixed against the viewport; sticky sticks at its offset but never leaves its parent
  • Offsets: top-0 right-4 left-1/2 -top-2, inset-0, inset-x-0, start-0 end-4
  • Pattern: relative parent, absolute top-2 right-2 child; stretched link absolute inset-0; centring absolute inset-0 m-auto (needs a size) or left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
  • Sticky fails inside overflow-hidden/auto/scroll wrappers: remove the overflow or use overflow-clip
  • z-* needs a positioned element (or a flex/grid item); give sticky and fixed headers z-10+; isolate (also transform, opacity<1, a z-indexed positioned parent) makes a stacking context that traps children's z-index
  • Overflow: hidden (clips, still scrollable by script), clip (clips, no scroll container), auto (scrollbar only if needed), scroll; overflow-x-auto for wide tables; overscroll-contain; scrollbar-thin/none (4.3)
  • Snap: container snap-x snap-mandatory, items snap-start shrink-0; scroll-smooth, scroll-mt-16
  • object-cover/contain/fill/none/scale-down, object-center/top; sr-only / not-sr-only; pointer-events-none on decorative overlays
  • Modal: fixed inset-0 z-50 grid place-items-center bg-black/50; render it at the end of <body> so no parent's stacking context traps it
Coming next
Tailwind CSS Fundamentals 6 is state variants: hover:, focus-visible:, group and peer, aria-* and data-*, and the structural variants such as first:, has-* and not-*.