<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>LoupeKit — notes: layout</title>
    <link>https://loupekit.com/blog/</link>
    <atom:link href="https://loupekit.com/blog/layout/rss.xml" rel="self" type="application/rss+xml" />
    <description>Notes on layout, from the blog that https://loupekit.com/blog/rss.xml carries in full.</description>
    <language>en</language>
    <lastBuildDate>Mon, 24 Aug 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>Why z-index: 9999 does not put your element on top</title>
      <link>https://loupekit.com/blog/z-index-9999/</link>
      <guid isPermaLink="true">https://loupekit.com/blog/z-index-9999/</guid>
      <pubDate>Mon, 24 Aug 2026 00:00:00 GMT</pubDate>
      <category>layout</category>
      <dc:creator>Ján Turský</dc:creator>
      <description>The number is almost never the problem. z-index: 9999 loses to z-index: 2 every day of the week, and it loses for a reason that is nowhere near the element you were editing. (layout, 6 min)</description>
      <content:encoded><![CDATA[<p><strong>The number is almost never the problem. <code class="font-mono text-[0.92em]">z-index: 9999</code> loses to <code class="font-mono text-[0.92em]">z-index: 2</code> every day of the week, and it loses for a reason that is <strong>nowhere near the element you were editing</strong>.</strong></p><p>Before anything else, one check that costs five seconds: <code class="font-mono text-[0.92em]">z-index</code> does nothing at all on an element whose <code class="font-mono text-[0.92em]">position</code> is <code class="font-mono text-[0.92em]">static</code>. That is the default. If the property is not being read, no value of it is going to help.</p><blockquote><p>The exception is a flex or grid child, where <code class="font-mono text-[0.92em]">z-index</code> applies <strong>without</strong> positioning. It is the one place the property works on a <code class="font-mono text-[0.92em]">static</code> element, and it is a pleasant surprise exactly once.</p></blockquote><p>Past that, raising the number stops helping, and it stops helping abruptly rather than gradually. The reason is that <code class="font-mono text-[0.92em]">z-index</code> does not sort an element against the page. It sorts it against its siblings, inside a box called a <mark class="bg-accent-light text-text">stacking context</mark>.</p><h2>The stacking context is the whole answer</h2><p>A stacking context is a self-contained painting order. Everything inside one paints together, as a unit, at whatever position its root occupies in the <em>parent</em> context. Nothing inside can paint above something outside it.</p><p>Which produces the rule that explains almost every one of these bugs: <strong>a child of a context with <code class="font-mono text-[0.92em]">z-index: 1</code> sits below a sibling of that context with <code class="font-mono text-[0.92em]">z-index: 2</code></strong> — at 9999, at 2147483647, at any number you can type.</p><p><em>the tooltip never wins, and the number is irrelevant</em></p><pre><code>.card    { position: relative; z-index: 1; }
.overlay { position: relative; z-index: 2; }

.card .tooltip { position: absolute; z-index: 9999; }

/* .tooltip paints inside .card, so it paints under .overlay.
   The only number that matters here is the 1. */</code></pre><p><em>The same tooltip at z-index: 9999, twice. The only thing changing is opacity on its parent - and opacity below 1 opens a stacking context, which puts the tooltip under an overlay it used to sit above. — <a href="https://loupekit.com/blog/z-index-9999/">runs on the page itself</a>.</em></p><p>So the element you are editing is rarely the one deciding. The question is which ancestor opened a context, and where <strong>that</strong> ancestor sits among its own siblings.</p><p>It is worth being precise about what a context does to the numbers inside it, because the mental model people carry is usually <em>a bigger number wins</em> rather than the true one, which is closer to a version scheme. <code class="font-mono text-[0.92em]">1.9999</code> is still less than <code class="font-mono text-[0.92em]">2</code>. The ancestor is the integer part.</p><h2>What creates one is a longer list than anybody remembers</h2><p>The familiar case is a positioned element with a <code class="font-mono text-[0.92em]">z-index</code> other than <code class="font-mono text-[0.92em]">auto</code>. The rest is where the surprises live:</p><dl><dt>opacity</dt><dd>Any value below 1. A hover state at <code class="font-mono text-[0.92em]">0.98</code> opens a context for the whole subtree.</dd><dt>transform</dt><dd>Any value other than <code class="font-mono text-[0.92em]">none</code> — including <code class="font-mono text-[0.92em]">translateZ(0)</code>, added for the compositor by someone not thinking about paint order at all.</dd><dt>filter, backdrop-filter</dt><dd>Any value. A blur behind a sticky header is the usual arrival route.</dd><dt>mix-blend-mode</dt><dd>Anything other than <code class="font-mono text-[0.92em]">normal</code>.</dd><dt>isolation: isolate</dt><dd>The only one on this list whose entire purpose is to do it, and therefore the only one that is never a surprise.</dd><dt>contain</dt><dd><code class="font-mono text-[0.92em]">paint</code>, <code class="font-mono text-[0.92em]">layout</code>, <code class="font-mono text-[0.92em]">strict</code> or <code class="font-mono text-[0.92em]">content</code>.</dd><dt>will-change</dt><dd>Naming any property above. A hint about performance that changes correctness.</dd><dt>position: fixed</dt><dd>On its own, with no <code class="font-mono text-[0.92em]">z-index</code> involved at all.</dd></dl><blockquote><p>This is why the bug so often arrives with a commit that has nothing to do with layering. A card gets a hover opacity, a section gets a transform, and a dropdown three levels down that worked yesterday is trapped. <mark class="bg-accent-light text-text">Nothing about the dropdown changed.</mark></p></blockquote><h2>The move that actually finds it</h2><p>Do not start at the element. Start above it and work down:</p><ol><li>Walk from the trapped element up to the root, and at every ancestor ask the one question: does this open a stacking context?</li><li>The first ancestor that does is your ceiling. Nothing below it can ever paint above something outside it.</li><li>Find where that ceiling sits among its own siblings. That comparison — not the one you were editing — is the one deciding.</li><li>Fix it there: raise the ceiling, or move the element out from under it, and put the child back to a number a person can read.</li></ol><p>Doing that by hand means reading computed styles for eight properties on every ancestor, which is precisely why it turns into raising the number instead. It is worth doing properly once, because the answer is nearly always <strong>a single ancestor with a property nobody associates with layering</strong>.</p><h2>The four fixes, worst to best</h2><dl><dt>Raise the number</dt><dd>Works only when the two elements are already in the same context, which is the case you did not have. <strong>It is the fix that produced 9999 in the first place</strong> - each round of it raises the ceiling nobody found.</dd><dt>Raise the ancestor</dt><dd>Correct, and usually a one-line change: put the <code class="font-mono text-[0.92em]">z-index</code> on the ceiling instead of on the child. The risk is that the ceiling now beats things it should not, so it moves the problem up a level rather than removing it.</dd><dt>Move the element out</dt><dd>Render the overlay as a sibling of the ceiling rather than a descendant. In a component framework that means a portal, and the reason portals exist is precisely this - not styling, <mark class="bg-accent-light text-text">paint order</mark>.</dd><dt>Use the top layer</dt><dd>For anything modal, this is the answer, and it is the only one that cannot regress. See below.</dd></dl><blockquote><p>There is a fifth thing people try - <code class="font-mono text-[0.92em]">position: fixed</code> on the trapped element - and it is worth naming because it <em>seems</em> to work. A fixed element escapes scrolling ancestors, not stacking contexts. <strong>If any ancestor has a <code class="font-mono text-[0.92em]">transform</code>, <code class="font-mono text-[0.92em]">filter</code> or <code class="font-mono text-[0.92em]">will-change</code>, the fixed element is positioned against that ancestor instead of the viewport</strong>, and the bug becomes two bugs.</p></blockquote><h2>isolation: isolate, and the one time you want more of this</h2><p>Everything above treats a stacking context as an accident. <code class="font-mono text-[0.92em]">isolation: isolate</code> is the property that creates one on purpose, and it is the right tool in one specific situation: a component whose internal layering must not leak into the page it is dropped into.</p><p><em>a component that cannot fight with the page around it</em></p><pre><code>.widget {
  isolation: isolate;
}

/* Everything inside .widget now sorts among itself.
   The page sorts .widget as one thing.
   Nothing inside can reach past it, in either direction. */</code></pre><p>That is the same mechanism causing every bug in this article, aimed deliberately. <strong>A stacking context is not a hazard; an accidental one is.</strong> The difference is whether you knew you were opening it.</p><h2>The top layer ignores all of it</h2><p>A <code class="font-mono text-[0.92em]">&lt;dialog&gt;</code> opened with <code class="font-mono text-[0.92em]">showModal()</code>, an element with the <code class="font-mono text-[0.92em]">popover</code> attribute, and anything in fullscreen do not participate in this system at all. They paint in the <mark class="bg-accent-light text-text">top layer</mark>: above every stacking context on the page, in the order they were opened.</p><p><em>no z-index anywhere, and nothing can cover it</em></p><pre><code>&lt;div popover id=&quot;menu&quot;&gt;…&lt;/div&gt;
&lt;button popovertarget=&quot;menu&quot;&gt;Open&lt;/button&gt;</code></pre><p>No <code class="font-mono text-[0.92em]">z-index</code> competes with the top layer and none is needed on it. Which makes it the real fix for the case that produces 9999 in the first place — a modal, a tooltip, a menu that has to escape whatever it is nested in. <strong>Not a bigger number. A different layer.</strong></p>]]></content:encoded>
    </item>
    <item>
      <title>What makes a page scroll sideways</title>
      <link>https://loupekit.com/blog/sideways-scroll/</link>
      <guid isPermaLink="true">https://loupekit.com/blog/sideways-scroll/</guid>
      <pubDate>Tue, 18 Aug 2026 00:00:00 GMT</pubDate>
      <category>layout</category>
      <dc:creator>Ján Turský</dc:creator>
      <description>overflow-x: hidden on body is not a fix. It is a way of not knowing which element is forty pixels too wide — and it quietly breaks position: sticky on everything inside it on the way past. (layout, 6 min)</description>
      <content:encoded><![CDATA[<p><strong><code class="font-mono text-[0.92em]">overflow-x: hidden</code> on <code class="font-mono text-[0.92em]">body</code> is not a fix. It is a way of <strong>not knowing</strong> which element is forty pixels too wide — and it quietly breaks <code class="font-mono text-[0.92em]">position: sticky</code> on everything inside it on the way past.</strong></p><p>Sideways scroll is one of the few layout bugs with a single cause. Something is wider than the viewport, or starts far enough right that its edge lands outside it. There is no cascade to reason about and no specificity to lose to. <mark class="bg-accent-light text-text">There is one element, and the work is finding it.</mark></p><p>It is also the bug most often closed without being found, because hiding it takes one line and looks identical in the screenshot.</p><h2>The handful of things that actually do it</h2><dl><dt>100vw</dt><dd>On a desktop browser with a classic scrollbar, <code class="font-mono text-[0.92em]">100vw</code> is the viewport <strong>including</strong> the scrollbar; the content box you are comparing it against is not. A full-bleed section overflows by around fifteen pixels — enough to scroll, small enough to read as a rendering fault.</dd><dt>min-width: auto</dt><dd>The default on flex and grid children, and it means <em>do not shrink below your content</em>. A long word, a wide table or an unbroken URL in a grid cell will push the whole track. Responsible for more of these than everything else here combined.</dd><dt>Fixed pixel widths</dt><dd>A <code class="font-mono text-[0.92em]">width: 480px</code> card in a 390px viewport. Obvious in review, invisible on the desktop it was written on.</dd><dt>Negative margins</dt><dd>A full-bleed trick pulling content out past the right edge, usually correct on one breakpoint and not the others.</dd><dt>Absolute positioning</dt><dd><code class="font-mono text-[0.92em]">right: -40px</code> on a decorative shape. The element is out of flow, but not out of the scroll area.</dd><dt>Unbreakable strings</dt><dd>A URL, a hash, a wallet address, a base64 blob. No spaces means no wrap opportunity, unless <code class="font-mono text-[0.92em]">overflow-wrap: anywhere</code> says otherwise.</dd></dl><p><em>Three rows in a frame. Two are sized to the content box; the middle one is sized to 100vw, which on a desktop browser includes the scrollbar the content box does not get. Fifteen pixels, and the page scrolls. — <a href="https://loupekit.com/blog/sideways-scroll/">runs on the page itself</a>.</em></p><blockquote><p>Worth knowing what does <strong>not</strong> do it: <code class="font-mono text-[0.92em]">box-shadow</code> and <code class="font-mono text-[0.92em]">outline</code> paint outside the box without contributing to scroll width. <code class="font-mono text-[0.92em]">transform: translateX</code> does contribute, even though the layout position has not moved.</p></blockquote><h2>Why the outline trick misleads</h2><p><em>the advice everybody gives</em></p><pre><code>* { outline: 1px solid red; }</code></pre><p>It works often enough to stay in circulation, and it is wrong in a specific way: <strong>it marks every element</strong>, so the offender and its dozen innocent descendants all appear to stick out together. You end up staring at the deepest one — a <code class="font-mono text-[0.92em]">&lt;span&gt;</code> inside the thing that is actually too wide — because it is the one whose red box is easiest to see.</p><p>It also cannot see anything already clipped by an ancestor with <code class="font-mono text-[0.92em]">overflow: hidden</code> set for a legitimate reason, which is exactly where these hide.</p><h2>Measure edges instead</h2><p>The reliable test is arithmetic, not colour. Compare every element's right edge against the document width, then take the <mark class="bg-accent-light text-text">shallowest</mark> element that fails:</p><p><em>the whole diagnosis, in five lines</em></p><pre><code>const limit = document.documentElement.clientWidth;

[...document.querySelectorAll('*')]
  .filter((el) =&gt; el.getBoundingClientRect().right &gt; limit)
  .slice(0, 5);

// The first result is the cause.
// Everything after it is a passenger inside the cause.</code></pre><p>The same pass catches the left side, which people forget exists. An element at a negative offset does not always produce scroll — a browser does not scroll into inline-start overflow in a left-to-right document — but <strong>it does in a right-to-left one</strong>. Which is how this ships broken in Arabic and Hebrew after passing every check in English.</p><h2>The two fixes for the two commonest causes</h2><p>Once the element is identified, the repair is nearly always one of two lines. Neither is clever, and both are worth knowing by heart.</p><p><em>full bleed without the scrollbar arithmetic</em></p><pre><code>.bleed {
  width: 100dvw;
  margin-inline: calc(50% - 50dvw);
}

/* Or skip the unit question entirely: */
.bleed-grid {
  grid-column: 1 / -1;
}</code></pre><p><em>letting a flex or grid child actually shrink</em></p><pre><code>.track &gt; * {
  min-width: 0;
}

.track pre,
.track code {
  overflow-wrap: anywhere;
}

/* min-width: auto is the default and it means
   &quot;never shrink below my content&quot;. A table, a long
   URL or a wide &lt;pre&gt; will push the whole track. */</code></pre><blockquote><p><code class="font-mono text-[0.92em]">min-width: 0</code> on flex and grid children is the single highest-value line in responsive CSS, and it is invisible until the day a long word arrives. <mark class="bg-accent-light text-text">Some teams set it globally on grid and flex children and never see this class of bug again.</mark></p></blockquote><h2>Catching it before a person does</h2><p>This bug has an unusual property among layout bugs: <strong>it is trivially detectable in CI</strong>. It is one comparison of two numbers, it needs no screenshots and no visual diffing, and it fails deterministically.</p><p><em>the assertion, at three widths</em></p><pre><code>for (const width of [320, 768, 1280]) {
  await page.setViewportSize({ width, height: 900 });
  const scrolls = await page.evaluate(
    () =&gt; document.documentElement.scrollWidth &gt;
          document.documentElement.clientWidth,
  );
  expect(scrolls, `scrolls sideways at ${width}px`).toBe(false);
}</code></pre><p>320 is the width WCAG asks a page to reflow to, so that row is a conformance check as well as a layout one. Adding those three lines is usually the last time anybody thinks about horizontal overflow in that codebase.</p><h2>When hiding it is the correct answer</h2><p>Sometimes it genuinely is: a decorative shape deliberately drawn past the edge, a carousel whose track is meant to be wider than its frame. In both cases the clip belongs on that element or its immediate container — <mark class="bg-accent-light text-text">never on <code class="font-mono text-[0.92em]">body</code> or <code class="font-mono text-[0.92em]">html</code></mark>.</p><p>The reason is not tidiness. <code class="font-mono text-[0.92em]">overflow</code> on the scroll container is what makes <code class="font-mono text-[0.92em]">position: sticky</code> stop working, and the failure surfaces somewhere else entirely, weeks later, as a header that will not stick. A clip scoped to the thing being clipped costs nothing and takes nothing else down with it.</p><blockquote><p>If you inherit a codebase with <code class="font-mono text-[0.92em]">overflow-x: hidden</code> on <code class="font-mono text-[0.92em]">body</code>, remove it before debugging anything sticky. <strong>You are not looking for a sticky bug. You are looking for the overflow it was hiding.</strong></p></blockquote>]]></content:encoded>
    </item>
  </channel>
</rss>
