How does position: sticky behave differently from fixed, and what are its common failure cases?

Advanced15 min interview
Skills tested:
Describing sticky as a hybrid of relative and fixedKnowing a threshold offset is mandatoryIdentifying overflow on an ancestor as the most common failure causeExplaining why parent height determines the sticky travel rangeContrasting sticky with a scroll-listener fixed implementation

Advertisement

🧩 Scenario

Sticky is the correct implementation for pinned section headers, sticky table headers, and sidebars that follow the scroll, and it replaces a whole category of scroll-listener JavaScript. But it fails silently: there is no error, no console warning, and no visible difference between a correctly written sticky element and one disabled by an overflow: auto on a wrapper eight levels up. Knowing the exact preconditions is what makes it debuggable rather than a coin flip.

Architecture Walkthrough

A Hybrid, Not a Variant of Fixed

position: sticky is best understood as two behaviours in one element, switching at a scroll threshold.

Before the threshold is reached, the element behaves exactly like position: relative: it sits in normal flow, reserves its space, and its offsets are measured from its own original position. Once the threshold is crossed relative to the nearest scrolling ancestor, it behaves like position: fixed, pinned at the offset you specified.

Two properties follow from this and they are the real differences from fixed.

First, a sticky element never leaves normal flow. It keeps reserving its space for the entire time, which is why engaging stickiness causes no layout shift. The old pattern of listening to scroll and swapping an element to position: fixed removes it from flow at the moment of the swap, collapsing the space behind it and jolting the page, which is why implementations had to add a compensating spacer element.

Second, a sticky element is bounded by its parent. It can only travel within the parent's content box. When the parent scrolls out of view, the sticky child goes with it and unsticks. fixed has no such bound; it stays pinned to the viewport indefinitely regardless of what happens to its ancestors. This bounding is exactly what makes per-section sticky headers work: each header pins while its own section is on screen and hands off as the next section arrives.

Failure Case 1: No Threshold Offset

position: sticky with no top, right, bottom, or left never sticks. The threshold is defined by the offset, so with none specified there is nothing to cross and the element behaves as position: relative forever.

This is the most common beginner mistake and it produces no error of any kind. position: sticky; top: 0 is the minimum viable declaration.

Failure Case 2: Overflow on an Ancestor

This is the failure that wastes the most time. If any ancestor between the sticky element and its intended scroll container has overflow set to hidden, scroll, auto, or clip, that ancestor becomes the sticky element's scrolling container. If that ancestor does not itself scroll, there is no scroll for the element to respond to and stickiness silently does nothing.

The reason this is so common is that overflow: hidden is added for entirely unrelated purposes: clearing floats in legacy code, suppressing a horizontal scrollbar, clipping a decorative element, or containing a margin collapse. Nobody connects that declaration to a sticky element several levels down.

The diagnosis is mechanical: walk every ancestor up to the scroll container and check computed overflow on both axes. Note that setting overflow-x: hidden also computes overflow-y to auto in many cases, so a horizontal-only fix can still create a vertical scroll container. The fixes are to remove the overflow, to move the sticky element outside the clipping ancestor, or to use overflow: clip with overflow-clip-margin where clipping is genuinely required, since clip does not create a scroll container the way hidden does in modern engines.

Failure Case 3: No Room to Travel

A sticky element can only move within its parent's content box. If the parent is exactly as tall as the sticky element, there is zero travel range and the element appears not to stick at all, even though everything is technically working.

This happens most often with sticky sidebars. A flex row with default align-items: stretch makes both columns the same height, so the sidebar's parent is exactly the sidebar's height and there is no range. The fix is align-self: start on the sidebar so it takes only its content height inside a taller parent. The same bug appears with height: 100% chains and with grid items that stretch by default.

A related symptom is an element that sticks for a few pixels and then unsticks: that is a parent whose height barely exceeds the sticky child's, giving a very short travel range.

Failure Case 4: The Sticky Element Is Clipped or Layered Wrongly

Once pinned, a sticky element paints over the content scrolling beneath it, which means it needs a z-index high enough to sit above that content, and it needs a background, since content will otherwise show through. And because position: sticky always creates a stacking context, its own z-index is scoped: descendants of the sticky element cannot escape it, which occasionally surprises people building dropdowns inside sticky headers.

Sticky Table Headers

Sticky table headers deserve a specific mention because they were historically inconsistent. Applying position: sticky to <thead> or <tr> was not supported in older engines; the reliable approach was to make the <th> cells sticky individually. Modern browsers support sticky on thead and tr, but the per-cell approach remains the safest and is still what most component libraries ship. Border collapsing also interacts badly with sticky headers, so border-collapse: separate with border-spacing: 0 is the usual accompaniment.


Key Code Explained

/* Minimum viable sticky: the threshold offset is REQUIRED */
.section-header {
  position: sticky;
  top: 0;
  z-index: 10;      /* must paint above the content scrolling under it */
  background: #fff; /* otherwise content shows through */
}

/* FAILURE 1: no offset -> never sticks, behaves as relative forever */
.never-sticks {
  position: sticky; /* no top/right/bottom/left */
}

/* FAILURE 2: overflow on ANY ancestor becomes the scroll container */
.wrapper {
  overflow-x: hidden; /* added to kill a horizontal scrollbar...
                         ...and it silently disables sticky descendants */
}
/* Fix A: remove it. Fix B: use clip, which does not create a scroll container. */
.wrapper-fixed {
  overflow-x: clip;
}

