The JavaScript Feature That’s Been in Your Browser for 3 Years and You’ve Never Used

The Intersection Observer, View Transitions API, CSS Custom Highlight API, Popover API, and Speculation Rules — five native browser APIs that eliminate entire npm packages. Your bundle is bigger than it needs to be.


The JavaScript ecosystem has a reflexive response to almost any UI requirement: install a package. Want to detect when an element enters the viewport? Install a scroll-detection library. Want a tooltip? Install Floating UI or Tippy.js. Want to highlight search results in text? Install a DOM manipulation library. Want page transition animations? Install a routing animation package.

Most of the time, that response is wrong — not because the packages are bad, but because the browser already ships the answer. It has for years.

This post covers five browser APIs that are well-supported, production-ready, and criminally underused. For each one, the comparison is simple: this is what you were installing npm packages to do, and this is what the browser can do natively for free.


1. The Intersection Observer API

Replaces: scroll event listeners, manual bounding rect calculations, is-in-viewport packages, lazy-loading libraries

Baseline: Widely Available — all major browsers since 2016–2018

The Old Way That Everyone Writes

// ✗ The pattern still in most codebases — expensive, janky, wrong
window.addEventListener('scroll', () => {
  const elements = document.querySelectorAll('.animate-on-scroll')

  elements.forEach(el => {
    const rect = el.getBoundingClientRect()

    // getBoundingClientRect() triggers layout — called on EVERY scroll event
    if (rect.top < window.innerHeight && rect.bottom > 0) {
      el.classList.add('visible')
    }
  })
}, { passive: true })

This fires on every scroll event (potentially 60+ times per second), calls getBoundingClientRect() on every element on every event (which triggers layout recalculation), and runs entirely on the main thread. At scale, this causes scroll jank.

The Native Way — IntersectionObserver

// ✓ IntersectionObserver — runs off the main thread, no layout thrashing
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        entry.target.classList.add('visible')
        observer.unobserve(entry.target)  // stop observing after first intersection
      }
    })
  },
  {
    root:       null,    // viewport
    rootMargin: '0px',   // no offset
    threshold:  0.15,    // fire when 15% of the element is visible
  }
)

// Observe all target elements
document.querySelectorAll('.animate-on-scroll').forEach(el => observer.observe(el))

The browser handles the geometry — no getBoundingClientRect(), no scroll listener, no main thread blocking. The callback fires only when elements cross the threshold.

Lazy Loading Images — The Correct Implementation

<!-- Even simpler — native lazy loading for images -->
<img src="product.jpg" loading="lazy" alt="Product photo" />

<!-- For more control — IntersectionObserver with a placeholder -->
<img
  data-src="high-res-product.jpg"
  src="placeholder-blur.jpg"
  class="lazy-image"
  alt="Product photo"
/>
const imageObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target
      img.src = img.dataset.src            // swap to real source
      img.classList.add('loaded')
      imageObserver.unobserve(img)
    }
  })
}, { rootMargin: '200px' })  // start loading 200px before entering viewport

document.querySelectorAll('img.lazy-image').forEach(img => imageObserver.observe(img))

Infinite Scroll — Clean Implementation

// Observe a sentinel element at the bottom of the list
const sentinel = document.querySelector('#load-more-sentinel')

const infiniteScrollObserver = new IntersectionObserver(async (entries) => {
  if (entries[0].isIntersecting && !isLoading) {
    isLoading = true
    await loadMoreItems()
    isLoading = false
  }
}, { rootMargin: '100px' })

infiniteScrollObserver.observe(sentinel)

Multiple Thresholds — Progress Tracking

// Fire at 0%, 25%, 50%, 75%, and 100% visibility
const progressObserver = new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      const visibility = Math.round(entry.intersectionRatio * 100)
      entry.target.style.opacity = entry.intersectionRatio
      entry.target.querySelector('.progress-label').textContent = `${visibility}% visible`
    })
  },
  { threshold: [0, 0.25, 0.5, 0.75, 1] }
)

2. The View Transitions API

