[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-intersection-observer-visibility":32,"quiz-article-intersection-observer-visibility":48},[4,20],{"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","Build. Learn. Ship.","Practical courses, engineering-grade articles and open-source tools for people who ship.","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.","Structured paths, listening drills and spaced repetition that actually sticks.","amber-400","⌘",[13,15,29],"es",{"courses":17,"paths":17,"articles":18,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":17},2,{"id":33,"slug":34,"kind":35,"title":36,"description":37,"config":38,"verticalId":5,"vertical":43,"course":40,"_count":44,"access":45,"attempts":47,"questionCount":39},"019fe776-5f89-739f-b169-bbce60a3949d","intersection-observer-visibility","PRACTICE_QUIZ","IntersectionObserver: visibility without a scroll listener","IntersectionObserver reports visibility changes asynchronously instead of running on every scroll pixel. Know how threshold and rootMargin decide WHEN it fires, what an entry actually exposes, and how to stop watching once you're done.",{"questionCount":39,"timeLimitSec":40,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":41,"maxAttempts":40,"revealAnswers":42,"allowFlagging":18,"allowBacktracking":17},6,null,70,"IMMEDIATE",{"slug":6,"name":7},{"questions":39},{"allowed":17,"reason":46},"FREE",[],{"id":49,"slug":34,"title":50,"subtitle":40,"excerpt":51,"coverUrl":52,"locale":13,"readingMinutes":53,"publishedAt":54,"viewCount":55,"likeCount":19,"commentCount":19,"author":56,"vertical":61,"topic":62,"tags":65,"_count":77,"playground":79,"body":81,"bodyMd":233,"seo":234,"translationGroupId":238,"thread":239,"assessments":241,"translations":244,"quiz":246},"019fe660-503a-7133-b7b0-7d5ce9587539","Your scroll listener fires on every pixel. `IntersectionObserver` fires when visibility actually changes.","Detecting whether an element is in the viewport by listening to scroll events runs an expensive layout calculation on every frame. `IntersectionObserver` is the browser-native alternative — it fires only when visibility changes, off the main thread, with no scroll handler involved.","\u002Fmedia\u002Fcovers\u002Fintersection-observer-visibility.png",4,"2026-07-19T08:25:59.411Z",34,{"id":57,"name":58,"username":59,"avatarUrl":40,"headline":60},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":63,"name":64},"javascript","JavaScript",[66,68,71,74],{"slug":63,"name":67,"color":40},"Javascript",{"slug":69,"name":70,"color":40},"webdev","Webdev",{"slug":72,"name":73,"color":40},"frontend","Frontend",{"slug":75,"name":76,"color":40},"performance","Performance",{"assessments":78},1,{"slug":34,"title":80},"IntersectionObserver — interactive playground",{"blocks":82,"version":78},[83,87,93,96,99,103,106,110,113,116,119,122,125,128,131,134,138,141,145,148,151,155,158,161,165,168,171,174,178,181,184,187,191,194,197,200,203,206,209,212,215,218,221,224],{"id":84,"html":85,"type":86},"b1","\u003Cp>Here is a pattern I still see in real codebases:\u003C\u002Fp>","paragraph",{"id":88,"code":89,"type":90,"language":91,"highlight":92},"b2","window.addEventListener('scroll', () => {\n  const rect = element.getBoundingClientRect();\n  if (rect.top \u003C window.innerHeight && rect.bottom >= 0) {\n    loadImage(element);\n  }\n});","code","js",[],{"id":94,"html":95,"type":86},"b3","\u003Cp>It works. It&#39;s also firing on every pixel of scroll movement — potentially 60 callbacks per second on a smooth display — and each one calls \u003Ccode>getBoundingClientRect()\u003C\u002Fcode>, which forces a synchronous layout recalculation. You probably added throttling or \u003Ccode>requestAnimationFrame\u003C\u002Fcode> wrapping to soften the blow. You&#39;re still doing layout work on the main thread to answer a question the browser already knows the answer to.\u003C\u002Fp>",{"id":97,"html":98,"type":86},"b4","\u003Cp>\u003Ccode>IntersectionObserver\u003C\u002Fcode> has been baseline since 2019. It&#39;s still underused.\u003C\u002Fp>",{"id":100,"html":101,"text":101,"type":102,"level":31},"b5","What it does","heading",{"id":104,"html":105,"type":86},"b6","\u003Cp>\u003Ccode>IntersectionObserver\u003C\u002Fcode> watches one or more elements and fires a callback \u003Cem>only when their visibility status changes\u003C\u002Fem> — entering the viewport, leaving it, or crossing a fraction threshold you specify. The geometry calculations happen off the main thread; you get called when something meaningful happens.\u003C\u002Fp>",{"id":107,"code":108,"type":90,"language":91,"highlight":109},"b7","const observer = new IntersectionObserver((entries) => {\n  entries.forEach(entry => {\n    if (entry.isIntersecting) {\n      console.log(entry.target, 'entered the viewport');\n    }\n  });\n});\n\nobserver.observe(document.querySelector('.card'));",[],{"id":111,"html":112,"type":86},"b8","\u003Cp>\u003Ccode>entries\u003C\u002Fcode> is an array of \u003Ccode>IntersectionObserverEntry\u003C\u002Fcode> objects — one per observed element that changed state in this tick. \u003Ccode>entry.isIntersecting\u003C\u002Fcode> is \u003Ccode>true\u003C\u002Fcode> when the element entered, \u003Ccode>false\u003C\u002Fcode> when it left.\u003C\u002Fp>",{"id":114,"html":115,"type":86},"b9","\u003C!-- playground:start -->",{"id":117,"html":118,"text":118,"type":102,"level":31},"b10","🎮 Try it yourself",{"id":120,"html":121,"type":86},"b11","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fdaily-post-dev.netlify.app\u002Fposts\u002F2026-07-19-intersection-observer-visibility\u002Fplayground\u002F\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":123,"html":124,"type":86},"b12","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":126,"html":127,"type":86},"b13","\u003C!-- playground:end -->",{"id":129,"html":130,"text":130,"type":102,"level":31},"b14","Lazy loading an image",{"id":132,"html":133,"type":86},"b15","\u003Cp>The classic use case. No scroll event, no position math:\u003C\u002Fp>",{"id":135,"code":136,"type":90,"language":91,"highlight":137},"b16","const lazyImages = document.querySelectorAll('img[data-src]');\n\nconst imageObserver = new IntersectionObserver((entries) => {\n  entries.forEach(entry => {\n    if (!entry.isIntersecting) return;\n\n    const img = entry.target;\n    img.src = img.dataset.src;\n    imageObserver.unobserve(img); \u002F\u002F done watching this one\n  });\n});\n\nlazyImages.forEach(img => imageObserver.observe(img));",[],{"id":139,"html":140,"type":86},"b17","\u003Cp>The observer fires once when each image enters the viewport. After setting \u003Ccode>src\u003C\u002Fcode>, \u003Ccode>unobserve\u003C\u002Fcode> drops the element from the watch list. No cleanup loop, no residual listener, no memory leak.\u003C\u002Fp>",{"id":142,"html":143,"text":144,"type":102,"level":31},"b18","\u003Ccode>threshold\u003C\u002Fcode> and \u003Ccode>rootMargin\u003C\u002Fcode>","threshold and rootMargin",{"id":146,"html":147,"type":86},"b19","\u003Cp>Two options control exactly when the callback fires.\u003C\u002Fp>",{"id":149,"html":150,"type":86},"b20","\u003Cp>\u003Cstrong>\u003Ccode>threshold\u003C\u002Fcode>\u003C\u002Fstrong> is a number between 0 and 1 — the fraction of the element that must be visible. The default is \u003Ccode>0\u003C\u002Fcode>, which fires the moment a single pixel enters the viewport. \u003Ccode>threshold: 1\u003C\u002Fcode> waits until the element is fully visible. Pass an array to fire at multiple milestones:\u003C\u002Fp>",{"id":152,"code":153,"type":90,"language":91,"highlight":154},"b21","const observer = new IntersectionObserver(callback, {\n  threshold: [0, 0.25, 0.5, 0.75, 1],\n});",[],{"id":156,"html":157,"type":86},"b22","\u003Cp>\u003Ccode>entry.intersectionRatio\u003C\u002Fcode> tells you the actual fraction at the moment of the callback — useful for video autoplay, progressive reveal animations, or impression-depth analytics.\u003C\u002Fp>",{"id":159,"html":160,"type":86},"b23","\u003Cp>\u003Cstrong>\u003Ccode>rootMargin\u003C\u002Fcode>\u003C\u002Fstrong> works like a CSS margin around the viewport boundary, shifting where &quot;visible&quot; begins:\u003C\u002Fp>",{"id":162,"code":163,"type":90,"language":91,"highlight":164},"b24","const observer = new IntersectionObserver(callback, {\n  rootMargin: '0px 0px 300px 0px', \u002F\u002F fire 300px before entering from the bottom\n});",[],{"id":166,"html":167,"type":86},"b25","\u003Cp>This is the correct tool for preloading. Trigger the network request 300px before the element reaches the viewport, so it&#39;s ready when the user gets there — without any manual distance math.\u003C\u002Fp>",{"id":169,"html":170,"text":170,"type":102,"level":31},"b26","Analytics impression tracking",{"id":172,"html":173,"type":86},"b27","\u003Cp>Knowing whether a user \u003Cem>actually saw\u003C\u002Fem> a piece of content, versus just scrolling past it, is a common product requirement. With a scroll listener you&#39;re sampling at intervals and accepting inaccuracy. With \u003Ccode>IntersectionObserver\u003C\u002Fcode>:\u003C\u002Fp>",{"id":175,"code":176,"type":90,"language":91,"highlight":177},"b28","const impressionObserver = new IntersectionObserver((entries) => {\n  entries.forEach(entry => {\n    if (!entry.isIntersecting) return;\n    analytics.track('impression', {\n      id: entry.target.dataset.id,\n      ratio: entry.intersectionRatio,\n    });\n    impressionObserver.unobserve(entry.target);\n  });\n}, { threshold: 0.5 }); \u002F\u002F at least half the element must be visible\n\ndocument.querySelectorAll('[data-track]').forEach(el => impressionObserver.observe(el));",[],{"id":179,"html":180,"type":86},"b29","\u003Cp>The \u003Ccode>threshold: 0.5\u003C\u002Fcode> option means a fast scroll past an element doesn&#39;t count — the user had to slow down enough for half of it to enter view. One observer, zero scroll event listeners, accurate data.\u003C\u002Fp>",{"id":182,"html":183,"text":183,"type":102,"level":31},"b30","Cleanup in components",{"id":185,"html":186,"type":86},"b31","\u003Cp>In React, Vue, or any component that mounts and unmounts, disconnect the observer on teardown:\u003C\u002Fp>",{"id":188,"code":189,"type":90,"language":91,"highlight":190},"b32","useEffect(() => {\n  const observer = new IntersectionObserver(callback);\n  observer.observe(ref.current);\n\n  return () => observer.disconnect(); \u002F\u002F stops watching all elements\n}, []);",[],{"id":192,"html":193,"type":86},"b33","\u003Cp>\u003Ccode>disconnect()\u003C\u002Fcode> removes all observed elements at once. If you have a single long-lived observer watching multiple elements and only want to stop tracking one, \u003Ccode>unobserve(element)\u003C\u002Fcode> does that without affecting the rest.\u003C\u002Fp>",{"id":195,"html":196,"type":86},"b34","\u003C!-- quiz:start -->",{"id":198,"html":199,"text":199,"type":102,"level":31},"b35","🧠 Test yourself",{"id":201,"html":202,"type":86},"b36","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fdaily-post-dev.netlify.app\u002Fquiz\u002Ftake.html?post=2026-07-19-intersection-observer-visibility\">Take the 6-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":204,"html":205,"type":86},"b37","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":207,"html":208,"type":86},"b38","\u003C!-- quiz:end -->",{"id":210,"html":211,"text":211,"type":102,"level":31},"b39","The takeaway",{"id":213,"html":214,"type":86},"b40","\u003Cp>\u003Ccode>IntersectionObserver\u003C\u002Fcode> answers the question &quot;is this element visible?&quot; without running on every scroll tick. The main thread is not involved — the browser handles the geometry internally and calls your callback only when the answer changes.\u003C\u002Fp>",{"id":216,"html":217,"type":86},"b41","\u003Cp>Search your codebase for scroll event listeners that contain \u003Ccode>getBoundingClientRect\u003C\u002Fcode>. Every one of them is a candidate for replacement. The handler runs less often, the layout thrash disappears, and the intent is clearer in the code: you&#39;re watching for visibility, and the observer is the right name for that.\u003C\u002Fp>",{"id":219,"type":220},"b42","divider",{"id":222,"html":223,"type":86},"b43","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":225,"type":226,"items":227,"ordered":18},"b44","list",[228,229,230,231,232],"⭐ \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>","💼 \u003Cstrong>LinkedIn\u003C\u002Fstrong> — \u003Ca href=\"https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fparsa-jiravand\u002F\">linkedin.com\u002Fin\u002Fparsa-jiravand\u003C\u002Fa>","✉️ \u003Cstrong>Email\u003C\u002Fstrong> (work &amp; contract inquiries): \u003Ca href=\"mailto:bestpractice2026@gmail.com\">bestpractice2026@gmail.com\u003C\u002Fa>","Here is a pattern I still see in real codebases:\n\n```js\nwindow.addEventListener('scroll', () => {\n  const rect = element.getBoundingClientRect();\n  if (rect.top \u003C window.innerHeight && rect.bottom >= 0) {\n    loadImage(element);\n  }\n});\n```\n\nIt works. It's also firing on every pixel of scroll movement — potentially 60 callbacks per second on a smooth display — and each one calls `getBoundingClientRect()`, which forces a synchronous layout recalculation. You probably added throttling or `requestAnimationFrame` wrapping to soften the blow. You're still doing layout work on the main thread to answer a question the browser already knows the answer to.\n\n`IntersectionObserver` has been baseline since 2019. It's still underused.\n\n## What it does\n\n`IntersectionObserver` watches one or more elements and fires a callback *only when their visibility status changes* — entering the viewport, leaving it, or crossing a fraction threshold you specify. The geometry calculations happen off the main thread; you get called when something meaningful happens.\n\n```js\nconst observer = new IntersectionObserver((entries) => {\n  entries.forEach(entry => {\n    if (entry.isIntersecting) {\n      console.log(entry.target, 'entered the viewport');\n    }\n  });\n});\n\nobserver.observe(document.querySelector('.card'));\n```\n\n`entries` is an array of `IntersectionObserverEntry` objects — one per observed element that changed state in this tick. `entry.isIntersecting` is `true` when the element entered, `false` when it left.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fdaily-post-dev.netlify.app\u002Fposts\u002F2026-07-19-intersection-observer-visibility\u002Fplayground\u002F)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## Lazy loading an image\n\nThe classic use case. No scroll event, no position math:\n\n```js\nconst lazyImages = document.querySelectorAll('img[data-src]');\n\nconst imageObserver = new IntersectionObserver((entries) => {\n  entries.forEach(entry => {\n    if (!entry.isIntersecting) return;\n\n    const img = entry.target;\n    img.src = img.dataset.src;\n    imageObserver.unobserve(img); \u002F\u002F done watching this one\n  });\n});\n\nlazyImages.forEach(img => imageObserver.observe(img));\n```\n\nThe observer fires once when each image enters the viewport. After setting `src`, `unobserve` drops the element from the watch list. No cleanup loop, no residual listener, no memory leak.\n\n## `threshold` and `rootMargin`\n\nTwo options control exactly when the callback fires.\n\n**`threshold`** is a number between 0 and 1 — the fraction of the element that must be visible. The default is `0`, which fires the moment a single pixel enters the viewport. `threshold: 1` waits until the element is fully visible. Pass an array to fire at multiple milestones:\n\n```js\nconst observer = new IntersectionObserver(callback, {\n  threshold: [0, 0.25, 0.5, 0.75, 1],\n});\n```\n\n`entry.intersectionRatio` tells you the actual fraction at the moment of the callback — useful for video autoplay, progressive reveal animations, or impression-depth analytics.\n\n**`rootMargin`** works like a CSS margin around the viewport boundary, shifting where \"visible\" begins:\n\n```js\nconst observer = new IntersectionObserver(callback, {\n  rootMargin: '0px 0px 300px 0px', \u002F\u002F fire 300px before entering from the bottom\n});\n```\n\nThis is the correct tool for preloading. Trigger the network request 300px before the element reaches the viewport, so it's ready when the user gets there — without any manual distance math.\n\n## Analytics impression tracking\n\nKnowing whether a user *actually saw* a piece of content, versus just scrolling past it, is a common product requirement. With a scroll listener you're sampling at intervals and accepting inaccuracy. With `IntersectionObserver`:\n\n```js\nconst impressionObserver = new IntersectionObserver((entries) => {\n  entries.forEach(entry => {\n    if (!entry.isIntersecting) return;\n    analytics.track('impression', {\n      id: entry.target.dataset.id,\n      ratio: entry.intersectionRatio,\n    });\n    impressionObserver.unobserve(entry.target);\n  });\n}, { threshold: 0.5 }); \u002F\u002F at least half the element must be visible\n\ndocument.querySelectorAll('[data-track]').forEach(el => impressionObserver.observe(el));\n```\n\nThe `threshold: 0.5` option means a fast scroll past an element doesn't count — the user had to slow down enough for half of it to enter view. One observer, zero scroll event listeners, accurate data.\n\n## Cleanup in components\n\nIn React, Vue, or any component that mounts and unmounts, disconnect the observer on teardown:\n\n```js\nuseEffect(() => {\n  const observer = new IntersectionObserver(callback);\n  observer.observe(ref.current);\n\n  return () => observer.disconnect(); \u002F\u002F stops watching all elements\n}, []);\n```\n\n`disconnect()` removes all observed elements at once. If you have a single long-lived observer watching multiple elements and only want to stop tracking one, `unobserve(element)` does that without affecting the rest.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 6-question quiz →](https:\u002F\u002Fdaily-post-dev.netlify.app\u002Fquiz\u002Ftake.html?post=2026-07-19-intersection-observer-visibility)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\n## The takeaway\n\n`IntersectionObserver` answers the question \"is this element visible?\" without running on every scroll tick. The main thread is not involved — the browser handles the geometry internally and calls your callback only when the answer changes.\n\nSearch your codebase for scroll event listeners that contain `getBoundingClientRect`. Every one of them is a candidate for replacement. The handler runs less often, the layout thrash disappears, and the intent is clearer in the code: you're watching for visibility, and the observer is the right name for that.\n\n---\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)\n- 💼 **LinkedIn** — [linkedin.com\u002Fin\u002Fparsa-jiravand](https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fparsa-jiravand\u002F)\n- ✉️ **Email** (work & contract inquiries): [bestpractice2026@gmail.com](mailto:bestpractice2026@gmail.com)",{"title":235,"canonical":236,"description":237},"Your scroll listener fires on every pixel. `IntersectionObserver` fire","https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fintersection-observer-visibility","Detecting whether an element is in the viewport by listening to scroll events runs an expensive layout calculation on every frame. `IntersectionObserver` is the browser-native alte","019fe660-503a-7133-b7b0-80bdca1ae636",{"id":240,"locked":18},"019fe660-5759-7723-8d7f-95aa26fa1247",[242],{"id":33,"slug":34,"title":36,"_count":243},{"questions":39},[245],{"locale":13,"slug":34},{"id":33,"slug":34,"title":36,"_count":247,"questionCount":39},{"questions":39}]