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.
The position Values and the Offsets
| Class | CSS | The element… |
|---|---|---|
| static | position: static | sits in the normal flow (the default); offsets and z-index do nothing |
| relative | position: relative | stays in the flow, can be nudged, and becomes the reference box for absolute children |
| absolute | position: absolute | leaves the flow and is placed against its nearest positioned ancestor |
| fixed | position: fixed | leaves the flow and is placed against the viewport |
| sticky | position: sticky | flows 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:
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:
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:
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:
| Situation | Header's top edge |
|---|---|
| Scrolled 200px into its container | 0 (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-clip | 0 (stuck) |
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 parent | On top where they overlap |
|---|---|
| plain positioned box (no stacking context) | the blue child (z-50 beats z-10) |
the same box with isolate | the 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
| Class | CSS | Result |
|---|---|---|
| overflow-visible | overflow: visible | content spills out of the box (the default) |
| overflow-hidden | overflow: hidden | clipped; still scrollable by script; creates a scroll container |
| overflow-clip | overflow: clip | clipped; no scrolling at all; no scroll container |
| overflow-auto | overflow: auto | scrollbars only when the content is bigger |
| overflow-scroll | overflow: scroll | scrollbars always |
| overflow-x-auto, overflow-y-hidden | overflow-x: auto, overflow-y: hidden | one 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):
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:
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:
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 is | On top at the header |
|---|---|
inside a relative z-0 parent | the header (the overlay's z-50 only counts inside the parent, which has level 0) |
same, after removing z-0 from the parent | the 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
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 solutionA 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.
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.
Chapter 5 Quick Reference
relativekeeps its slot and is the reference box;absoluteleaves the flow and is placed against the nearest non-static ancestor (none: the page);fixedagainst the viewport;stickysticks 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:
relativeparent,absolute top-2 right-2child; stretched linkabsolute inset-0; centringabsolute inset-0 m-auto(needs a size) orleft-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 - Sticky fails inside
overflow-hidden/auto/scrollwrappers: remove the overflow or useoverflow-clip z-*needs a positioned element (or a flex/grid item); give sticky and fixed headersz-10+;isolate(alsotransform,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-autofor wide tables;overscroll-contain;scrollbar-thin/none(4.3) - Snap: container
snap-x snap-mandatory, itemssnap-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-noneon 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
hover:, focus-visible:, group and peer, aria-* and
data-*, and the structural variants such as first:, has-* and not-*.