/* FAILURE 3: no travel room. Flex stretch makes parent == child height. */
.layout {
  display: flex;
  gap: 24px;
  /* align-items defaults to stretch */
}
.sidebar {
  position: sticky;
  top: 24px;
  align-self: start; /* THE FIX: take content height, leave travel range */
}

/* Sticky within a scrolling container: the container must actually scroll */
.scroll-area {
  max-height: 400px;
  overflow-y: auto; /* this IS the scroll container — correct here */
}
.scroll-area .group-label {
  position: sticky;
  top: 0;
  background: #fff;
}

/* Sticky table header: make the cells sticky, not the thead */
.data-table {
  border-collapse: separate;
  border-spacing: 0;
}
.data-table th {
  position: sticky;
  top: 0;
  z-index: 1;
  background: #f8fafc;
}

/* Sticky footer bar inside a form panel */
.actions {
  position: sticky;
  bottom: 0;
}

The .layout and .sidebar pair is the most valuable example here, because it is a failure with no visible cause. Every declaration on .sidebar is correct, the ancestors have no overflow, and yet nothing sticks. The culprit is a default: align-items: stretch on the flex parent makes the sidebar's containing block exactly its own height, leaving zero travel range. align-self: start is a one-line fix that looks unrelated to stickiness.

The .wrapper example is the other one to memorise, because the causal distance is so large. A horizontal-scrollbar fix on a top-level wrapper disables a sticky header nested eight levels below it, with no error and no devtools warning. overflow: clip is the modern escape hatch when clipping is genuinely needed, since it clips without establishing a scroll container.


Tradeoffs

Aspectposition: stickyposition: fixedScroll listener + fixed
Stays in flowYesNoNo, after the swap
Layout shift on engageNoneN/AYes, needs a spacer
Bounded by parentYesNoNo
Broken by ancestor overflowYesNoNo
Broken by transformed ancestorNoYesYes
Runs JavaScript on scrollNoNoYes
Per-section handoffNaturalNot possibleManual bookkeeping

What Interviewers Actually Check

  • Whether you describe sticky as a relative-to-fixed hybrid rather than a kind of fixed
  • Whether you know the threshold offset is mandatory
  • Whether you name ancestor overflow as the top failure cause and can explain the mechanism
  • Whether you understand that parent height bounds the travel range, and can give the flex-stretch example
  • Whether you can articulate why sticky avoids the layout shift that a scroll-listener implementation causes

Follow-Up Questions

  1. How does overflow: clip differ from overflow: hidden with respect to scroll containers, and why does that matter for sticky?
  2. What are scroll-driven animations and animation-timeline: scroll(), and where do they overlap with what sticky solves?
  3. How would you detect in JavaScript that a sticky element has become pinned, given there is no :stuck pseudo-class today?
  4. Why does position: sticky always create a stacking context, and what does that imply for dropdowns inside a sticky header?
  5. How do multiple sticky siblings with the same top value behave as you scroll through them, and how would you build a stacked handoff effect?

Common Candidate Mistakes

  • Writing position: sticky with no offset, so there is no threshold to cross and the element behaves as relative with no error to indicate anything is wrong
  • Adding overflow: hidden or overflow-x: hidden to an ancestor for an unrelated reason and silently converting it into the sticky element's scroll container
  • Expecting a sticky element to remain pinned after its parent scrolls away, which is fixed behaviour and not what sticky guarantees
  • Leaving a flex or grid parent at its default stretch alignment, so the sticky child's parent is exactly the child's height and there is no travel range
  • Applying sticky to <thead> and assuming universal support, when making the <th> cells sticky individually alongside border-collapse: separate is the pattern component libraries actually ship

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you explain the two phases of sticky behaviour and what triggers the switch between them?
  • Can you explain why a threshold offset is required?
  • Can you name the ancestor properties that silently disable sticky and describe the mechanism?
  • Can you explain why parent height bounds the travel range, and give the flex-stretch example?
  • Can you list the advantages of sticky over a scroll-listener plus fixed implementation?

Summary

position: sticky is a hybrid. Below its threshold it behaves as position: relative, staying in flow and reserving its space; above the threshold it pins like position: fixed. Two consequences distinguish it from fixed: it never leaves normal flow, so engaging it causes no layout shift and needs no spacer element, and it is bounded by its parent's content box, so it unsticks when the parent scrolls away. That bounding is what makes per-section header handoff work naturally.

It fails silently, which is why the preconditions matter. A threshold offset such as top: 0 is mandatory, since the offset defines the threshold. Any ancestor with overflow set to hidden, scroll, auto, or clip becomes the sticky element's scroll container, and if that ancestor does not scroll, stickiness does nothing at all. And the element can only travel within its parent's content box, so a flex or grid parent left at its default stretch alignment gives the child zero range, fixed by align-self: start.

Once pinned, a sticky element needs a z-index and an opaque background so it paints correctly over the content passing beneath it, and it always creates a stacking context that scopes its descendants. For tables, making the <th> cells sticky alongside border-collapse: separate remains the most reliable pattern. Together these rules turn sticky from an unpredictable behaviour into a mechanical checklist: offset, ancestor overflow, parent height, then layering.

Frequently Asked Questions

Why does my sticky element never stick?

The three usual causes are: no threshold offset was set, an ancestor has overflow other than visible, or the parent is exactly as tall as the sticky element so there is no room to travel.

Does sticky cause layout shift when it engages?

No. A sticky element stays in normal flow and keeps reserving its space, which is the main advantage over swapping to position: fixed on scroll.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement