{"id":137,"date":"2026-10-11T05:44:39","date_gmt":"2026-10-11T05:44:39","guid":{"rendered":"https:\/\/webdevpuneet.com\/blog\/?p=137"},"modified":"2026-10-11T05:44:42","modified_gmt":"2026-10-11T05:44:42","slug":"how-to-detect-when-an-element-enters-the-viewport-without-a-library","status":"publish","type":"post","link":"https:\/\/webdevpuneet.com\/blog\/how-to-detect-when-an-element-enters-the-viewport-without-a-library\/","title":{"rendered":"How to Detect When an Element Enters the Viewport Without a Library"},"content":{"rendered":"<p><!--\n================================================================\n  POST TITLE (paste into the WordPress title field \u2014 it becomes the page H1)\n  How to Detect When an Element Enters the Viewport Without a Library\n\n  SEO TITLE (55 chars, target 50\u201360, max 60 \u2014 \"Optimize SEO\" panel \u2192 SEO TITLE)\n  Detect When an Element Enters the Viewport (No Library)\n\n  SEO DESCRIPTION (151 chars, target 150\u2013160, max 160 \u2014 \"Optimize SEO\" panel \u2192 SEO DESCRIPTION)\n  Use IntersectionObserver to detect elements entering the viewport: fade-ins, lazy images, infinite scroll, scrollspy and auto-pause, with 8 live demos.\n\n  HOW TO PASTE: click the first line after this comment, press Ctrl+Shift+End, Ctrl+C.\n  In WordPress click \"Type \/ to choose a block\", then Ctrl+V.\n  Fallback: \u22ee menu \u2192 Code editor \u2192 paste.\n================================================================\n--><\/p>\n\n\n<p class=\"wp-block-paragraph\">The classic way to know when something scrolls into view is a <code>scroll<\/code> event listener that calls <code>getBoundingClientRect()<\/code> on every element you care about, on every single scroll frame \u2014 forcing a synchronous layout recalculation each time, dozens of times a second, for every element being watched. It works, but it&#8217;s the kind of code that quietly tanks scroll performance on a long page and gets worse the more things you watch. The <code>IntersectionObserver<\/code> API replaces all of that: you describe what &#8220;in view&#8221; means once, hand it a set of elements, and the browser tells you \u2014 asynchronously, without your code ever forcing a synchronous layout recalculation to find out \u2014 exactly when each one crosses that line. No library, no polling, and it&#8217;s been supported in every major browser for years.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The eight demos below are all real, interactive and scrollable \u2014 scroll inside each one and watch it react live. Every embed also has a <strong>Fork &amp; Edit<\/strong> button that opens the exact snippet, already running, in <a href=\"https:\/\/webdevpuneet.com\/ui-snippets\/mycode\/\" target=\"_blank\" rel=\"noopener\">My Code<\/a> \u2014 webdevpuneet&#8217;s free in-browser editor. Change a threshold, a rootMargin, or a class name and watch what actually happens \u2014 that&#8217;s a faster way to build real intuition for this API than any explanation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If vanilla JS and browser APIs in general are still shaky ground, the <a href=\"https:\/\/webdevpuneet.com\/js-playground\/\" target=\"_blank\" rel=\"noopener\">JavaScript Playground<\/a> is worth having open alongside this post \u2014 a free, click-based, in-browser course that runs beginner to pro, with dedicated chapters covering the DOM and browser APIs as a guided curriculum.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why IntersectionObserver beats a scroll listener<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A <code>scroll<\/code> handler runs constantly, whether or not anything interesting actually happened, and every <code>getBoundingClientRect()<\/code> call inside it forces the browser to recompute layout synchronously \u2014 the exact kind of work that causes visible jank on a busy page. <code>IntersectionObserver<\/code> flips the model entirely: you register a callback once, and the browser calls it only when an element&#8217;s visibility actually crosses a threshold you defined, computed asynchronously and batched efficiently regardless of how many elements you&#8217;re watching. The cost grows far more gently than a scroll listener&#8217;s as you watch more elements, because the browser does the geometry work in bulk. That single architectural difference is why it&#8217;s the right default answer for scroll-triggered animation, lazy loading, infinite scroll, and visibility tracking of every kind \u2014 the eight demos below are eight different shapes the same one API takes.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">1. Fade-in on scroll<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Each card fades and slides up the moment it enters view, once, and never re-triggers on a second pass.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/09\/1.html\" style=\"width:100%;height:420px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Fade-in on Scroll Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The rule:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>var io = new IntersectionObserver(function (entries) {\n  entries.forEach(function (entry) {\n    if (!entry.isIntersecting) return;\n    entry.target.classList.add('in');\n    io.unobserve(entry.target); \/\/ animate once, then stop watching it\n  });\n}, { threshold: 0.2 });\n\ndocument.querySelectorAll('.reveal').forEach(function (el) { io.observe(el); });<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The callback receives an array of entries, not just one \u2014 if several observed elements cross the threshold in the same frame, they all arrive together in a single call, which is part of why this scales so well. Calling <code>unobserve()<\/code> the moment an element has done its job (rather than leaving it watched forever) is what keeps a page with hundreds of reveal animations from accumulating hundreds of live observers it no longer needs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> scroll-triggered entrance animations on landing pages, portfolios, and long-form content. <strong>Tip:<\/strong> a <code>threshold<\/code> of <code>0.2<\/code> here means &#8220;20% of the element must be visible&#8221; \u2014 raise it toward <code>1<\/code> for an animation that should only fire once something is almost fully on screen.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">2. Lazy-loading images<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every image starts with an empty <code>src<\/code> and a placeholder pattern. Scroll, and each one&#8217;s real <code>data-src<\/code> only gets assigned once it&#8217;s about to enter view.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/09\/2.html\" style=\"width:100%;height:440px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Lazy-Loading Images Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The rule:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>var io = new IntersectionObserver(function (entries) {\n  entries.forEach(function (entry) {\n    if (!entry.isIntersecting) return;\n    var img = entry.target;\n    io.unobserve(img);\n    img.src = img.dataset.src; \/\/ the real network request starts here\n  });\n}, { rootMargin: '200px 0px' }); \/\/ start loading before it's actually visible\n\ndocument.querySelectorAll('.lazy').forEach(function (img) { io.observe(img); });<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>rootMargin<\/code> grows or shrinks the trigger area independently of the element&#8217;s actual visible box \u2014 a positive value here means the image starts loading 200px before it would otherwise be considered &#8220;in view,&#8221; so by the time a visitor actually scrolls to it, it&#8217;s often already there. This is the same underlying technique the browser&#8217;s own native <code>loading=\"lazy\"<\/code> attribute uses; reach for <code>IntersectionObserver<\/code> directly when you need more control than that attribute gives you \u2014 a loaded-count readout like this demo&#8217;s, a custom placeholder swap, or coordinating the load with something else on the page.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> image-heavy pages, galleries, and feeds where loading every image up front would waste bandwidth on images the visitor may never scroll to. <strong>Tip:<\/strong> for plain <code>&lt;img&gt;<\/code> lazy-loading with no extra logic attached, <code>loading=\"lazy\"<\/code> as a native HTML attribute needs zero JavaScript at all \u2014 this pattern earns its keep once you need custom behavior on top.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">3. Infinite scroll list<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A one-pixel-tall sentinel below the last row is what triggers loading the next batch \u2014 no scroll or resize listener anywhere.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/09\/3.html\" style=\"width:100%;height:420px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Infinite Scroll List Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The rule:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>var io = new IntersectionObserver(function (entries) {\n  if (entries&#91;0].isIntersecting) {\n    loadNextPage(); \/\/ a real fetch() call, in practice\n  }\n}, { rootMargin: '150px' }); \/\/ fetch before the sentinel is fully on-screen\n\nio.observe(sentinelElement);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The sentinel itself renders nothing \u2014 it&#8217;s an empty element that exists purely as a trigger. Watching it instead of watching every row individually means the observer count stays at exactly one no matter how many rows have loaded, which is what keeps this pattern cheap at any list length. This is the same core technique behind the auto-loading list in webdevpuneet&#8217;s own <a href=\"https:\/\/webdevpuneet.com\/ui-snippets\/\" target=\"_blank\" rel=\"noopener\">UI Snippets<\/a> sidebar \u2014 there, the observer watches the &#8220;Load next&#8221; button itself rather than a separate invisible element, so the button doubles as its own trigger and stays in the DOM as a manual fallback if the observer never fires.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> feeds, search results, and any list too long to load in one request. <strong>Tip:<\/strong> keep the manual &#8220;Load more&#8221; button in the DOM alongside the sentinel, hidden or not, as a fallback for the rare case a reader&#8217;s browser or extension blocks the observer \u2014 it costs nothing and guarantees the list is never permanently stuck.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">4. Scrollspy nav highlighting<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Scroll and watch the nav bar above react \u2014 no click needed. Whichever section is most visible gets the highlight.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/09\/4.html\" style=\"width:100%;height:420px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Scrollspy Nav Highlighting Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The rule:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>var io = new IntersectionObserver(function (entries) {\n  entries.forEach(function (entry) {\n    ratios&#91;entry.target.id] = entry.isIntersecting ? entry.intersectionRatio : 0;\n  });\n  var bestId = Object.keys(ratios).reduce(function (a, b) {\n    return ratios&#91;a] &gt; ratios&#91;b] ? a : b;\n  });\n  highlightNavLink(bestId);\n}, { threshold: &#91;0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1] });\n\nsections.forEach(function (s) { io.observe(s); });<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A single boolean \u2014 <code>isIntersecting<\/code> \u2014 isn&#8217;t enough here, because two adjacent sections are routinely both partly on screen at once during a scroll. Passing an array of thresholds instead of one number makes the callback fire again at every 10% step of visibility change, giving a live <code>intersectionRatio<\/code> precise enough to compare sections against each other and pick a genuine winner, rather than just flipping a boolean.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> &#8220;you are here&#8221; table-of-contents navs on docs sites and long-form articles. <strong>Tip:<\/strong> a dense threshold array like this one costs more callback invocations than a single <code>0.5<\/code> \u2014 fine for a handful of sections, but worth trimming for a page watching dozens of them.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">5. Count-up stats on scroll<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Each number counts up from zero the instant it comes into view, once, and never restarts if you scroll past it again.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/09\/5.html\" style=\"width:100%;height:420px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Count-Up Stats on Scroll Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The rule:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>var io = new IntersectionObserver(function (entries) {\n  entries.forEach(function (entry) {\n    if (!entry.isIntersecting) return;\n    animateCountUp(entry.target);\n    io.unobserve(entry.target); \/\/ count once \u2014 never restart on re-entry\n  });\n}, { threshold: 0.6 });\n\ndocument.querySelectorAll('.num').forEach(function (el) { io.observe(el); });<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The count-up animation itself is a separate concern, driven by <code>requestAnimationFrame<\/code> with an eased progress curve \u2014 <code>IntersectionObserver<\/code>&#8216;s only job is deciding the one moment to kick it off. That separation is worth keeping in every demo on this page: the observer decides <em>when<\/em>, plain DOM\/CSS\/animation code decides <em>what<\/em>. A <code>threshold<\/code> of <code>0.6<\/code> here means a stat card has to be genuinely mostly on screen before its number starts moving, not just barely peeking into view.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> landing-page stat sections, dashboards, and anywhere a number should feel like it&#8217;s &#8220;counting up&#8221; the first time a visitor sees it. <strong>Tip:<\/strong> always <code>unobserve()<\/code> after the animation starts \u2014 without it, scrolling past the stat and back triggers the count-up again every single time, which reads as broken rather than intentional.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">6. Floating button on scroll<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A &#8220;Back to top&#8221; button stays hidden while the hero is on screen, then fades in the instant it scrolls away.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/09\/6.html\" style=\"width:100%;height:440px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Floating Button on Scroll Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The rule:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>var io = new IntersectionObserver(function (entries) {\n  \/\/ isIntersecting is false the instant the hero has fully scrolled away \u2014\n  \/\/ that single boolean is the entire \"should the button show\" condition.\n  toggleBtn.classList.toggle('show', !entries&#91;0].isIntersecting);\n}, { threshold: 0 });\n\nio.observe(heroElement);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This demo watches the hero, not the button \u2014 the button&#8217;s visibility is just the inverse of the hero&#8217;s. That&#8217;s a genuinely useful reframing for a lot of &#8220;show this when scrolled past that&#8221; UI: rather than tracking a scroll position number and comparing it to some pixel threshold you have to update every time the layout changes, watch the actual element that marks the boundary and let the browser tell you when it&#8217;s gone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> back-to-top buttons, sticky &#8220;buy now&#8221; bars, and any UI that should only appear once a visitor has scrolled past a specific landmark. <strong>Tip:<\/strong> <code>threshold: 0<\/code> means &#8220;any pixel of the hero visible at all counts as intersecting&#8221; \u2014 the button won&#8217;t show until the hero is completely, not just mostly, off screen.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">7. Auto-pause off-screen<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This visualizer is &#8220;playing.&#8221; Scroll it out of view and it pauses itself; scroll back and it resumes \u2014 the same pattern a background video or audio player uses.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/09\/7.html\" style=\"width:100%;height:440px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Auto-Pause Off-Screen Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The rule:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>var io = new IntersectionObserver(function (entries) {\n  if (entries&#91;0].isIntersecting) { video.play(); }\n  else { video.pause(); }\n}, { threshold: 0.5 });\n\nio.observe(video);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Autoplaying video is a common source of wasted CPU, battery, and bandwidth on a long page \u2014 a background hero video or an audio visualizer still running while scrolled three screens away serves nobody. The same technique works for pausing a purely visual animation loop off-screen too \u2014 toggle a CSS class instead of calling <code>play()<\/code>\/<code>pause()<\/code>, and the rest of the logic is identical.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> background\/hero videos, audio visualizers, and any looping animation expensive enough that it shouldn&#8217;t run while nobody can see it. <strong>Tip:<\/strong> a <code>threshold<\/code> around <code>0.5<\/code> avoids a distracting play\/pause flicker right at the edge of the viewport that a <code>threshold: 0<\/code> would cause.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">8. threshold &amp; rootMargin visualizer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Drag the slider, then scroll the box through the striped zone. The readout is a real observer reporting its live <code>intersectionRatio<\/code> on every threshold step it crosses.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/09\/8.html\" style=\"width:100%;height:460px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"threshold and rootMargin Visualizer Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The rule:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>var io = new IntersectionObserver(callback, {\n  root: null,          \/\/ null = the browser viewport (or the nearest scrollable ancestor)\n  rootMargin: '0px',   \/\/ shrinks\/grows the trigger box, CSS-margin syntax\n  threshold: &#91;0, 0.25, 0.5, 0.75, 1] \/\/ fire again at each of these visibility steps\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">These three options are the entire API surface, and they&#8217;re read once, at construction \u2014 there&#8217;s no method to update them on a live observer, which is why the slider in this demo tears down and rebuilds the observer on every change rather than mutating one in place. <code>root<\/code> defaults to the browser viewport but can be any scrollable ancestor element, which is exactly what makes this work correctly inside a scrolling card, modal, or sidebar rather than only the whole page. A negative <code>rootMargin<\/code> shrinks the trigger zone inward \u2014 useful for &#8220;must be well inside the viewport, not just barely peeking in&#8221; logic \u2014 while a positive one, as demo #2 uses, grows it outward for early triggering.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> debugging exactly why an observer is firing earlier or later than expected, and building intuition before tuning the real thing in demos #1\u20137. <strong>Tip:<\/strong> when in doubt, start with <code>threshold: 0<\/code> (fires on any visibility at all) and a single number rather than an array \u2014 reach for an array only once you genuinely need multiple callback firings across a visibility range, as demo #4&#8217;s scrollspy does.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Common pitfalls<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Forgetting to call <code>unobserve()<\/code> after a one-time effect.<\/strong> A fade-in, a lazy image swap, or a count-up animation should only ever fire once per element \u2014 without <code>unobserve()<\/code>, scrolling an element out and back triggers it again, every time, which reads as a bug rather than a feature.<\/li>\n\n\n\n<li><strong>Trying to change <code>threshold<\/code> or <code>rootMargin<\/code> on a live observer.<\/strong> Both are read once at construction and cannot be updated afterward \u2014 changing either genuinely requires disconnecting the old observer and creating a new one, as demo #8 does on every slider move.<\/li>\n\n\n\n<li><strong>Reaching for <code>threshold: 1<\/code> and expecting it to reliably fire.<\/strong> An element taller than the viewport (or its <code>root<\/code>) can never reach 100% visibility at once, so a threshold of exactly <code>1<\/code> may never trigger for it \u2014 a fractional threshold like <code>0.9<\/code> is almost always the safer choice for large elements.<\/li>\n\n\n\n<li><strong>Watching hundreds of individual elements instead of one sentinel.<\/strong> Demo #3&#8217;s infinite scroll watches a single empty sentinel element, not every row \u2014 watching every row individually works but scales far worse for no real benefit once a sentinel does the same job for one observer&#8217;s worth of cost.<\/li>\n\n\n\n<li><strong>Assuming <code>isIntersecting<\/code> alone is enough for &#8220;which is most visible.&#8221;<\/strong> When two elements are both partially on screen, as in demo #4&#8217;s scrollspy, only comparing their <code>intersectionRatio<\/code> values against each other tells you which one is genuinely more visible right now.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently asked questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Does IntersectionObserver work inside a scrolling container, not just the page?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes \u2014 pass that container element as the <code>root<\/code> option instead of leaving it at the default <code>null<\/code> (which means the browser viewport). The element being observed still needs to be a descendant of that root for intersection to be measured correctly.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can I observe the same element with more than one observer?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, and it&#8217;s a completely normal pattern \u2014 each observer runs independently with its own options and callback. Demo #6&#8217;s floating button and a hypothetical analytics tracker could both watch the same hero element with two separate observers and neither would interfere with the other.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Is browser support good enough to use this without a fallback?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>IntersectionObserver<\/code> has shipped in every major browser (Chrome, Firefox, Safari, Edge) for several years now and needs no polyfill for the vast majority of real-world traffic. Check <a href=\"https:\/\/caniuse.com\/intersectionobserver\" target=\"_blank\" rel=\"noopener\">caniuse.com<\/a> for current numbers if a project specifically needs to support very old browser versions.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What&#8217;s the difference between isIntersecting and intersectionRatio?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>isIntersecting<\/code> is a plain boolean \u2014 is any part of the element overlapping the root right now. <code>intersectionRatio<\/code> is the actual number behind that boolean, from <code>0<\/code> to <code>1<\/code>, representing exactly what fraction of the element is currently visible. Most simple show\/hide logic only needs the boolean, as in demos #1, #2, #5, #6, and #7; anything comparing degrees of visibility against each other, like demo #4&#8217;s scrollspy, needs the real ratio.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should I use this instead of the native loading=&#8221;lazy&#8221; attribute for images?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Not if plain lazy-loaded images are all you need \u2014 <code>loading=\"lazy\"<\/code> on an <code>&lt;img&gt;<\/code> tag does that natively with zero JavaScript. Reach for <code>IntersectionObserver<\/code>, as demo #2 does, once you need something the attribute alone can&#8217;t give you: a custom placeholder swap, a loaded-count readout, coordinating the load with an animation, or lazy-loading something that isn&#8217;t a plain image at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Every demo above ships its full HTML, CSS and JS in the <strong>HTML \/ CSS \/ JS<\/strong> tabs in its own top bar. The fastest way to actually internalize the differences is to click <strong>Fork &amp; Edit<\/strong> on demo #1, change its <code>threshold<\/code> from <code>0.2<\/code> to <code>0.9<\/code>, and watch exactly how much further you now have to scroll before the card animates in. Once you&#8217;ve got a version you like, save it to <a href=\"https:\/\/webdevpuneet.com\/ui-snippets\/mycode\/\" target=\"_blank\" rel=\"noopener\">My Code<\/a> and it&#8217;s yours to keep, tweak further, or reuse in a real project.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Recommended next step:<\/strong> open the <a href=\"https:\/\/webdevpuneet.com\/js-playground\/\" target=\"_blank\" rel=\"noopener\">JavaScript Playground<\/a> and work through its DOM, Browser APIs and Observers chapters \u2014 free, click-based lessons covering exactly this kind of ground, built for the pattern this post covers. For more tutorials like this one, browse the <a href=\"https:\/\/webdevpuneet.com\/blog\/category\/javascript\/\" target=\"_blank\" rel=\"noopener\">JavaScript<\/a> category on the blog.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>The classic way to know when something scrolls into view is a scroll event listener that calls getBoundingClientRect() on every element you care about, on every single scroll frame \u2014 forcing a synchronous layout recalculation each time, dozens of times a second, for every element being watched. It works, but it&#8217;s the kind of code [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":138,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_et_pb_use_builder":"","_et_pb_old_content":"","_et_gb_content_width":"","advanced_seo_description":"Use IntersectionObserver to detect elements entering the viewport: fade-ins, lazy images, infinite scroll, scrollspy and auto-pause, with 8 live demos.","jetpack_seo_html_title":"Detect When an Element Enters the Viewport (No Library)","jetpack_seo_noindex":false,"jetpack_seo_schema_type":"","_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_feature_clip_id":0,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":false,"jetpack_social_options":{"image_generator_settings":{"template":"highway","default_image_id":0,"font":"","enabled":false},"version":2},"jetpack_post_was_ever_published":false},"categories":[7,9],"tags":[],"class_list":["post-137","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-javascript","category-tutorials","et-has-post-format-content","et_post_format-et-post-format-standard"],"jetpack_publicize_connections":[],"jetpack_sharing_enabled":true,"jetpack_likes_enabled":true,"jetpack_shortlink":"https:\/\/wp.me\/phrh5V-2d","jetpack_featured_media_url":"https:\/\/i0.wp.com\/webdevpuneet.com\/blog\/wp-content\/uploads\/2026\/10\/how-to-detect-element-viewport-header-1200x630-compressed.jpg?fit=1200%2C630&ssl=1","_links":{"self":[{"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/posts\/137","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/comments?post=137"}],"version-history":[{"count":1,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/posts\/137\/revisions"}],"predecessor-version":[{"id":139,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/posts\/137\/revisions\/139"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/media\/138"}],"wp:attachment":[{"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/media?parent=137"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/categories?post=137"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/tags?post=137"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}