026: Positioning
Learning outcomes
By the end of this lesson, you should be able to tell static, relative, absolute, fixed, and sticky positioning apart; identify the containing block an element uses; work with inset properties and z-index; keep the source order meaningful; and choose positioning when you need a genuine overlay or an anchored detail rather than as a replacement for page layout.
Prerequisites and retrieval
Bring back the ideas of normal flow, block and inline behavior, and the four box layers. Before you move a box with CSS, make sure the page already works in source order. Positioning changes a box's relationship with flow; it is not the main tool for building page columns.
Terminology
- Positioned element: An element whose position value is anything other than static. — Source: CSS Positioned Layout 3
- Inset: Offset properties (top/right/bottom/left and logical inset-*) positioning relative to the containing block. — Source: CSS Positioned Layout 3: Insets
- Containing block (official): "The containing block is the rectangle against which position offsets and percentage sizes are resolved." — Source: MDN: Layout and the containing block & CSS Positioned Layout 3
- Out of flow: Removed from normal-flow spacing because it is absolutely/fixedly positioned or floated. — Source: CSS Positioned Layout 3
- Stacking context: An isolated three-dimensional stacking group; z-index comparisons are local to it. — Source: MDN: Stacking context
z-index: Sets the stack level of positioned elements and flex/grid children. — Source: CSS Positioned Layout 3: z-index- Sticky threshold: The inset at which a sticky element begins sticking within its scroll container. — Source: CSS Positioned Layout 3: Sticky
- Out-of-flow (official): "An element is out of flow if it is floated or absolutely positioned (removed from normal flow)." — Source: CSS Display Level 3: Out of flow
Mental model: preserve a seat or float above the document
Start with the box's place in the document. static is normal positioning, so inset properties do not move it. relative keeps its original seat in flow but can shift the painted box, and it establishes a containing block for absolutely positioned descendants. absolute leaves normal flow and uses its containing block for its position. fixed normally uses the viewport. sticky starts in flow, then sticks when scrolling reaches its specified inset, while remaining constrained by its scrolling ancestor.
Positioning by itself does not mean “in front.” Painting order and stacking contexts determine which overlapping box wins. A very large z-index cannot jump out of an ancestor stacking context. Keeping the stack local and understandable is usually more useful than reaching for a larger number.
Beginner example: card badge
<article class="project-card">
<p class="project-card__badge">Featured</p>
<h2>Library finder</h2>
<p>Search opening hours and accessible routes.</p>
<a href="#">Read case study</a>
</article>
.project-card {
position: relative;
max-inline-size: 36rem;
padding: 2.75rem 1rem 1rem;
border: 1px solid rgb(203 213 225);
border-radius: 0.75rem;
background: white;
}
.project-card__badge {
position: absolute;
inset-block-start: 0.75rem;
inset-inline-end: 0.75rem;
margin: 0;
padding: 0.2em 0.6em;
border-radius: 999px;
color: rgb(30 58 138);
background: rgb(219 234 254);
font-weight: 700;
}
The card is positioned, so it becomes the badge's containing block. The badge is out of flow and therefore contributes no space of its own; the extra top padding on the card keeps it from colliding with the heading. Remove position: relative and watch the badge look for another containing block, which may make it appear a long way from the card. The logical inset properties also avoid baking a left-to-right assumption into the component.
The HTML order remains useful: “Featured” still comes before the project heading if the CSS is unavailable, and assistive technology receives that same sensible sequence.
Intermediate example: sticky section navigation
<nav class="section-nav" aria-label="Portfolio sections">
<a href="#work">Work</a>
<a href="#process">Process</a>
<a href="#contact">Contact</a>
</nav>
.section-nav {
position: sticky;
inset-block-start: 0;
z-index: 10;
padding: 0.75rem 1rem;
background: rgb(255 255 255 / 96%);
border-block-end: 1px solid rgb(203 213 225);
}
.section-nav a {
display: inline-block;
padding: 0.5rem;
}
Sticky needs an inset such as inset-block-start: 0; that inset supplies the point at which sticking begins. It follows the nearest ancestor with a scrolling mechanism, which can result from overflow: auto, scroll, or hidden, and it remains inside its containing block. overflow: clip clips paint but does not create a scrolling mechanism or scroll container. Use it when clipping is intended, not to explain which ancestor a sticky element follows. Check that sticky content does not cover focused content or fragment targets, including at high zoom, narrow widths, and with long labels.
A skip link is a legitimate use of an off-screen positioned element:
.skip-link {
position: absolute;
inset-inline-start: 1rem;
inset-block-start: 0;
transform: translateY(-150%);
padding: 0.75rem 1rem;
background: white;
color: rgb(30 64 175);
}
.skip-link:focus { transform: translateY(0); z-index: 100; }
The link stays in the keyboard path and returns to view when it receives focus. display: none would remove it from that path, defeating the purpose of a skip link.
Optional advanced example: fixed utility control
.back-to-top {
position: fixed;
inset-inline-end: 1rem;
inset-block-end: 1rem;
z-index: 20;
padding: 0.75rem;
border: 2px solid currentColor;
background: white;
}
Fixed controls can cover page content, a software keyboard, or browser UI. Add one only when it solves a real problem, verify that its target and label are clear, and keep the page usable without it. In some cases a transformed ancestor changes how fixed positioning determines its containing block.
Stacking and overlap
Two positioned elements may occupy the same area:
.project-card { position: relative; z-index: 0; }
.project-card__badge { position: absolute; z-index: 1; }
These rules create small, local levels that are easy to reason about. A positioned non-auto z-index, opacity below 1, and transform, among other properties, can create stacking contexts. When z-index: 999999 has no effect, inspect the ancestors and their contexts instead of increasing the value again.
Mistakes, debugging, and DevTools
- Absolutely positioning every section: content no longer reserves space and overlaps at different sizes.
- Forgetting a positioned ancestor: absolute offsets reference an unexpected box.
- Sticky with no inset: there is no threshold.
- Sticky inside an unintended overflow ancestor: it sticks within that ancestor.
- Using fixed headers without compensating for covered anchors and focus.
- Using
top/leftfor visual layout that Flexbox or Grid should handle. - Solving stacking with enormous arbitrary numbers: inspect stacking contexts.
- Moving focusable items visually away from DOM order: keyboard focus appears to jump.
Modern browser DevTools expose sticky and scroll-container information through badges and Layout panels. Inspect position, inset values, containing dimensions, and overflow on ancestors. Use the Elements tree to locate stacking-context triggers. Then scroll, zoom, and Tab through the page while watching the overlays; one screenshot at one width cannot reveal these failures.
Accessibility and performance
Keep visual order consistent with DOM order. At 200% and 400% zoom, overlays must not hide focused controls or text. A sticky header should remain compact enough when its text wraps. Skip links need strong focus contrast, while fixed controls need accessible names and sufficiently large targets.
Repeatedly animating top and left can trigger layout. Transforms are often smoother, but they still need restraint. For nonessential motion, honor prefers-reduced-motion. Static positioning is not itself a performance problem; unnecessary overlapping layers and visual effects can be.
Deep dive: containing blocks decide what offsets mean
It is tempting to say that absolute positioning is “relative to the nearest parent,” but that rule is incomplete. The actual reference is the containing block, established by particular ancestor and layout conditions.
Common pattern:
<article class="card">
<span class="card__badge">New</span>
...
</article>
.card {
position: relative;
}
.card__badge {
position: absolute;
inset-block-start: 0.75rem;
inset-inline-end: 0.75rem;
}
position: relative keeps the card in normal flow while establishing a containing block for the absolutely positioned badge.
Prefer logical offsets such as inset-inline-end when a component should adapt to writing direction rather than assuming that the intended relationship is always “right.”
Deep dive: each positioning mode
Static
.element {
position: static;
}
This is the default. Insets and z-index generally do not position the element as they would a positioned box.
Relative
.element {
position: relative;
inset-block-start: 0.25rem;
}
The element retains its original space in flow. Its visual offset does not cause neighboring content to reflow into the space it appears to have left.
Absolute
.element {
position: absolute;
inset: 0;
}
The element leaves normal flow and is positioned against its containing block.
Fixed
.utility {
position: fixed;
inset-inline-end: 1rem;
inset-block-end: 1rem;
}
Fixed positioning normally anchors the element to the viewport, although transforms and related properties on ancestors can change its containing-block behavior.
Sticky
.toc {
position: sticky;
inset-block-start: 1rem;
}
Sticky positioning follows normal flow until the scroll threshold is reached, then remains constrained by its scroll container and containing block.
Worked example: why sticky “does not work”
.layout {
overflow: hidden;
}
.sidebar {
position: sticky;
top: 1rem;
}
An ancestor with overflow behavior can become the scroll container or establish a clipping context for sticky positioning. The result can therefore differ from the assumption that the sidebar will stick to the viewport.
Debug it in this order:
- Inspect overflow values on the ancestors.
- Check whether the sticky box is taller than the available scroll space.
- Check whether its parent ends before the element has room to remain stuck.
- Confirm that a non-
autoinset such astop/inset-block-startis present.
Changing sticky to fixed is not a general fix. Fixed positioning has different semantics and can cover content that sticky would have left visible.
Deep dive: stacking contexts
z-index is not one global number line shared by the entire page. Each element is painted within a stacking context, and comparisons happen within that hierarchy.
Common stacking-context triggers include certain combinations of:
- positioned elements with non-auto
z-index; opacitybelow 1;- transforms;
- filters;
- isolation;
- some containment properties.
Example:
.card {
position: relative;
z-index: 10;
}
.modal {
position: fixed;
z-index: 9999;
}
A modal with a huge z-index can still lose when it is inside an ancestor stacking context that sits below a different context. The child's number is only meaningful within the context that contains it.
Worked example: stable layering scale
:root {
--z-base: 0;
--z-dropdown: 20;
--z-sticky: 30;
--z-overlay: 40;
--z-modal: 50;
--z-toast: 60;
}
.site-header {
position: sticky;
top: 0;
z-index: var(--z-sticky);
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: var(--z-overlay);
}
.modal {
position: fixed;
z-index: var(--z-modal);
}
Tokens do not repair a stacking-context bug on their own. They do document the intended layers and make random values such as 99999999 less likely.
Positioning anti-pattern: using absolute positioning for page layout
Fragile:
.sidebar {
position: absolute;
left: 70%;
top: 10rem;
width: 25%;
}
Changes in content height, zoom, localization, or responsive layout can make this overlap other content.
Prefer:
.layout {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(16rem, 1fr);
gap: 2rem;
}
Use positioning for overlays, anchored badges, sticky controls, and deliberate layers. Do not use it to replace the normal relationships that Flexbox, Grid, and flow already provide.
Stacking-debug sequence
- Identify the two elements whose overlap is wrong.
- Find the nearest stacking-context ancestor for each element.
- Compare those ancestor contexts, not merely the child
z-indexvalues. - Look for unexpected transforms, opacity, filters, or isolation.
- Remove unnecessary stacking-context triggers before raising any numbers.
Tiered exercises
Checkpoint: containing-block investigation
Build three nested boxes named outer, card, and badge. Give only outer position: relative, then absolutely position badge with inset: 0. Observe that the badge uses outer even though card is its immediate parent. Move position: relative to card and observe the reference change. This demonstrates why “absolute means relative to the parent” is incomplete: the relevant reference is the containing block established by the nearest qualifying ancestor.
Next, scroll a sticky element through a short section. It cannot stick past the boundary of its containing block. Add overflow: auto to an ancestor with a constrained height and watch the sticky element respond to that scrolling box. Remove the test declarations once you can explain the result.
For stacking, create two overlapping positioned cards. Give one child z-index: 100 inside an ancestor stacking context at level 1, and put the other ancestor at level 2. The child cannot leap above the level-2 sibling context. Inspect ancestors for transform, opacity, and positioned z-index; fixing the hierarchy is more reliable than inventing a larger number.
Finally, audit overlap at every viewport width: Tab through controls, follow in-page anchors, enlarge text, and scroll to the bottom. Fixed and sticky content must not conceal the focused item, a validation message, the footer, or the destination heading. If visibility requires complicated offsets, simplify or remove the overlay.
Use relative offsets sparingly. A relatively positioned element keeps its original flow space, so a visual move can leave a gap and paint over a neighbor. That behavior is useful for a small intentional nudge or for creating an absolute containing block, but not for rearranging sections. Transforms likewise change painting without making normal flow reserve the transformed destination.
Positioned inset values can use logical properties. inset-inline-end maps to the appropriate physical side for the writing direction, while right always means the physical right. For reusable international components, logical insets usually express the intended relationship more accurately. Test right-to-left direction when localization is a possibility.
A positioned element with z-index: auto participates in painting differently from one that establishes an explicit local level. Add z-index only when overlap requires it, and document a small scale such as base, sticky header, modal, and skip link. A modal also needs JavaScript focus management, keyboard dismissal, labeling, and background interaction control; CSS stacking alone does not make an accessible dialog.
Foundation: Add an absolute “Featured” badge anchored to a relative project card. Reserve enough card space for it.
Core: Create a sticky section navigation with a background and local stack level. Test scrolling, narrow width, and keyboard focus.
Stretch: Add a skip link hidden by transform until focus. Explain why display: none would break its purpose.
<a class="skip-link" href="#main">Skip to main content</a>
<nav class="section-nav" aria-label="Portfolio sections">...</nav>
<main id="main">
<article class="project-card"><p class="project-card__badge">Featured</p><h2>Library finder</h2></article>
</main>
.skip-link { position: absolute; inset: 0 auto auto 1rem; transform: translateY(-150%); padding: .75rem 1rem; background: white; }
.skip-link:focus { transform: translateY(0); z-index: 100; }
.section-nav { position: sticky; inset-block-start: 0; z-index: 10; padding: .75rem 1rem; background: rgb(255 255 255 / 96%); }
.project-card { position: relative; padding: 2.75rem 1rem 1rem; }
.project-card__badge { position: absolute; inset-block-start: .75rem; inset-inline-end: .75rem; margin: 0; }
Recap and exit questions
Positioning is for deliberate offsets, anchors, and overlays, not for primary page layout. Relative positioning preserves flow space; absolute and fixed positioning usually do not; sticky changes behavior at a scroll threshold while staying constrained.
- Which position values remove a box from normal flow?
- Why does an absolute badge need a positioned ancestor?
- What two conditions commonly make sticky fail?
- Why can a large
z-indexstill lose? - What accessibility checks apply to fixed and sticky content?
