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-expandedand 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
| API | Baseline Status | Chrome | Firefox | Safari | Notes |
|---|---|---|---|---|---|
| IntersectionObserver | Widely Available (2018) | ✅ | ✅ | ✅ | Safe to use everywhere |
| View Transitions (same-doc) | Widely Available (Oct 2025) | ✅ | ✅ 133+ | ✅ 18+ | Fallback gracefully |
| CSS Custom Highlight | Widely Available (Jun 2025) | ✅ | ✅ | ✅ | DOM mutation fallback |
| Popover API | Widely Available (Apr 2025) | ✅ | ✅ | ✅ | Safe to use everywhere |
| Speculation Rules | Partial (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.
