Module 5: Layout & Positioning
Positioning and Stacking: static, relative, absolute, fixed, sticky and z-index
How normal flow works, how each position value changes it, what a containing block is, and how stacking contexts decide what is on top.
Before any layout technique, there is normal flow. Block elements stack top to bottom, inline content flows left to right and wraps. Positioning is how you step outside that flow, and the rules for where an element ends up are more precise than most tutorials suggest.
The five values#
.a { position: static; } /* default: normal flow, offsets ignored */
.b { position: relative; }
.c { position: absolute; }
.d { position: fixed; }
.e { position: sticky; }
The offset properties, top, right, bottom, left, and the shorthand inset, do nothing on a static element. They only matter once you change position.
relative#
A relatively positioned element stays in the normal flow, and is then nudged from where it would have been.
.badge {
position: relative;
top: -4px;
left: 8px;
}
Its original space is preserved, so neighbors do not move. The shift is purely visual.
The main reason to use position: relative is not the offset at all. It makes the element a reference point for absolutely positioned children.
absolute#
An absolutely positioned element is removed from the normal flow. Siblings behave as if it were not there, and its offsets are measured from its containing block.
The containing block of an absolutely positioned element is the padding box of the nearest ancestor that is not static. If there is none, it is the initial containing block, which is roughly the first screenful of the page.
<div class="card">
<span class="tag">New</span>
<h3>Title</h3>
</div>
.card { position: relative; } /* becomes the reference */
.tag {
position: absolute;
top: 0.75rem;
right: 0.75rem;
}
┌──────────────── .card (position: relative) ───────┐
│ ┌──────┐ │
│ Title │ New │ │ ← top:0.75rem; right:0.75rem
│ └──────┘ │ measured from the card's padding box
└────────────────────────────────────────────────────┘
Forget position: relative on the parent and the tag flies to the corner of the page. That is the single most common absolute-positioning bug.
An absolute element that has both left and right (or top and bottom) set stretches between them. That is how you get overlays:
.overlay {
position: absolute;
inset: 0; /* top, right, bottom, left all 0 */
background: rgb(0 0 0 / 0.5);
}
To center something absolutely:
.modal {
position: absolute;
inset: 0;
width: 20rem;
height: 10rem;
margin: auto; /* auto margins soak up the free space in all directions */
}
/* or, for unknown sizes */
.modal {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
fixed#
A fixed element is positioned relative to the viewport, and stays put when you scroll.
.chat-button {
position: fixed;
right: 1rem;
bottom: 1rem;
}
There is one trap. If any ancestor has a transform, filter, perspective, or similar property, that ancestor becomes the containing block for the fixed element instead of the viewport. A modal "randomly" scrolling with its parent usually has a transformed ancestor.
sticky#
Sticky is a hybrid. The element behaves like relative until it reaches a threshold while scrolling, then behaves like fixed, but only within its parent.
.table-header {
position: sticky;
top: 0; /* required: the threshold */
}
For sticky to work:
- You must set at least one offset (
top,bottom, and so on). - The element sticks to the nearest ancestor that scrolls. If an ancestor has
overflow: hidden,autoorscroll, that becomes the scroll container, and the page scroll no longer controls it. - It never leaves its parent's box. When the parent scrolls out of view, the sticky child goes with it.
parent (tall)
┌─────────────────────────┐
│ [sticky header] ◄── sticks at top:0 while the parent is in view
│ │
│ content... │
│ │
│ header leaves with the parent's bottom edge
└─────────────────────────┘
If a sticky element does nothing, check for overflow: hidden on an ancestor first, then for missing top, and then whether the parent is as tall as the sticky element.
Stacking: z-index and stacking contexts#
When elements overlap, which one is on top? By default:
- Elements later in the HTML paint over earlier ones.
- Positioned elements paint over non-positioned ones.
z-index changes that, and it has a rule people miss. z-index only applies to positioned elements and to flex or grid items. On a plain static block, it is ignored.
The deeper idea is the stacking context. It is an isolated group. Elements inside it are ordered among themselves, and then the whole group is ordered as one unit in its parent's context.
A new stacking context is created by:
- The root
htmlelement. - A positioned element with a
z-indexother thanauto. - Elements with
opacitybelow 1, atransform,filter,perspective,clip-pathormask. - A flex or grid item with a
z-indexother thanauto. isolation: isolate.position: fixedorsticky.
root context
├── header (z-index: 1) ← creates a context
│ └── dropdown (z-index: 9999) competes only with things inside header
└── main (z-index: 10) ← creates a context
└── .toast (z-index: 50)
The dropdown has z-index: 9999, but that number is only compared with other elements inside header. At the level that matters, the browser compares header (1) against main (10). The whole header group, dropdown included, paints below main. Where the dropdown hangs over the main content, main wins.
That is why raising the z-index to 99999 does not fix things. The number only matters between siblings in the same stacking context. The fix is to find the context that traps the element, usually an ancestor with opacity, transform or a z-index, and change that.
Contain it on purpose#
.card {
isolation: isolate; /* new stacking context, no other side effects */
}
Giving a component its own context means its internal z-indexes (like z-index: 1 on a badge) cannot leak out and conflict with the rest of the page. It is the tidy way to keep z-indexes local.
Keep global layers as a short list of custom properties:
:root {
--z-dropdown: 100;
--z-sticky: 200;
--z-modal: 300;
--z-toast: 400;
}
Common mistakes#
- No
position: relativeon the parent of an absolute element. - Absolute positioning for page layout. It removes the element from the flow, so the parent collapses and nothing makes room for it. Use flex or grid.
- Expecting
z-indexon a static element to work. - A
z-indexarms race. If you reach 9999, the problem is a stacking context. - Sticky inside
overflow: hidden. - A fixed element behaving like absolute because an ancestor has a transform.
- Using
topwithposition: staticand seeing nothing happen.
Practice#
- Build a card with a badge in its top-right corner. Remove
position: relativefrom the card and note exactly where the badge goes. - Make a sticky table header. Then wrap the table in a container with
overflow: hiddenand see it fail. Fix it. - Build a modal: a fixed overlay with
inset: 0and a centered dialog. Addtransformto a parent of the overlay and watch it break, and explain why. - Construct two sibling boxes where the child with
z-index: 999still sits below the other box's child withz-index: 1. Identify the stacking contexts responsible.