Replaces: route animation libraries, GSAP page transitions, custom fade/slide implementations between pages or states

Baseline: Same-document transitions — Baseline Widely Available since October 2025 (Chrome, Edge, Firefox 133+, Safari 18+)

The View Transitions API captures the visual state of the page before an update, applies the update, then smoothly animates between the two states. The default animation is a cross-fade. With view-transition-name, individual elements animate independently — including shared element transitions where an element “flies” from its position on one page to its position on the next.

The Basic Pattern

// Without View Transitions — instant DOM update
function switchTab(tabId) {
  showTab(tabId)
}

// With View Transitions — cross-fade between states, no library needed
function switchTab(tabId) {
  if (!document.startViewTransition) {
    showTab(tabId)  // fallback for unsupported browsers
    return
  }

  document.startViewTransition(() => showTab(tabId))
}

That’s the entire implementation for a cross-fade transition. One function call.

Shared Element Transitions — Product Card to Detail

/* Product card in a grid */
.product-card img {
  view-transition-name: var(--product-id);  /* unique per product */
}

/* Product detail page */
.product-hero img {
  view-transition-name: var(--product-id);  /* same name = shared transition */
}

/* Optional: customise the transition for product images */
::view-transition-group(product-*) {
  animation-duration: 0.4s;
  animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
// When user clicks a product card
function navigateToProduct(productId) {
  // Set the CSS custom property to link the two elements
  document.documentElement.style.setProperty('--product-id', `product-${productId}`)

  document.startViewTransition(() => {
    renderProductDetail(productId)
  })
}

The browser handles the animation — no position tracking, no GSAP, no animation frame calculations.

Router Integration (Framework-Agnostic)

// Wrap every route navigation in a View Transition
async function navigate(url) {
  if (!document.startViewTransition) {
    await renderPage(url)
    return
  }

  const transition = document.startViewTransition(async () => {
    await renderPage(url)
  })

  // Optional: wait for transition to complete
  await transition.finished
}

Directional Slide Based on Navigation Direction

let navigationDirection = 'forward'

async function navigateForward(url) {
  navigationDirection = 'forward'
  document.startViewTransition(() => renderPage(url))
}

async function navigateBack(url) {
  navigationDirection = 'back'
  document.startViewTransition(() => renderPage(url))
}
/* Apply direction class to :root before transition */
:root.nav-forward::view-transition-old(root) {
  animation: slide-out-left 0.3s ease forwards;
}
:root.nav-forward::view-transition-new(root) {
  animation: slide-in-right 0.3s ease forwards;
}

:root.nav-back::view-transition-old(root) {
  animation: slide-out-right 0.3s ease forwards;
}
:root.nav-back::view-transition-new(root) {
  animation: slide-in-left 0.3s ease forwards;
}

@keyframes slide-out-left  { to { transform: translateX(-100%); } }
@keyframes slide-in-right  { from { transform: translateX(100%);  } }
@keyframes slide-out-right { to { transform: translateX(100%);  } }
@keyframes slide-in-left   { from { transform: translateX(-100%); } }

/* Always respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

3. The CSS Custom Highlight API

Replaces: DOM-wrapping search highlight libraries, mark.js, custom range manipulation packages

Baseline: Baseline Widely Available since June 2025

The CSS Custom Highlight API lets you highlight arbitrary text ranges in a document using JavaScript to create the ranges and CSS to style them — without modifying the DOM at all. The highlights live in a separate rendering layer. No <span> wrappers, no DOM mutation, no performance cost from DOM manipulation.

The Old Way — DOM Mutation

// ✗ The mark.js / DOM-wrapping approach
function highlightText(query) {
  const content = document.getElementById('content')

  // Remove previous highlights
  content.querySelectorAll('mark').forEach(mark => {
    mark.replaceWith(mark.textContent)
  })

  if (!query) return

  // Walk the DOM, find matches, wrap in <mark> tags
  // This is fragile, slow, breaks event listeners, and modifies the DOM
  walkTextNodes(content, (node) => {
    const index = node.textContent.toLowerCase().indexOf(query.toLowerCase())
    if (index !== -1) {
      const mark = document.createElement('mark')
      const range = document.createRange()
      range.setStart(node, index)
      range.setEnd(node, index + query.length)
      range.surroundContents(mark)  // breaks if the match crosses element boundaries
    }
  })
}

This approach breaks when search terms span across HTML elements, is expensive on large documents, and invalidates the DOM subtree on every search change.

The Native Way — CSS Custom Highlight API

// ✓ CSS Custom Highlight API — no DOM mutation, works across element boundaries
function highlightSearchResults(query) {
  // Clear previous highlights
  CSS.highlights.clear()

  if (!query.trim()) return

  const ranges = []
  const walker = document.createTreeWalker(
    document.getElementById('content'),
    NodeFilter.SHOW_TEXT
  )

  // Walk all text nodes and collect matching ranges
  let node
  while (node = walker.nextNode()) {
    const text  = node.textContent.toLowerCase()
    const search = query.toLowerCase()
    let   index  = 0

    while ((index = text.indexOf(search, index)) !== -1) {
      const range = new Range()
      range.setStart(node, index)
      range.setEnd(node, index + search.length)
      ranges.push(range)
      index += search.length
    }
  }

  if (ranges.length === 0) return

  // Create a named highlight from all the ranges
  const highlight = new Highlight(...ranges)
  CSS.highlights.set('search-results', highlight)
}
/* Style the highlight using the ::highlight() pseudo-element */
::highlight(search-results) {
  background-color: #ffdd57;
  color: #1a1a1a;
}
<input type="search" oninput="highlightSearchResults(this.value)" placeholder="Search…" />
<div id="content">
  <p>This paragraph <strong>contains text</strong> that can be searched across elements.</p>
</div>

The highlight works across element boundaries — the <strong> tag doesn’t break the highlight. The DOM is never touched. Clearing highlights is instant: CSS.highlights.clear(). Performance is dramatically better on large documents.

Multiple Named Highlights — Different Styles

// Named highlights can coexist with different styles
CSS.highlights.set('current-match',  new Highlight(currentRange))
CSS.highlights.set('other-matches',  new Highlight(...otherRanges))
CSS.highlights.set('spelling-error', new Highlight(...spellErrorRanges))
CSS.highlights.set('code-keyword',   new Highlight(...keywordRanges))
::highlight(current-match)  { background: #0070f3; color: white; }
::highlight(other-matches)  { background: #ffdd57; color: #1a1a1a; }
::highlight(spelling-error) { text-decoration: wavy underline red; }
::highlight(code-keyword)   { color: #c678dd; font-weight: 600; }

4. The Popover API

Replaces: Floating UI, Tippy.js, Popper.js, custom tooltip/dropdown logic, manual z-index management, focus trap implementations

Baseline: Baseline Widely Available since April 2025 (Chrome, Firefox, Safari, Edge — all versions since early 2025)

The Popover API is a native mechanism for displaying content on top of other content. The Popover API reached Baseline Widely Available in April 2025 — meaning it now works in Chrome, Firefox, Safari, and Edge with full cross-browser support. The browser handles z-index stacking via the top layer (same layer as <dialog>), outside-click dismissal, keyboard escape handling, focus management, and accessibility attributes automatically.

Declarative Popover — Zero JavaScript

<!-- Button that triggers the popover -->
<button popovertarget="settings-menu">
  Settings
</button>

<!-- The popover — positioned relative to its anchor in CSS -->
<div id="settings-menu" popover>
  <ul>
    <li><a href="/profile">Edit Profile</a></li>
    <li><a href="/preferences">Preferences</a></li>
    <li><button>Sign Out</button></li>
  </ul>
</div>

That’s it. No JavaScript. The browser:

  • Toggles the popover open/closed on button click
  • Closes it when the user clicks outside (light dismiss)
  • Closes it on Escape key
  • Manages z-index via the top layer
  • Adds aria-expanded and popover accessibility semantics

Popover Types

<!-- Auto popover (default) — light dismiss, closes others -->
<div id="menu" popover="auto">...</div>

<!-- Manual popover — only closes when explicitly dismissed -->
<div id="toast" popover="manual">...</div>

<!-- hint popover (2026) — subordinate tooltip, doesn't close auto popovers -->
<div id="tooltip" popover="hint">...</div>

Positioning with CSS Anchor Positioning

/* Anchor the popover to its trigger button */
button[popovertarget="settings-menu"] {
  anchor-name: --settings-button;
}

#settings-menu {
  position: absolute;
  position-anchor: --settings-button;
  top: anchor(bottom);    /* position below the button */
  left: anchor(left);     /* align left edges */
  margin-top: 8px;
}

JavaScript API for Dynamic Control

const popover = document.getElementById('settings-menu')

// Open and close programmatically
popover.showPopover()
popover.hidePopover()
popover.togglePopover()

// Listen for open/close events
popover.addEventListener('toggle', (event) => {
  if (event.newState === 'open') {
    console.log('Popover opened')
    loadMenuItems()
  } else {
    console.log('Popover closed')
  }
})

Tooltip Pattern — No Library Needed

<style>
  [popover] {
    margin: 0;
    padding: 6px 10px;
    border-radius: 4px;
    background: #1a1a1a;
    color: white;
    font-size: 0.85rem;
    border: none;
    box-shadow: 0 4px 12px rgba(0,0,0,0.15);
  }

  /* Anchor positioning for tooltip */
  .has-tooltip { anchor-name: --tooltip-anchor; }
  #tooltip-content {
    position: absolute;
    position-anchor: --tooltip-anchor;
    bottom: anchor(top);
    left: anchor(center);
    translate: -50% -8px;
  }
</style>

<span class="has-tooltip" popovertarget="tooltip-content" popovertargetaction="show">
  Hover over me
  <span id="tooltip-content" popover="hint" role="tooltip">
    This is the tooltip content
  </span>
</span>

5. Speculation Rules API

Replaces: quicklink, instant.page, custom prefetch implementations, service worker prefetch strategies

Baseline: Chrome 109+, Edge 109+ (2023). Firefox and Safari support under active development.

The Speculation Rules API lets you tell the browser to prefetch or prerender pages the user is likely to visit next — before they navigate. Prerendering is stronger than prefetching: the browser renders the full page in a hidden background tab, so when the user navigates, the page appears instantly.

Basic Speculation Rules

<script type="speculationrules">
{
  "prerender": [
    {
      "where": { "href_matches": "/checkout" },
      "eagerness": "moderate"
    }
  ],
  "prefetch": [
    {
      "where": { "selector_matches": "a[href^='/products/']" },
      "eagerness": "conservative"
    }
  ]
}
</script>

Eagerness Levels

<script type="speculationrules">
{
  "prerender": [
    {
      "urls": ["/dashboard"],
      "eagerness": "immediate"
      // immediate: start as soon as the rule is parsed — high confidence navigation
    },
    {
      "where": { "selector_matches": "nav a" },
      "eagerness": "eager"
      // eager: start on link hover — medium confidence
    },
    {
      "where": { "selector_matches": "article a" },
      "eagerness": "moderate"
      // moderate: start when cursor moves toward the link — lower confidence
    },
    {
      "where": { "selector_matches": ".sidebar a" },
      "eagerness": "conservative"
      // conservative: only prefetch, don't prerender, minimal bandwidth use
    }
  ]
}
</script>

Dynamic Speculation Rules

For single-page applications or dynamically rendered content, inject speculation rules from JavaScript:

// Add speculation rules dynamically based on the current page
function addSpeculationRules(urls) {
  // Check support
  if (!HTMLScriptElement.supports('speculationrules')) return

  // Remove existing rules
  document.querySelector('script[type="speculationrules"]')?.remove()

  const script = document.createElement('script')
  script.type  = 'speculationrules'
  script.textContent = JSON.stringify({
    prerender: [{ urls, eagerness: 'moderate' }],
  })
  document.head.appendChild(script)
}

// On a product listing page — prerender the top products
const topProductLinks = [...document.querySelectorAll('.product-card a')]
  .slice(0, 5)
  .map(a => a.href)

addSpeculationRules(topProductLinks)

What Prerendering Actually Delivers

On a page with a typical server-side render time of 400ms and a JS hydration time of 600ms, prerendering delivers:

  • Without prerendering: User clicks link → 400ms server render + 600ms hydration = 1000ms TTI
  • With prerendering: User clicks link → page appears in ~50ms (swapping the prerendered page into view)

The navigation feels instant. No code changes required on the destination page.

Speculation Rules and Analytics

Prerendered pages may trigger analytics page views before the user actually navigates. Most analytics libraries handle this correctly if you fire the page view event on pageshow rather than DOMContentLoaded:

// Fire analytics on actual display, not prerender
window.addEventListener('pageshow', (event) => {
  if (!document.prerendering) {
    analytics.page()  // only fires when page is actually shown to user
  }
})

// Or use the prerendering API directly
if (document.prerendering) {
  document.addEventListener('prerenderingchange', () => {
    analytics.page()
  })
} else {
  analytics.page()
}

Browser Support Summary

APIBaseline StatusChromeFirefoxSafariNotes
IntersectionObserverWidely Available (2018)Safe to use everywhere
View Transitions (same-doc)Widely Available (Oct 2025)✅ 133+✅ 18+Fallback gracefully
CSS Custom HighlightWidely Available (Jun 2025)DOM mutation fallback
Popover APIWidely Available (Apr 2025)Safe to use everywhere
Speculation RulesPartial (Chrome/Edge 109+)Progressive enhancement only

How to Adopt These APIs Today

The Progressive Enhancement Pattern

Every one of these APIs should be adopted with a feature check. The pattern is identical for all of them:

// Feature detect, use native if available, fall back to library
function animatePageTransition(updateFn) {
  if (document.startViewTransition) {
    document.startViewTransition(updateFn)
  } else {
    updateFn()
    // Or: libraryFallback(updateFn)
  }
}

function observeVisibility(element, callback) {
  if ('IntersectionObserver' in window) {
    new IntersectionObserver(callback).observe(element)
  } else {
    // Immediate fallback — just show the element
    callback([{ isIntersecting: true, target: element }])
  }
}

if (!HTMLScriptElement.supports?.('speculationrules')) {
  // Load quicklink as a fallback for browsers without Speculation Rules
  import('quicklink').then(({ listen }) => listen())
}

Start Here: The Audit Checklist

Before your next sprint, check if your bundle contains any of these:

✓ Scroll event listeners for viewport detection?
  → Replace with IntersectionObserver

✓ Page transition animation library (GSAP page transitions, swup, barba.js)?
  → Replace with View Transitions API

✓ Text highlighting library (mark.js, Highlight.js DOM wrapping)?
  → Replace with CSS Custom Highlight API

✓ Tooltip/dropdown positioning library (Tippy.js, Floating UI, Popper.js)?
  → Replace with Popover API + CSS Anchor Positioning

✓ Link prefetch library (quicklink, instant.page)?
  → Replace with Speculation Rules API

✓ Using getBoundingClientRect() in a scroll listener?
  → Replace with IntersectionObserver — same result, no main thread blocking

Final Thoughts

The browser platform has been advancing faster in the last three years than in the previous ten. APIs that would have required entire libraries — or would have been impossible without them — now ship natively in every major browser.

The argument for using native APIs isn’t ideological. It’s practical:

  • Zero bundle cost. The browser ships them; you don’t pay for them.
  • Better performance. Native APIs like IntersectionObserver run off the main thread. Scroll listeners don’t.
  • Better accessibility. The Popover API manages ARIA attributes, focus trapping, and keyboard dismissal automatically.
  • Less maintenance. A browser API doesn’t deprecate, change its API in a major version, or require security updates.

The gap between “what the platform provides” and “what developers install packages to do” has never been smaller. Spend 30 minutes auditing your bundle against this list. The packages you remove today are performance you get back forever.

Leave a Reply

Your email address will not be published. Required fields are marked *