[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"search-suggestions":44,"article-css-scroll-state-container-queries":93,"path-membership-css-scroll-state-container-queries":281,"related-css-scroll-state-container-queries":282,"code:js:true:1q407bl":411,"code:css:true:176n3lt":412,"code:css:true:1tvq7k8":413,"comments-01a1623b-6184-751c-ba70-d14968adfe52":414},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,[45,49,53,57,61,65,69,73,77,81,85,89],{"slug":46,"name":47,"articles":48},"webdev","Webdev",156,{"slug":50,"name":51,"articles":52},"javascript","Javascript",128,{"slug":54,"name":55,"articles":56},"frontend","Frontend",89,{"slug":58,"name":59,"articles":60},"tutorial","Tutorial",61,{"slug":62,"name":63,"articles":64},"css","Css",46,{"slug":66,"name":67,"articles":68},"performance","Performance",26,{"slug":70,"name":71,"articles":72},"typescript","Typescript",22,{"slug":74,"name":75,"articles":76},"react","React",19,{"slug":78,"name":79,"articles":80},"browser","Browser",15,{"slug":82,"name":83,"articles":84},"programming","Programming",14,{"slug":86,"name":87,"articles":88},"node","Node",13,{"slug":90,"name":91,"articles":92},"accessibility","Accessibility",10,{"id":94,"slug":95,"title":96,"subtitle":97,"excerpt":98,"coverUrl":99,"locale":13,"readingMinutes":100,"publishedAt":101,"viewCount":102,"likeCount":19,"commentCount":19,"author":103,"vertical":108,"topic":109,"tags":111,"_count":116,"playground":118,"body":120,"bodyMd":264,"seo":265,"translationGroupId":268,"series":97,"podcastUrl":97,"verticalId":5,"thread":269,"assessments":271,"translations":277,"quiz":279},"01a1623b-6184-751c-ba70-d14968adfe52","css-scroll-state-container-queries","Stop Writing JS to Detect a Stuck Header. `scroll-state()` Fixes It.",null,"A sticky header that should only cast a shadow once it's actually stuck to the top usually means a hidden sentinel div and an IntersectionObserver. CSS now tracks that state itself — the `scroll-state()` container query reads it directly, no sentinel, no observer, no JS math.","\u002Fmedia\u002Fcovers\u002Fcss-scroll-state-container-queries.png",5,"2026-10-27T06:10:31.568Z",33,{"id":104,"name":105,"username":106,"avatarUrl":97,"headline":107},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":62,"name":110},"CSS",[112,113,114,115],{"slug":62,"name":63,"color":97},{"slug":46,"name":47,"color":97},{"slug":54,"name":55,"color":97},{"slug":78,"name":79,"color":97},{"assessments":117},1,{"slug":95,"title":119},"Is the header actually stuck? — scroll-state() vs IntersectionObserver",{"blocks":121,"version":117},[122,126,129,133,136,142,145,148,151,154,157,160,163,167,170,176,179,182,185,188,191,194,197,200,203,207,210,213,216,219,222,225,228,231,234,237,240,246,249,252,255,258],{"id":123,"html":124,"type":125},"b1","\u003Cp>The brief was one sentence: &quot;the header should get a shadow once it&#39;s stuck to the top.&quot; Not while it&#39;s visible — only once it&#39;s actually locked there, mid-scroll, floating over the content instead of sitting in it. Someone had already built it, eight months earlier, and it had quietly been wrong the whole time: the shadow appeared the instant the header scrolled into view, a good 40 pixels before it ever touched the top of the viewport.\u003C\u002Fp>","paragraph",{"id":127,"html":128,"type":125},"b2","\u003Cp>Nobody had filed a bug. Nobody had noticed, really — a shadow that&#39;s technically early by a few dozen pixels doesn&#39;t scream at you. But once you see it, the header looks stuck when it isn&#39;t, for just long enough to feel off.\u003C\u002Fp>",{"id":130,"html":131,"text":131,"type":132,"level":31},"b3","The sentinel trick, and the number nobody wants to own","heading",{"id":134,"html":135,"type":125},"b4","\u003Cp>Here&#39;s the fix that had shipped, more or less verbatim from a well-known pattern:\u003C\u002Fp>",{"id":137,"code":138,"type":139,"language":140,"highlight":141},"b5","const sentinel = document.createElement('div');\nsentinel.style.cssText = 'position:absolute; top:-1px; height:1px; width:1px;';\nheader.parentElement.insertBefore(sentinel, header);\n\nconst observer = new IntersectionObserver(\n  ([entry]) => header.classList.toggle('is-stuck', !entry.isIntersecting),\n  { threshold: 0, rootMargin: '-1px 0px 0px 0px' }\n);\nobserver.observe(sentinel);","code","js",[],{"id":143,"html":144,"type":125},"b6","\u003Cp>This is the standard way to detect a stuck \u003Ccode>position: sticky\u003C\u002Fcode> element without a scroll listener, and it&#39;s genuinely clever: plant an invisible 1-pixel tripwire just above the header, watch it with an \u003Ccode>IntersectionObserver\u003C\u002Fcode>, and the moment the tripwire scrolls out from under the (negative) root margin, the header must have hit its sticky offset. No \u003Ccode>scroll\u003C\u002Fcode> event, no layout thrash, no polling.\u003C\u002Fp>",{"id":146,"html":147,"type":125},"b7","\u003Cp>It also has a number baked into it that has nothing to do with your actual layout: that \u003Ccode>-1px\u003C\u002Fcode> margin assumes the header sticks at \u003Ccode>top: 0\u003C\u002Fcode>. Our header stuck at \u003Ccode>top: 64px\u003C\u002Fcode>, under a fixed announcement bar. Someone had hardcoded \u003Ccode>rootMargin: &#39;-65px 0px 0px 0px&#39;\u003C\u002Fcode> to compensate — and the day the announcement bar&#39;s height became responsive (48px on mobile, 64px on desktop), the sentinel&#39;s math stopped matching reality on exactly one breakpoint. The shadow started firing early again, and this time only on phones.\u003C\u002Fp>",{"id":149,"html":150,"type":125},"b8","\u003Cp>\u003Cstrong>Guess what breaks it further:\u003C\u002Fstrong> what happens to this whole setup if the header is sticky \u003Cem>inside a scrollable panel\u003C\u002Fem>, not the page itself — a sidebar, a modal, a data table with a frozen header row? Take a second before you scroll on.\u003C\u002Fp>",{"id":152,"html":153,"type":125},"b9","\u003Cp>The \u003Ccode>IntersectionObserver\u003C\u002Fcode>&#39;s default root is the viewport. Inside a scroll container, you&#39;d need to pass that container in as \u003Ccode>root\u003C\u002Fcode>, track it down through the DOM, and keep that reference valid across re-renders. The sentinel trick doesn&#39;t just need a magic number — it needs you to manually tell it which scrolling box it even lives in.\u003C\u002Fp>",{"id":155,"html":156,"text":156,"type":132,"level":31},"b10","The browser already has this answer",{"id":158,"html":159,"type":125},"b11","\u003Cp>Every one of those numbers — the sticky offset, which ancestor is actually scrolling, whether the element is stuck \u003Cem>right now\u003C\u002Fem> — is information the rendering engine computes on its own, every frame, just to paint a \u003Ccode>position: sticky\u003C\u002Fcode> element in the right place. We weren&#39;t detecting a new fact with the sentinel trick. We were re-deriving, in JavaScript, with a hardcoded guess, something the layout engine already had exactly right.\u003C\u002Fp>",{"id":161,"html":162,"type":125},"b12","\u003Cp>CSS container queries can now read that internal state directly. The feature is called \u003Cstrong>scroll-state container queries\u003C\u002Fstrong>, and the piece that matters here is \u003Ccode>scroll-state(stuck: ...)\u003C\u002Fcode>:\u003C\u002Fp>",{"id":164,"code":165,"type":139,"language":62,"highlight":166},"b13",".header-wrap {\n  position: sticky;\n  top: 64px;\n  container-type: scroll-state;\n}\n\n@container scroll-state(stuck: top) {\n  .header-inner {\n    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);\n  }\n}",[],{"id":168,"html":169,"type":125},"b14","\u003Cp>Two things to notice, because both are easy to get backwards the first time:\u003C\u002Fp>",{"id":171,"type":172,"items":173,"ordered":17},"b15","list",[174,175],"\u003Cstrong>\u003Ccode>container-type: scroll-state\u003C\u002Fcode> goes on the sticky element itself\u003C\u002Fstrong> — the one with \u003Ccode>position: sticky\u003C\u002Fcode>. That&#39;s what turns it into a query container whose scroll\u002Fstuck state other elements can read.","\u003Cstrong>The \u003Ccode>@container scroll-state(stuck: top)\u003C\u002Fcode> rule has to target a \u003Cem>different\u003C\u002Fem> element than the one carrying \u003Ccode>container-type\u003C\u002Fcode>\u003C\u002Fstrong> — here, a nested \u003Ccode>.header-inner\u003C\u002Fcode>, not \u003Ccode>.header-wrap\u003C\u002Fcode>. An element can&#39;t query its own container.",{"id":177,"html":178,"type":125},"b16","\u003Cp>No sentinel div. No observer to instantiate, tear down, or leak. No \u003Ccode>rootMargin\u003C\u002Fcode> number to recompute when the layout above it changes — \u003Ccode>top: 64px\u003C\u002Fcode> lives in exactly one place, the same \u003Ccode>top\u003C\u002Fcode> the sticky positioning itself already uses, so there&#39;s nothing left to get out of sync.\u003C\u002Fp>",{"id":180,"html":181,"type":125},"b17","\u003C!-- playground:start -->",{"id":183,"html":184,"text":184,"type":132,"level":31},"b18","🎮 Try it yourself",{"id":186,"html":187,"type":125},"b19","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-scroll-state-container-queries\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":189,"html":190,"type":125},"b20","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":192,"html":193,"type":125},"b21","\u003C!-- playground:end -->",{"id":195,"html":196,"text":196,"type":132,"level":31},"b22","The honest part: this is a Chrome feature today",{"id":198,"html":199,"type":125},"b23","\u003Cp>\u003Ccode>container-type: scroll-state\u003C\u002Fcode> and \u003Ccode>scroll-state()\u003C\u002Fcode> queries shipped in Chrome and Edge 133, in early 2025. As of this writing, Firefox and Safari haven&#39;t implemented them — this is a single-engine feature, not a baseline one yet, and a title that said otherwise would be lying to you.\u003C\u002Fp>",{"id":201,"html":202,"type":125},"b24","\u003Cp>That doesn&#39;t mean you need a JavaScript fork in every file that uses it. \u003Ccode>@supports\u003C\u002Fcode> already knows how to ask:\u003C\u002Fp>",{"id":204,"code":205,"type":139,"language":62,"highlight":206},"b25","@supports (container-type: scroll-state) {\n  .header-wrap { container-type: scroll-state; }\n  @container scroll-state(stuck: top) {\n    .header-inner { box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15); }\n  }\n}",[],{"id":208,"html":209,"type":125},"b26","\u003Cp>Browsers that support the feature get the CSS-only path. Everywhere else, \u003Ccode>@supports\u003C\u002Fcode> simply doesn&#39;t match, and you fall through to the sentinel-and-observer version as a real fallback — not a hack you&#39;re embarrassed by, just progressive enhancement doing its actual job. The win isn&#39;t &quot;delete the IntersectionObserver code today.&quot; It&#39;s that the code path doing the real work, the one with no stale magic number in it, is the one that gets to run in a growing share of real sessions, and the JS version becomes what it should have been from the start: a fallback, not the only implementation.\u003C\u002Fp>",{"id":211,"html":212,"type":125},"b27","\u003Cp>Container queries don&#39;t stop at \u003Ccode>stuck\u003C\u002Fcode>. The same \u003Ccode>scroll-state()\u003C\u002Fcode> function can also tell you whether a scroll-snap child is currently \u003Ccode>snapped\u003C\u002Fcode>, and whether a scrollable container still has more content in a given direction — the kind of state that today usually means a scroll-position calculation on every \u003Ccode>scroll\u003C\u002Fcode> event, for a &quot;can scroll further&quot; arrow that&#39;s wrong half the time anyway.\u003C\u002Fp>",{"id":214,"html":215,"type":125},"b28","\u003C!-- quiz:start -->",{"id":217,"html":218,"text":218,"type":132,"level":31},"b29","🧠 Test yourself",{"id":220,"html":221,"type":125},"b30","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-scroll-state-container-queries\u002Fquiz\">Take the 7-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":223,"html":224,"type":125},"b31","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":226,"html":227,"type":125},"b32","\u003C!-- quiz:end -->",{"id":229,"html":230,"type":125},"b33","\u003Cp>The bug was never really about a shadow appearing 40 pixels early. It was that we&#39;d built a parallel, worse copy of information the browser was already tracking correctly, and every layout change was a new chance for our copy to drift from the original. \u003Ccode>scroll-state()\u003C\u002Fcode> isn&#39;t a trick — it&#39;s the actual state, exposed.\u003C\u002Fp>",{"id":232,"html":233,"type":125},"b34","\u003Cp>Next time you reach for an \u003Ccode>IntersectionObserver\u003C\u002Fcode> and a tripwire div, ask first: is this something the browser already knows? What&#39;s the last &quot;detect X from scroll position&quot; snippet you wrote that you suspect was really just this?\u003C\u002Fp>",{"id":235,"html":236,"type":125},"b35","\u003C!-- related:start -->",{"id":238,"html":239,"text":239,"type":132,"level":31},"b36","📚 Read next",{"id":241,"type":172,"items":242,"ordered":18},"b37",[243,244,245],"\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcontainer-queries-responsive-primitive\">Your card breaks in the sidebar. Here&#39;s the real fix.\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-dynamic-viewport-units-dvh-svh-lvh\">Stop Writing JavaScript To Fix \u003Ccode>100vh\u003C\u002Fcode> On Mobile\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-aspect-ratio-replace-padding-hack\">Delete Your padding-bottom Aspect-Ratio Hack\u003C\u002Fa>",{"id":247,"html":248,"type":125},"b38","\u003C!-- related:end -->",{"id":250,"type":251},"b39","divider",{"id":253,"html":254,"type":125},"b40","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":256,"html":257,"type":125},"b41","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":259,"type":172,"items":260,"ordered":18},"b42",[261,262,263],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","The brief was one sentence: \"the header should get a shadow once it's stuck to the top.\" Not while it's visible — only once it's actually locked there, mid-scroll, floating over the content instead of sitting in it. Someone had already built it, eight months earlier, and it had quietly been wrong the whole time: the shadow appeared the instant the header scrolled into view, a good 40 pixels before it ever touched the top of the viewport.\n\nNobody had filed a bug. Nobody had noticed, really — a shadow that's technically early by a few dozen pixels doesn't scream at you. But once you see it, the header looks stuck when it isn't, for just long enough to feel off.\n\n## The sentinel trick, and the number nobody wants to own\n\nHere's the fix that had shipped, more or less verbatim from a well-known pattern:\n\n```js\nconst sentinel = document.createElement('div');\nsentinel.style.cssText = 'position:absolute; top:-1px; height:1px; width:1px;';\nheader.parentElement.insertBefore(sentinel, header);\n\nconst observer = new IntersectionObserver(\n  ([entry]) => header.classList.toggle('is-stuck', !entry.isIntersecting),\n  { threshold: 0, rootMargin: '-1px 0px 0px 0px' }\n);\nobserver.observe(sentinel);\n```\n\nThis is the standard way to detect a stuck `position: sticky` element without a scroll listener, and it's genuinely clever: plant an invisible 1-pixel tripwire just above the header, watch it with an `IntersectionObserver`, and the moment the tripwire scrolls out from under the (negative) root margin, the header must have hit its sticky offset. No `scroll` event, no layout thrash, no polling.\n\nIt also has a number baked into it that has nothing to do with your actual layout: that `-1px` margin assumes the header sticks at `top: 0`. Our header stuck at `top: 64px`, under a fixed announcement bar. Someone had hardcoded `rootMargin: '-65px 0px 0px 0px'` to compensate — and the day the announcement bar's height became responsive (48px on mobile, 64px on desktop), the sentinel's math stopped matching reality on exactly one breakpoint. The shadow started firing early again, and this time only on phones.\n\n**Guess what breaks it further:** what happens to this whole setup if the header is sticky *inside a scrollable panel*, not the page itself — a sidebar, a modal, a data table with a frozen header row? Take a second before you scroll on.\n\nThe `IntersectionObserver`'s default root is the viewport. Inside a scroll container, you'd need to pass that container in as `root`, track it down through the DOM, and keep that reference valid across re-renders. The sentinel trick doesn't just need a magic number — it needs you to manually tell it which scrolling box it even lives in.\n\n## The browser already has this answer\n\nEvery one of those numbers — the sticky offset, which ancestor is actually scrolling, whether the element is stuck *right now* — is information the rendering engine computes on its own, every frame, just to paint a `position: sticky` element in the right place. We weren't detecting a new fact with the sentinel trick. We were re-deriving, in JavaScript, with a hardcoded guess, something the layout engine already had exactly right.\n\nCSS container queries can now read that internal state directly. The feature is called **scroll-state container queries**, and the piece that matters here is `scroll-state(stuck: ...)`:\n\n```css\n.header-wrap {\n  position: sticky;\n  top: 64px;\n  container-type: scroll-state;\n}\n\n@container scroll-state(stuck: top) {\n  .header-inner {\n    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);\n  }\n}\n```\n\nTwo things to notice, because both are easy to get backwards the first time:\n\n1. **`container-type: scroll-state` goes on the sticky element itself** — the one with `position: sticky`. That's what turns it into a query container whose scroll\u002Fstuck state other elements can read.\n2. **The `@container scroll-state(stuck: top)` rule has to target a *different* element than the one carrying `container-type`** — here, a nested `.header-inner`, not `.header-wrap`. An element can't query its own container.\n\nNo sentinel div. No observer to instantiate, tear down, or leak. No `rootMargin` number to recompute when the layout above it changes — `top: 64px` lives in exactly one place, the same `top` the sticky positioning itself already uses, so there's nothing left to get out of sync.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-scroll-state-container-queries\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## The honest part: this is a Chrome feature today\n\n`container-type: scroll-state` and `scroll-state()` queries shipped in Chrome and Edge 133, in early 2025. As of this writing, Firefox and Safari haven't implemented them — this is a single-engine feature, not a baseline one yet, and a title that said otherwise would be lying to you.\n\nThat doesn't mean you need a JavaScript fork in every file that uses it. `@supports` already knows how to ask:\n\n```css\n@supports (container-type: scroll-state) {\n  .header-wrap { container-type: scroll-state; }\n  @container scroll-state(stuck: top) {\n    .header-inner { box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15); }\n  }\n}\n```\n\nBrowsers that support the feature get the CSS-only path. Everywhere else, `@supports` simply doesn't match, and you fall through to the sentinel-and-observer version as a real fallback — not a hack you're embarrassed by, just progressive enhancement doing its actual job. The win isn't \"delete the IntersectionObserver code today.\" It's that the code path doing the real work, the one with no stale magic number in it, is the one that gets to run in a growing share of real sessions, and the JS version becomes what it should have been from the start: a fallback, not the only implementation.\n\nContainer queries don't stop at `stuck`. The same `scroll-state()` function can also tell you whether a scroll-snap child is currently `snapped`, and whether a scrollable container still has more content in a given direction — the kind of state that today usually means a scroll-position calculation on every `scroll` event, for a \"can scroll further\" arrow that's wrong half the time anyway.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 7-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-scroll-state-container-queries\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\nThe bug was never really about a shadow appearing 40 pixels early. It was that we'd built a parallel, worse copy of information the browser was already tracking correctly, and every layout change was a new chance for our copy to drift from the original. `scroll-state()` isn't a trick — it's the actual state, exposed.\n\nNext time you reach for an `IntersectionObserver` and a tripwire div, ask first: is this something the browser already knows? What's the last \"detect X from scroll position\" snippet you wrote that you suspect was really just this?\n\n\u003C!-- related:start -->\n\n## 📚 Read next\n\n- [Your card breaks in the sidebar. Here's the real fix.](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcontainer-queries-responsive-primitive)\n- [Stop Writing JavaScript To Fix `100vh` On Mobile](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-dynamic-viewport-units-dvh-svh-lvh)\n- [Delete Your padding-bottom Aspect-Ratio Hack](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-aspect-ratio-replace-padding-hack)\n\n\u003C!-- related:end -->\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":96,"canonical":266,"description":267},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-scroll-state-container-queries","A sticky header that should only cast a shadow once it's actually stuck to the top usually means a hidden sentinel div and an IntersectionObserver. CSS now tracks that state itself","01a1623b-6185-72bf-b83d-02d597231d96",{"id":270,"locked":18},"01a1623b-61a7-72a2-9d9e-f39d93d3725b",[272],{"id":273,"slug":95,"title":274,"_count":275},"01a1623b-61c4-732e-8777-eabcaeb2bde7","Scroll-state container queries: detecting \"stuck\" without JS",{"questions":276},7,[278],{"locale":13,"slug":95},{"id":273,"slug":95,"title":274,"_count":280,"questionCount":276},{"questions":276},[],{"items":283,"meta":410},[284,296,316,334,353,371,389],{"id":94,"slug":95,"title":96,"subtitle":97,"excerpt":98,"coverUrl":99,"locale":13,"readingMinutes":100,"publishedAt":101,"viewCount":285,"likeCount":19,"commentCount":19,"author":286,"vertical":287,"topic":288,"tags":289,"_count":294,"playground":295,"hasQuiz":17,"hasPlayground":17},34,{"id":104,"name":105,"username":106,"avatarUrl":97,"headline":107},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":62,"name":110},[290,291,292,293],{"slug":62,"name":63,"color":97},{"slug":46,"name":47,"color":97},{"slug":54,"name":55,"color":97},{"slug":78,"name":79,"color":97},{"assessments":117},{"slug":95},{"id":297,"slug":298,"title":299,"subtitle":97,"excerpt":300,"coverUrl":301,"locale":13,"readingMinutes":100,"publishedAt":302,"viewCount":303,"likeCount":19,"commentCount":19,"author":304,"vertical":305,"topic":306,"tags":307,"_count":314,"playground":315,"hasQuiz":17,"hasPlayground":17},"01a149cb-71f3-71ba-bb39-4f894fa2f1a8","css-individual-transform-properties","Stop Jamming Translate, Rotate, and Scale Into One `transform`","A CSS :hover scale and a JS drag handler both write to `transform` — and the moment JS sets it, the hover rule silently stops working, no error, no warning. The `translate`, `rotate`, and `scale` properties exist so this never happens.","\u002Fmedia\u002Fcovers\u002Fcss-individual-transform-properties.png","2026-10-22T06:10:24.373Z",187,{"id":104,"name":105,"username":106,"avatarUrl":97,"headline":107},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":62,"name":110},[308,309,310,311],{"slug":62,"name":63,"color":97},{"slug":50,"name":51,"color":97},{"slug":46,"name":47,"color":97},{"slug":312,"name":313,"color":97},"animation","Animation",{"assessments":117},{"slug":298},{"id":317,"slug":318,"title":319,"subtitle":97,"excerpt":320,"coverUrl":321,"locale":13,"readingMinutes":276,"publishedAt":322,"viewCount":323,"likeCount":19,"commentCount":19,"author":324,"vertical":325,"topic":326,"tags":327,"_count":332,"playground":333,"hasQuiz":17,"hasPlayground":17},"01a133e2-3e89-757c-ae01-38a8ad8aa448","forced-colors-high-contrast-mode","Stop Shipping Custom UI That Disappears in `forced-colors` Mode","Windows High Contrast Mode doesn't just boost contrast — it strips your backgrounds, shadows, and colors outright. Here's what `forced-colors` actually does, why a styled \u003Cdiv> vanishes while a real \u003Cbutton> survives, and the fix.","\u002Fmedia\u002Fcovers\u002Fforced-colors-high-contrast-mode.png","2026-10-18T10:15:03.062Z",205,{"id":104,"name":105,"username":106,"avatarUrl":97,"headline":107},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":62,"name":110},[328,329,330,331],{"slug":62,"name":63,"color":97},{"slug":90,"name":91,"color":97},{"slug":46,"name":47,"color":97},{"slug":54,"name":55,"color":97},{"assessments":117},{"slug":318},{"id":335,"slug":336,"title":337,"subtitle":97,"excerpt":338,"coverUrl":339,"locale":13,"readingMinutes":340,"publishedAt":341,"viewCount":342,"likeCount":19,"commentCount":19,"author":343,"vertical":344,"topic":345,"tags":346,"_count":351,"playground":352,"hasQuiz":17,"hasPlayground":17},"01a12aed-3621-77cf-8827-f0fa7f687105","css-contain-rendering-isolation","Stop Letting One Widget Reflow the Whole Page. `contain` Scopes It.","A live counter or chat widget shouldn't make the rest of your page recompute layout every time it updates. The CSS contain property draws a hard boundary the browser can actually trust — here's what it promises, and the one gotcha that breaks pages when people misuse it.","\u002Fmedia\u002Fcovers\u002Fcss-contain-rendering-isolation.png",6,"2026-10-16T09:23:09.005Z",245,{"id":104,"name":105,"username":106,"avatarUrl":97,"headline":107},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":62,"name":110},[347,348,349,350],{"slug":62,"name":63,"color":97},{"slug":66,"name":67,"color":97},{"slug":46,"name":47,"color":97},{"slug":54,"name":55,"color":97},{"assessments":117},{"slug":336},{"id":354,"slug":355,"title":356,"subtitle":97,"excerpt":357,"coverUrl":358,"locale":13,"readingMinutes":100,"publishedAt":359,"viewCount":360,"likeCount":19,"commentCount":19,"author":361,"vertical":362,"topic":363,"tags":364,"_count":369,"playground":370,"hasQuiz":17,"hasPlayground":17},"01a10aaf-5ec4-700b-bbd2-5fc17c27d4e0","css-will-change-performance-hint","You Added `will-change` to Fix the Jank. You Made It Worse.","will-change: transform looks like a free performance switch. Applied broadly or left on permanently, it does the opposite — here's the narrow, temporary pattern that actually works.","\u002Fmedia\u002Fcovers\u002Fcss-will-change-performance-hint.png","2026-10-10T12:12:00.282Z",328,{"id":104,"name":105,"username":106,"avatarUrl":97,"headline":107},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":62,"name":110},[365,366,367,368],{"slug":62,"name":63,"color":97},{"slug":66,"name":67,"color":97},{"slug":46,"name":47,"color":97},{"slug":54,"name":55,"color":97},{"assessments":117},{"slug":355},{"id":372,"slug":373,"title":374,"subtitle":97,"excerpt":375,"coverUrl":376,"locale":13,"readingMinutes":340,"publishedAt":377,"viewCount":378,"likeCount":19,"commentCount":19,"author":379,"vertical":380,"topic":381,"tags":382,"_count":387,"playground":388,"hasQuiz":17,"hasPlayground":17},"01a106cb-f870-70ac-892c-2f9a55db26b3","prefers-reduced-motion-respect-the-setting","You're Animating for Everyone. `prefers-reduced-motion` Fixes That.","Most animated UIs never check whether the visitor asked for less motion. `prefers-reduced-motion` is the CSS media feature that tells you — and a fail-closed pattern keeps you honest even when a browser doesn't support it.","\u002Fmedia\u002Fcovers\u002Fprefers-reduced-motion-respect-the-setting.png","2026-10-09T06:10:28.564Z",532,{"id":104,"name":105,"username":106,"avatarUrl":97,"headline":107},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":62,"name":110},[383,384,385,386],{"slug":62,"name":63,"color":97},{"slug":90,"name":91,"color":97},{"slug":46,"name":47,"color":97},{"slug":54,"name":55,"color":97},{"assessments":117},{"slug":373},{"id":390,"slug":391,"title":392,"subtitle":97,"excerpt":393,"coverUrl":394,"locale":13,"readingMinutes":340,"publishedAt":395,"viewCount":396,"likeCount":19,"commentCount":19,"author":397,"vertical":398,"topic":399,"tags":402,"_count":408,"playground":409,"hasQuiz":17,"hasPlayground":17},"01a0ec73-289b-72dc-b408-cb3deb2a38a9","details-name-exclusive-accordion","Stop Hand-Rolling an Exclusive Accordion. `\u003Cdetails name>` Does It","You've probably shipped an accordion with a hand-rolled 'close the other panels' loop in JavaScript. The `name` attribute on \u003Cdetails> does it natively — no JS, no ARIA plumbing.","\u002Fmedia\u002Fcovers\u002Fdetails-name-exclusive-accordion.png","2026-10-04T12:03:17.379Z",497,{"id":104,"name":105,"username":106,"avatarUrl":97,"headline":107},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":400,"name":401},"html","HTML",[403,405,406,407],{"slug":400,"name":404,"color":97},"Html",{"slug":62,"name":63,"color":97},{"slug":46,"name":47,"color":97},{"slug":90,"name":91,"color":97},{"assessments":117},{"slug":391},{"page":117,"perPage":276,"total":64,"totalPages":276},"\u003Cdiv class=\"shj shj-lang-js shj-multiline\" data-lang=\"js\">\u003Cdiv class=\"shj-scroll\">\u003Cdiv class=\"shj-numbers\">\u003Cdiv>1\u003C\u002Fdiv>\u003Cdiv>2\u003C\u002Fdiv>\u003Cdiv>3\u003C\u002Fdiv>\u003Cdiv>4\u003C\u002Fdiv>\u003Cdiv>5\u003C\u002Fdiv>\u003Cdiv>6\u003C\u002Fdiv>\u003Cdiv>7\u003C\u002Fdiv>\u003Cdiv>8\u003C\u002Fdiv>\u003Cdiv>9\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cdiv class=\"shj-code\">\u003Cspan class=\"shj-kwd\">const\u003C\u002Fspan> sentinel \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> document\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">createElement\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-str\">'div'\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\nsentinel\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>style\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>cssText \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> \u003Cspan class=\"shj-str\">'position:absolute; top:-1px; height:1px; width:1px;'\u003C\u002Fspan>;\nheader\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>parentElement\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">insertBefore\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>sentinel\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> header\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\n\n\u003Cspan class=\"shj-kwd\">const\u003C\u002Fspan> observer \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">new\u003C\u002Fspan> \u003Cspan class=\"shj-class\">IntersectionObserver\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\n  \u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">[\u003C\u002Fspan>entry\u003Cspan class=\"shj-bracket\">]\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">=&gt;\u003C\u002Fspan> header\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>classList\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">toggle\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-str\">'is-stuck'\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> \u003Cspan class=\"shj-oper\">!\u003C\u002Fspan>entry\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>isIntersecting\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan>\n  \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> threshold\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-num\">0\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> rootMargin\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-str\">'-1px 0px 0px 0px'\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\n\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\nobserver\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">observe\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>sentinel\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\u003C\u002Fdiv>\u003C\u002Fdiv>\u003C\u002Fdiv>","\u003Cdiv class=\"shj shj-lang-css shj-multiline\" data-lang=\"css\">\u003Cdiv class=\"shj-scroll\">\u003Cdiv class=\"shj-numbers\">\u003Cdiv>1\u003C\u002Fdiv>\u003Cdiv>2\u003C\u002Fdiv>\u003Cdiv>3\u003C\u002Fdiv>\u003Cdiv>4\u003C\u002Fdiv>\u003Cdiv>5\u003C\u002Fdiv>\u003Cdiv>6\u003C\u002Fdiv>\u003Cdiv>7\u003C\u002Fdiv>\u003Cdiv>8\u003C\u002Fdiv>\u003Cdiv>9\u003C\u002Fdiv>\u003Cdiv>10\u003C\u002Fdiv>\u003Cdiv>11\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cdiv class=\"shj-code\">\u003Cspan class=\"shj-var\">.header-wrap\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n  \u003Cspan class=\"shj-var\">position\u003C\u002Fspan>: \u003Cspan class=\"shj-num\">sticky\u003C\u002Fspan>;\n  \u003Cspan class=\"shj-var\">top\u003C\u002Fspan>: \u003Cspan class=\"shj-num\">64\u003C\u002Fspan>\u003Cspan class=\"shj-var\">px\u003C\u002Fspan>\u003Cspan class=\"shj-num\">\u003C\u002Fspan>;\n  \u003Cspan class=\"shj-var\">container-type\u003C\u002Fspan>: \u003Cspan class=\"shj-num\">scroll-state\u003C\u002Fspan>;\n\u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\n\n\u003Cspan class=\"shj-kwd\">@container\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">scroll-state\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-kwd\">stuck\u003C\u002Fspan>: \u003Cspan class=\"shj-kwd\">top\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n  \u003Cspan class=\"shj-var\">.header-inner\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n    \u003Cspan class=\"shj-var\">box-shadow\u003C\u002Fspan>: \u003Cspan class=\"shj-num\">0\u003C\u002Fspan> \u003Cspan class=\"shj-num\">2\u003C\u002Fspan>\u003Cspan class=\"shj-var\">px\u003C\u002Fspan>\u003Cspan class=\"shj-num\">\u003C\u002Fspan> \u003Cspan class=\"shj-num\">10\u003C\u002Fspan>\u003Cspan class=\"shj-var\">px\u003C\u002Fspan>\u003Cspan class=\"shj-num\">\u003C\u002Fspan> \u003Cspan class=\"shj-func\">rgba\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-num\">0\u003C\u002Fspan>, \u003Cspan class=\"shj-num\">0\u003C\u002Fspan>, \u003Cspan class=\"shj-num\">0\u003C\u002Fspan>, \u003Cspan class=\"shj-num\">0.15\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\n  \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\n\u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003C\u002Fdiv>\u003C\u002Fdiv>\u003C\u002Fdiv>","\u003Cdiv class=\"shj shj-lang-css shj-multiline\" data-lang=\"css\">\u003Cdiv class=\"shj-scroll\">\u003Cdiv class=\"shj-numbers\">\u003Cdiv>1\u003C\u002Fdiv>\u003Cdiv>2\u003C\u002Fdiv>\u003Cdiv>3\u003C\u002Fdiv>\u003Cdiv>4\u003C\u002Fdiv>\u003Cdiv>5\u003C\u002Fdiv>\u003Cdiv>6\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cdiv class=\"shj-code\">\u003Cspan class=\"shj-kwd\">@supports\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-kwd\">container-type\u003C\u002Fspan>: \u003Cspan class=\"shj-kwd\">scroll-state\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n  \u003Cspan class=\"shj-var\">.header-wrap\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> \u003Cspan class=\"shj-var\">container-type\u003C\u002Fspan>: \u003Cspan class=\"shj-num\">scroll-state\u003C\u002Fspan>; \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\n  \u003Cspan class=\"shj-kwd\">@container\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">scroll-state\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-kwd\">stuck\u003C\u002Fspan>: \u003Cspan class=\"shj-kwd\">top\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n    \u003Cspan class=\"shj-var\">.header-inner\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> \u003Cspan class=\"shj-var\">box-shadow\u003C\u002Fspan>: \u003Cspan class=\"shj-num\">0\u003C\u002Fspan> \u003Cspan class=\"shj-num\">2\u003C\u002Fspan>\u003Cspan class=\"shj-var\">px\u003C\u002Fspan>\u003Cspan class=\"shj-num\">\u003C\u002Fspan> \u003Cspan class=\"shj-num\">10\u003C\u002Fspan>\u003Cspan class=\"shj-var\">px\u003C\u002Fspan>\u003Cspan class=\"shj-num\">\u003C\u002Fspan> \u003Cspan class=\"shj-func\">rgba\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-num\">0\u003C\u002Fspan>, \u003Cspan class=\"shj-num\">0\u003C\u002Fspan>, \u003Cspan class=\"shj-num\">0\u003C\u002Fspan>, \u003Cspan class=\"shj-num\">0.15\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>; \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\n  \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\n\u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003C\u002Fdiv>\u003C\u002Fdiv>\u003C\u002Fdiv>",{"locked":18,"total":19,"comments":415},[]]