<?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>Tue, 22 Sep 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>Why srcset keeps picking the wrong image</title>
      <link>https://loupekit.com/blog/srcset-picks-the-wrong-image/</link>
      <guid isPermaLink="true">https://loupekit.com/blog/srcset-picks-the-wrong-image/</guid>
      <pubDate>Tue, 22 Sep 2026 00:00:00 GMT</pubDate>
      <category>layout</category>
      <dc:creator>Ján Turský</dc:creator>
      <description>A srcset with four good widths on it will still fetch the largest one on a phone, and the attribute doing that is not srcset. sizes is the half that decides, it is guessed far more often than it is measured, and its default is the worst value it can hold. (layout, 4 min)</description>
      <content:encoded><![CDATA[<p><strong>A <code class="font-mono text-[0.92em]">srcset</code> with four good widths on it will still fetch the largest one on a phone, and the attribute doing that is not <code class="font-mono text-[0.92em]">srcset</code>. <strong><code class="font-mono text-[0.92em]">sizes</code> is the half that decides</strong>, it is guessed far more often than it is measured, and its default is the worst value it can hold.</strong></p><p>The selection happens <strong>before the page has a layout</strong>. A preload scanner reads the raw HTML while the parser is still working and the stylesheet has not necessarily arrived, so at the moment the browser chooses a file it does not know how wide the image will be drawn - and it will not go back and check.</p><p><code class="font-mono text-[0.92em]">sizes</code> is how you tell it in advance. It is not a hint and it is not documentation: it is the width the browser uses in the arithmetic, and if it disagrees with the CSS then the CSS is the thing that loses.</p><blockquote><p>Leave <code class="font-mono text-[0.92em]">sizes</code> off entirely and the browser uses <code class="font-mono text-[0.92em]">100vw</code>. Every image on the page is then declared full-bleed, whatever it looks like - which is why a sidebar thumbnail can arrive as a 1600px file.</p></blockquote><h2>The arithmetic, in the order it runs</h2><ol><li><strong>Resolve <code class="font-mono text-[0.92em]">sizes</code>.</strong> The media conditions are read left to right and the first one that matches wins - the rest are not consulted, the same rule <code class="font-mono text-[0.92em]">&lt;picture&gt;</code> uses.</li><li><strong>Multiply by device pixel ratio.</strong> A 400px box on a 2× screen needs an 800px file.</li><li><strong>Pick a candidate.</strong> The smallest <code class="font-mono text-[0.92em]">w</code> in the <code class="font-mono text-[0.92em]">srcset</code> that is at least that number. If none reaches it, the largest one.</li></ol><p>Three consequences follow from that and each of them is a bug somebody has shipped. A <code class="font-mono text-[0.92em]">sizes</code> that is too large is expensive on <strong>every</strong> device, because step 3 rounds up from an inflated number. A <code class="font-mono text-[0.92em]">sizes</code> that is too small is worse than too large on a retina screen, because a blurry image is visible in a way a wasted 900kB is not. And a candidate list with no width above the computed need means the largest file, on the smallest phone.</p><h2>The four ways it is written wrong</h2><dl><dt>A percentage</dt><dd><code class="font-mono text-[0.92em]">sizes=&quot;50%&quot;</code> is invalid and is discarded, which puts you back on <code class="font-mono text-[0.92em]">100vw</code>. Percentages of what? There is no containing block yet. Use <code class="font-mono text-[0.92em]">vw</code>, <code class="font-mono text-[0.92em]">px</code>, <code class="font-mono text-[0.92em]">em</code> or a <code class="font-mono text-[0.92em]">calc()</code> of them.</dd><dt>The CSS width, copied</dt><dd><code class="font-mono text-[0.92em]">max-width: 800px</code> on an image drawn at half the viewport is <code class="font-mono text-[0.92em]">sizes=&quot;(min-width: 1600px) 800px, 50vw&quot;</code>, not <code class="font-mono text-[0.92em]">sizes=&quot;800px&quot;</code>. The breakpoint in <code class="font-mono text-[0.92em]">sizes</code> is the viewport width at which the cap starts binding, and it is arithmetic rather than one of your design breakpoints.</dd><dt>Mixed descriptors</dt><dd><code class="font-mono text-[0.92em]">srcset=&quot;a.jpg 2x, b.jpg 800w&quot;</code> is one list with two grammars in it. Pick one: <code class="font-mono text-[0.92em]">x</code> descriptors ignore <code class="font-mono text-[0.92em]">sizes</code> completely and are for a fixed-size image, <code class="font-mono text-[0.92em]">w</code> descriptors are for one that responds.</dd><dt>Forgetting the container</dt><dd>An image inside a <code class="font-mono text-[0.92em]">max-width: 72ch</code> column is not <code class="font-mono text-[0.92em]">100vw</code> on a wide screen and it is not <code class="font-mono text-[0.92em]">50vw</code> on a narrow one. This is the case <code class="font-mono text-[0.92em]">sizes</code> models worst, and the one worth measuring rather than deriving.</dd></dl><p><em>a half-width image capped at 800px, written out</em></p><pre><code>&lt;img
  src=&quot;/img/hero-800.jpg&quot;
  srcset=&quot;/img/hero-480.jpg 480w,
          /img/hero-800.jpg 800w,
          /img/hero-1200.jpg 1200w,
          /img/hero-1600.jpg 1600w&quot;
  sizes=&quot;(min-width: 1600px) 800px, 50vw&quot;
  width=&quot;800&quot; height=&quot;450&quot;
  alt=&quot;&quot;&gt;</code></pre><p><code class="font-mono text-[0.92em]">width</code> and <code class="font-mono text-[0.92em]">height</code> are on it for a different reason and are not optional either: without them the box has no aspect ratio until the bytes land, and the layout shift that follows is the one thing on this page a visitor actually notices.</p><h2>Why the number never looks wrong in DevTools</h2><p>Two behaviours hide it. A browser that has already downloaded the 1600px file will keep using it after a resize rather than fetching something smaller - going up is a visible improvement, going down is a visible regression, so <strong>re-selection is one-way</strong>. And a desktop screen at 2× reaches the largest candidate honestly, which means the machine you are testing on is the one machine where the bug is invisible.</p><p>The measurement that settles it is the file the page actually fetched against the box it was actually drawn in, taken on a real viewport rather than in a resized window. Those two numbers are not printed together anywhere in a browser's own tooling, and the ratio between them is the whole finding.</p><blockquote><p><code class="font-mono text-[0.92em]">sizes=&quot;auto&quot;</code> moves the arithmetic to after layout and gets it exactly right. It requires <code class="font-mono text-[0.92em]">loading=&quot;lazy&quot;</code> on the same element - which excludes anything above the fold, so the images that cost the most are the ones it cannot help with. Check support before it is your only <code class="font-mono text-[0.92em]">sizes</code>.</p></blockquote><h2>What to do about it on Monday</h2><ul><li>Write <code class="font-mono text-[0.92em]">sizes</code> from the <strong>drawn</strong> width, not the intrinsic one, and keep the media condition in viewport terms.</li><li>Cap the candidate list at twice the largest drawn width. A 1600px file for an 800px box covers 2×; a 2400px one covers nobody.</li><li>Test at 1× on a narrow viewport with a cold cache. That is the only configuration where a wrong <code class="font-mono text-[0.92em]">sizes</code> is visible.</li><li>Check the ratio of bytes fetched to pixels drawn, per image, rather than the total page weight - one bad <code class="font-mono text-[0.92em]">sizes</code> on a repeated thumbnail outweighs everything else on the page.</li></ul><p>The first three are worth doing by hand. The fourth is a number, it is different on every breakpoint, and it is the reason this note has a calculator attached to it.</p>]]></content:encoded>
    </item>
    <item>
      <title>Why your sticky header stops sticking</title>
      <link>https://loupekit.com/blog/sticky-header/</link>
      <guid isPermaLink="true">https://loupekit.com/blog/sticky-header/</guid>
      <pubDate>Tue, 01 Sep 2026 00:00:00 GMT</pubDate>
      <category>layout</category>
      <dc:creator>Ján Turský</dc:creator>
      <description>position: sticky has no error state. When it does not work it simply behaves as relative - and the cause is usually an ancestor you have never looked at, changed by somebody solving a different problem. (layout, 4 min)</description>
      <content:encoded><![CDATA[<p><strong><code class="font-mono text-[0.92em]">position: sticky</code> has no error state. When it does not work it simply behaves as <code class="font-mono text-[0.92em]">relative</code> - and the cause is usually <strong>an ancestor you have never looked at</strong>, changed by somebody solving a different problem.</strong></p><p>Two things have to be true before sticky does anything at all, and both are easy to satisfy by accident and easy to break by accident.</p><ol><li><strong>A threshold.</strong> At least one of <code class="font-mono text-[0.92em]">top</code>, <code class="font-mono text-[0.92em]">right</code>, <code class="font-mono text-[0.92em]">bottom</code> or <code class="font-mono text-[0.92em]">left</code> must be set. Without one there is no line to stick to, and the element scrolls away exactly as if nothing were set.</li><li><strong>A scrollport that actually scrolls.</strong> Sticky positions the element inside the nearest scrolling ancestor - and once an ancestor establishes one, that box is the whole world the element can travel in.</li></ol><p>Everything below is a variation on the second one.</p><h2>The ancestor properties that end it</h2><dl><dt>overflow: hidden</dt><dd>The commonest by a distance, and usually written as <code class="font-mono text-[0.92em]">overflow-x: hidden</code> on <code class="font-mono text-[0.92em]">body</code> to hide a sideways scroll. It establishes a scrollport with no scrolling in it, so the sticky element sticks inside a box that never moves. <mark class="bg-accent-light text-text">The two bugs are the same bug.</mark></dd><dt>overflow: auto or scroll</dt><dd>Same mechanism, stated deliberately. The element now sticks within that container rather than within the page - correct behaviour, and rarely the intended one.</dd><dt>overflow: clip</dt><dd>The exception worth knowing. <code class="font-mono text-[0.92em]">clip</code> cuts the overflow <strong>without</strong> establishing a scrollport, so sticky survives it. It is the right property for the full-bleed case that produced the <code class="font-mono text-[0.92em]">hidden</code> in the first place.</dd><dt>A parent with no room</dt><dd>Sticky travels inside its own parent, never past it. A header whose parent is only as tall as the header has nowhere to go, and this is what a flex or grid item usually is by default.</dd><dt>contain: paint</dt><dd>Establishes a containing block for the same reason <code class="font-mono text-[0.92em]">overflow</code> does. Added for performance, and it takes sticky with it.</dd></dl><p><em>Two columns, the same header CSS in both. Scroll them. The right one has a wrapper with overflow: hidden between the header and the scrolling box, and that is the entire difference. - <a href="https://loupekit.com/blog/sticky-header/">runs on the page itself</a>.</em></p><h2>Finding the ancestor</h2><p>The search is mechanical, which makes it worth doing in one line rather than by opening panels:</p><p><em>every ancestor that could be the reason</em></p><pre><code>const blockers = [];
let node = document.querySelector('.your-sticky-thing');

while ((node = node.parentElement)) {
  const s = getComputedStyle(node);
  const overflow = `${s.overflowX} ${s.overflowY}`;
  if (/hidden|auto|scroll/.test(overflow) || s.contain.includes('paint')) {
    blockers.push([node, overflow, s.contain]);
  }
}

blockers;
// The first entry is the one. Everything after it is above the
// scrollport and cannot have caused anything.</code></pre><blockquote><p><strong>The first result, not the last.</strong> Once one ancestor establishes a scrollport, the ancestors above it are outside the element's world entirely - and a list read bottom-up sends people to change <code class="font-mono text-[0.92em]">overflow</code> on <code class="font-mono text-[0.92em]">html</code>, which is how a one-element bug turns into a page-wide one.</p></blockquote><h2>The fix that is almost always right</h2><p><em>clip instead of hidden, scoped instead of global</em></p><pre><code>/* Was: */
body { overflow-x: hidden; }

/* Becomes, on the element that actually overflows: */
.hero-decoration {
  overflow-x: clip;
}</code></pre><p><code class="font-mono text-[0.92em]">clip</code> does what the author meant - hide what sticks out - without the side effect they did not ask for. And scoping it to the offending element rather than the document means the next sticky thing anybody adds is not born broken.</p><p>If the cause is a parent with no height rather than an overflow, the fix is on the parent: give it the height the sticky element is meant to travel through, usually by removing an <code class="font-mono text-[0.92em]">align-items: stretch</code> assumption or setting <code class="font-mono text-[0.92em]">height: 100%</code> on the intermediate wrapper.</p><h2>What sticky is not</h2><ul><li><strong>Not <code class="font-mono text-[0.92em]">fixed</code>.</strong> A fixed element leaves the flow and is positioned against the viewport; a sticky one keeps its space and its place in the layout, which is why it does not cause a jump when it engages.</li><li><strong>Not a scroll listener.</strong> It is laid out by the engine, off the main thread, and it cannot be janky. Anything replacing it with a scroll handler and a class toggle is slower and worse in every browser.</li><li><strong>Not free of stacking.</strong> A sticky element creates a stacking context, so its children sort among themselves. A dropdown inside a sticky header cannot paint above something outside it, whatever number it carries.</li></ul><blockquote><p>That last one is the same trap as <code class="font-mono text-[0.92em]">z-index: 9999</code>, arriving from a different direction. <mark class="bg-accent-light text-text">Sticky is on the list of properties that open a stacking context, and almost nobody knows it is.</mark></p></blockquote>]]></content:encoded>
    </item>
    <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>
