Here’s a problem every UI dev hits eventually. You’ve got a sticky nav, a floating chevron, a fixed-position button — something that sits on top of your page and doesn’t scroll with it. The page looks great. Then someone scrolls through a dark hero section into a white content area and suddenly your white chevron is invisible against the white background. Or your black logo disappears into the dark footer. Classic.
The problem isn’t your colors. It’s that your element has no idea what’s underneath it. It picked a color once — at render time — and stuck with it. What you actually need is for that element to know what’s below it and react accordingly.
There are five distinct approaches to solving this, ranging from a two-line CSS trick to a full pixel-sampling system. Let’s go through all of them, then build a clean custom solution from scratch.
The Problem in Plain English
Quick Answer: A fixed or sticky DOM element needs to detect whether the content beneath it is dark or light, then switch its own color class automatically as the page scrolls.
Think about these real-world cases:
- A sticky navigation bar with a white logo — works on dark hero images, disappears on white sections
- A scroll-to-top chevron fixed to the bottom-right — black on white content, invisible on a dark footer
- A floating CTA button that sits over a testimonials section that alternates dark and light cards
- A fullscreen carousel where each slide has a different dominant color
The element’s position is fixed or sticky. The scroll changes what’s visually beneath it. Your element is oblivious. Let’s fix that.
Approach 1: CSS mix-blend-mode: difference (Zero JavaScript)
Quick Answer: Set your element’s color to white and apply mix-blend-mode: difference — it auto-inverts against any background with no JavaScript at all.
This is the sneaky CSS trick that feels like cheating. Two lines. Done.
|
1 2 3 4 5 6 7 8 9 10 11 12 |
.adaptive-element { color: white; mix-blend-mode: difference; } /* For SVG icons, target the fill */ .adaptive-chevron svg path { fill: white; mix-blend-mode: difference; } |
Here’s the math behind it: the difference blend mode subtracts the darker of two colors from the lighter one. White minus white = black. White minus black = white. It’s not a toggle — it’s a continuous inversion relative to whatever’s behind the element.
The result is genuinely cool. White text on a white background turns black. White text on black stays white. On a mid-gray, you get a contrasting mid-gray. It just works.
Perfect for: text overlays, simple icon elements, logo treatments where a blended visual is the desired effect.
The catch: This is a blend, not a binary switch. On colored backgrounds (anything that isn’t black or white), you get color artifacts — your white chevron over a coral section doesn’t just go dark, it goes green-ish. Also, any ancestor element with isolation: isolate in its styles will break the blend entirely because it creates a new stacking context. And you can’t trigger any JavaScript logic off it — there’s no event, no class, no callback. It’s CSS-only, all the way down.
Approach 2: BackgroundCheck (Open Source Library)
The most widely cited open-source solution for this exact problem is BackgroundCheck by Kenneth Cachia. It does what the name says: checks the brightness of whatever image is behind a target element, then applies CSS classes to flip that element’s styling.
|
1 2 3 4 |
<!-- Include the library --> <script src="background-check.min.js"></script> |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
BackgroundCheck.init({ targets: '.sticky-logo', // the element that needs to adapt images: '.hero-background', // the image(s) to sample beneath it threshold: 50, // 0-255 luminance cutoff (50 = midpoint) minOverlap: 50, // % image overlap required to trigger minComplexity: 30 // % non-uniform pixels to be considered complex }); // BackgroundCheck applies these classes to your target: // .background--dark → background beneath is dark // .background--light → background beneath is light // .background--complex → too mixed to call cleanly |
|
1 2 3 4 5 6 |
.sticky-logo { color: white; } .sticky-logo.background--dark { color: white; } .sticky-logo.background--light { color: black; } .sticky-logo.background--complex { color: white; /* fallback */ } |
How it works under the hood: BackgroundCheck uses getBoundingClientRect() to find your target’s viewport position, then draws the background image(s) onto a hidden <canvas> element. From there, getImageData() reads the actual pixel values in the region beneath your target. It calculates the average luminance of those pixels and compares it against your threshold to decide dark or light.
Perfect for: Hero sections with full-bleed photography, carousels with different images per slide, any scenario where the backgrounds are actual <img> tags or CSS background-image.
The catch: BackgroundCheck was last seriously maintained around 2015–2016. It’s a solid piece of code, but it’s aged. More importantly, it’s designed specifically for images — it can’t sample arbitrary DOM colors, gradients, or colored <div> backgrounds. Cross-origin images will also taint the canvas and throw a security error. And there’s no TypeScript, no ES module export, no npm package with modern tooling support.
Approach 3: document.elementsFromPoint() + getComputedStyle (DOM Walk)
This is the modern vanilla JS approach for pages where backgrounds are solid CSS colors — no images involved. It uses document.elementsFromPoint() to get the full z-order stack of elements at a given coordinate, then walks up through them looking for an opaque background color.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
function getRelativeLuminance(r, g, b) { const [rs, gs, bs] = [r, g, b].map(c => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }); return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; } function isDarkBeneathElement(el) { const rect = el.getBoundingClientRect(); const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; // Temporarily hide the target so it doesn't return itself el.style.visibility = 'hidden'; const elements = document.elementsFromPoint(cx, cy); el.style.visibility = ''; for (const candidate of elements) { const bg = window.getComputedStyle(candidate).backgroundColor; // Skip transparent backgrounds if (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') continue; const match = bg.match(/\d+/g); if (match) { const [r, g, b] = match.map(Number); const luminance = getRelativeLuminance(r, g, b); return luminance < 0.179; // WCAG threshold for "dark" } } return false; // default to light } // Wire it up to scroll let ticking = false; window.addEventListener('scroll', () => { if (!ticking) { requestAnimationFrame(() => { const chevron = document.querySelector('.sticky-chevron'); const dark = isDarkBeneathElement(chevron); chevron.classList.toggle('theme-dark', dark); chevron.classList.toggle('theme-light', !dark); ticking = false; }); ticking = true; } }); |
The luminance formula is straight from WCAG 2.1 — it’s the perceptual brightness calculation that accessibility tooling uses for contrast ratios. A luminance below 0.179 is what WCAG considers “dark.” That’s the standard threshold, though you can tune it.
Perfect for: Section-based layouts with solid colored backgrounds, CSS custom property driven themes, any scenario where background-color is the source of truth.
The catch: This reads declared CSS colors, not actual rendered pixels. It will not detect images, SVG backgrounds, or CSS gradients — those all return transparent or the base color before the gradient is applied. The visibility: hidden trick also forces a layout recalculation each frame if you’re running it on scroll, which can add up. Use the RAF-throttling pattern shown above to keep it performant.
Approach 4: Canvas getImageData (Pixel-Perfect Sampling)
This is the most accurate approach — it reads actual rendered pixel values — but it comes with the most complexity. There are two realistic implementations.
Sub-approach A: Sampling Known Images
If you know which image is behind your element (a <img> tag or a background-image URL you control), draw it to an offscreen canvas and sample the pixels at the element’s coordinates. This is essentially what BackgroundCheck does internally.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
function samplePixelsBeneathElement(targetEl, imgEl) { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = imgEl.naturalWidth; canvas.height = imgEl.naturalHeight; ctx.drawImage(imgEl, 0, 0); const targetRect = targetEl.getBoundingClientRect(); const imgRect = imgEl.getBoundingClientRect(); // Map target coords to image coords const scaleX = imgEl.naturalWidth / imgRect.width; const scaleY = imgEl.naturalHeight / imgRect.height; const x = Math.round((targetRect.left - imgRect.left) * scaleX); const y = Math.round((targetRect.top - imgRect.top) * scaleY); const w = Math.round(targetRect.width * scaleX); const h = Math.round(targetRect.height * scaleY); const data = ctx.getImageData(x, y, w, h).data; // Average the luminance across all sampled pixels let totalLuminance = 0; let pixelCount = 0; for (let i = 0; i < data.length; i += 4) { const r = data[i], g = data[i + 1], b = data[i + 2]; totalLuminance += 0.299 * r + 0.587 * g + 0.114 * b; // perceived brightness pixelCount++; } const avgBrightness = totalLuminance / pixelCount; return avgBrightness < 128; // true = dark } |
Note: images must be same-origin or served with appropriate CORS headers, otherwise the canvas will be tainted and getImageData() will throw a SecurityError.
Sub-approach B: html2canvas (Renders Anything, Slowly)
html2canvas can render any DOM section — including gradients, shadows, and mixed content — onto a canvas. But it’s heavy and not suitable for real-time scroll updates. Use it for one-shot checks (on load, on section change) rather than every scroll frame.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import html2canvas from 'html2canvas'; async function isDarkBeneathElementFull(targetEl) { const bgSection = document.querySelector('.section-behind-target'); const canvas = await html2canvas(bgSection, { logging: false }); const ctx = canvas.getContext('2d'); const rect = targetEl.getBoundingClientRect(); const sectionRect = bgSection.getBoundingClientRect(); const x = Math.round(rect.left - sectionRect.left); const y = Math.round(rect.top - sectionRect.top); const data = ctx.getImageData(x, y, rect.width, rect.height).data; let brightness = 0; let count = 0; for (let i = 0; i < data.length; i += 4) { brightness += 0.299 * data[i] + 0.587 * data[i+1] + 0.114 * data[i+2]; count++; } return brightness / count < 128; } |
Perfect for: When you absolutely need pixel-accurate results and your backgrounds include images, gradients, and blended content. Also good for initial load checks where performance during scroll doesn’t matter.
The catch: html2canvas is slow (50–200ms per call), adds ~200KB to your bundle, and has its own quirks with certain CSS features. Not suitable for scroll-driven real-time adaptation.
Approach 5: IntersectionObserver + Data Attributes (Structured Sections)
If you control the page markup, this is the cleanest and most performant solution. No pixel sampling, no canvas, no scroll events — just the browser’s native intersection engine doing the heavy lifting.
The idea: each page section declares its own theme via a data-theme attribute. An IntersectionObserver watches those sections and fires when they enter a specific zone in the viewport — the zone where your fixed element lives.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
<!-- Each section declares its own theme --> <section data-theme="dark" class="hero">...</section> <section data-theme="light" class="content">...</section> <section data-theme="dark" class="testimonials">...</section> <section data-theme="light" class="pricing">...</section> <section data-theme="dark" class="footer">...</section> <!-- The element that needs to adapt --> <button class="sticky-chevron" data-current-theme="dark"> <svg>...</svg> </button> |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
const chevron = document.querySelector('.sticky-chevron'); const sections = document.querySelectorAll('[data-theme]'); // rootMargin creates a thin horizontal strip at the chevron's vertical position. // Adjust the values to match your element's top offset from the viewport center. // Here, we create a 20px-tall detection zone centered on the element. const observer = new IntersectionObserver( entries => { entries.forEach(entry => { if (entry.isIntersecting) { const theme = entry.target.dataset.theme; chevron.dataset.currentTheme = theme; chevron.classList.toggle('is-dark', theme === 'dark'); chevron.classList.toggle('is-light', theme === 'light'); } }); }, { rootMargin: '-80% 0px -10% 0px', // fires when section crosses ~80% from top threshold: 0 } ); sections.forEach(section => observer.observe(section)); |
|
1 2 3 4 5 |
.sticky-chevron { transition: color 0.3s ease; } .sticky-chevron.is-dark { color: white; } .sticky-chevron.is-light { color: black; } |
The rootMargin is where the magic happens. By shrinking the root from top and bottom, you create a narrow trigger zone in the viewport. When a section enters that zone, the observer fires. Tune the percentages to match exactly where your element sits vertically.
The Smashing Magazine article Building A Dynamic Header With Intersection Observer goes deep on this pattern for sticky navbars specifically — highly recommended reading.
Perfect for: Page layouts you control, landing pages with distinct sections, any situation where you’d be happy annotating your markup with theme hints.
The catch: Requires structured markup. Falls apart if backgrounds overlap, blend, or aren’t declared per-section. Can’t react to dynamically loaded content without re-observing. And it only knows what you tell it — no pixel-level awareness of what the section actually looks like.
Build Your Own: A Hybrid ColorTracker
Here’s where it gets fun. The ideal solution layers these approaches: use IntersectionObserver as a cheap first pass (section-level awareness), fall back to elementsFromPoint for fine-grained DOM color reading, and only invoke canvas sampling when an image background is detected. You get the performance of the Observer with the accuracy of pixel sampling, only when needed.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 |
class ColorTracker { constructor(target, options = {}) { this.target = typeof target === 'string' ? document.querySelector(target) : target; this.options = { threshold: 0.179, // WCAG relative luminance threshold darkClass: 'is-dark', lightClass: 'is-light', onChange: null, // callback(isDark, luminance, element) sampleRate: 16, // ms between RAF checks (roughly 60fps) ...options }; this._lastLuminance = null; this._rafId = null; this._running = false; this.start(); } // --- Public API --- start() { if (this._running) return; this._running = true; this._tick(); window.addEventListener('resize', this._onResize = () => this._tick()); } stop() { this._running = false; cancelAnimationFrame(this._rafId); window.removeEventListener('resize', this._onResize); } // --- Core Logic --- _tick() { if (!this._running) return; this._rafId = requestAnimationFrame(() => { this._update(); this._tick(); }); } _update() { const luminance = this._getLuminanceBeneath(); if (luminance === null) return; // Only update DOM if the value has meaningfully changed (hysteresis) if (this._lastLuminance !== null && Math.abs(luminance - this._lastLuminance) < 0.02) return; this._lastLuminance = luminance; const isDark = luminance < this.options.threshold; this.target.classList.toggle(this.options.darkClass, isDark); this.target.classList.toggle(this.options.lightClass, !isDark); if (this.options.onChange) { this.options.onChange(isDark, luminance, this.target); } } _getLuminanceBeneath() { const rect = this.target.getBoundingClientRect(); const cx = Math.round(rect.left + rect.width / 2); const cy = Math.round(rect.top + rect.height / 2); // Temporarily hide target to get elements below it this.target.style.visibility = 'hidden'; const stack = document.elementsFromPoint(cx, cy); this.target.style.visibility = ''; for (const el of stack) { if (el === this.target) continue; if (el === document.documentElement || el === document.body) continue; const style = window.getComputedStyle(el); const bg = style.backgroundColor; const bgImage = style.backgroundImage; // If there's a background image, try canvas sampling if (bgImage && bgImage !== 'none') { const sampled = this._sampleImageBackground(el, rect); if (sampled !== null) return sampled; } // Otherwise check computed background color if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') { const match = bg.match(/\d+(\.\d+)?/g); if (match && match.length >= 3) { return this._relativeLuminance( Number(match[0]), Number(match[1]), Number(match[2]) ); } } } return null; } _sampleImageBackground(el, targetRect) { // Extract URL from backgroundImage: url("...") const bgImage = window.getComputedStyle(el).backgroundImage; const urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/); if (!urlMatch) return null; // Use cached canvas if available if (!this._canvas) { this._canvas = document.createElement('canvas'); this._ctx = this._canvas.getContext('2d'); } const img = new Image(); img.crossOrigin = 'anonymous'; img.src = urlMatch[1]; if (!img.complete || img.naturalWidth === 0) return null; // image not ready const elRect = el.getBoundingClientRect(); this._canvas.width = Math.round(targetRect.width); this._canvas.height = Math.round(targetRect.height); // Map target rect onto the element's background image const scaleX = img.naturalWidth / elRect.width; const scaleY = img.naturalHeight / elRect.height; const sx = Math.round((targetRect.left - elRect.left) * scaleX); const sy = Math.round((targetRect.top - elRect.top) * scaleY); const sw = Math.round(targetRect.width * scaleX); const sh = Math.round(targetRect.height * scaleY); try { this._ctx.drawImage(img, sx, sy, sw, sh, 0, 0, this._canvas.width, this._canvas.height); const data = this._ctx.getImageData( 0, 0, this._canvas.width, this._canvas.height).data; let total = 0; let count = 0; for (let i = 0; i < data.length; i += 4) { total += this._relativeLuminance(data[i], data[i+1], data[i+2]); count++; } return count > 0 ? total / count : null; } catch (e) { // Canvas tainted by cross-origin image — fall through return null; } } _relativeLuminance(r, g, b) { const linearize = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b); } } // Usage — that's it const tracker = new ColorTracker('.sticky-chevron', { threshold: 0.179, darkClass: 'is-dark', lightClass: 'is-light', onChange: (isDark, luminance) => { console.log(`Background is ${isDark ? 'dark' : 'light'} (luminance: ${luminance.toFixed(3)})`); } }); // Stop tracking when element is removed // tracker.stop(); |
A few design choices worth calling out:
- Hysteresis (the 0.02 threshold): Without this, a background at exactly the threshold luminance will thrash between dark and light classes on every frame. The hysteresis band means you need to move 2% away from the last known value before the class updates. Prevents flickering at transitions.
- Canvas caching: The canvas element is created once and reused. Creating DOM elements on every frame is expensive.
- Graceful cross-origin fallback: If the canvas gets tainted by a cross-origin image, the try/catch returns null and the DOM color check takes over.
- Image readiness check:
img.complete && img.naturalWidth !== 0prevents sampling before the image has loaded. Returns null so the next frame can try again.
Combining with IntersectionObserver for Best Performance
Running ColorTracker at 60fps is unnecessary if the element isn’t near a section boundary. You can pair it with IntersectionObserver to activate the RAF loop only when the target is in a transition zone:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
const tracker = new ColorTracker('.sticky-chevron'); // Only run the precise tracker near section boundaries const sections = document.querySelectorAll('[data-theme]'); const nearBoundaryObserver = new IntersectionObserver( entries => { const nearTransition = entries.some(e => e.isIntersecting); nearTransition ? tracker.start() : tracker.stop(); }, { rootMargin: '-40% 0px -40% 0px' } // 20% band around each boundary ); sections.forEach(s => nearBoundaryObserver.observe(s)); |
Now the precise pixel-sampling only runs when you’re within 20% of a section boundary. The rest of the time, the tracker is idle and you’re burning zero CPU on it.
Comparison: Which Approach Should You Use?
Approach | Images | Gradients | DOM Colors | JS Needed | Performance | Complexity |
|---|---|---|---|---|---|---|
mix-blend-mode | Yes | Yes | Yes | No | Excellent | Trivial |
BackgroundCheck | Yes | No | No | Yes | Good | Low |
elementsFromPoint | No | Partial | Yes | Yes | Good | Medium |
Canvas getImageData | Yes | Yes | Yes | Yes | Moderate | High |
IntersectionObserver | No | No | Declared | Yes | Excellent | Low |
Custom Hybrid (above) | Yes | Yes* | Yes | Yes | Good | Medium |
*Gradient detection is limited — getComputedStyle doesn’t return gradient pixel values, so gradient-background elements fall through to the image sampling path. For gradient-only backgrounds, the mix-blend-mode approach or explicit data-theme annotations are more reliable.
Quick Decision Guide
- You want a visual blend effect and don’t need JS callbacks — use
mix-blend-mode: difference - Backgrounds are images and you want a drop-in library — use BackgroundCheck and be aware of its CORS and age
- Backgrounds are solid CSS colors and you control the DOM — use the
elementsFromPointapproach - You control page structure and want zero runtime overhead — use IntersectionObserver with
data-themeattributes - You need it to work on everything (images, gradients, solid colors) with proper callbacks — build the hybrid
ColorTrackerclass above - You need pixel-perfect accuracy on complex mixed backgrounds and timing doesn’t matter — use html2canvas for a one-shot sample
FAQ
What is the simplest way to auto-switch a chevron between dark and light depending on the background?
The simplest approach is CSS mix-blend-mode: difference with a white color. Set color: white and mix-blend-mode: difference on the element — it auto-inverts against any background with zero JavaScript. It doesn’t give you a clean class toggle, but for visual adaptation it just works.
Why doesn’t mix-blend-mode: difference always look right?
Because it’s a mathematical color blend, not a binary dark/light switch. On colored backgrounds that aren’t black or white, the result can produce unexpected hues — white over orange might go blue-green. It also breaks when an ancestor has isolation: isolate set, which creates a new stacking context and prevents the blend from reading through to the background.
How does BackgroundCheck detect if a background is dark or light?
It draws the background image onto a hidden canvas using the Canvas API, then calls getImageData() to read the actual pixel values in the region beneath your target element. It averages the pixel brightness and compares it to a configurable threshold to decide whether to apply the background--dark or background--light class.
Why does BackgroundCheck throw a SecurityError on some images?
Canvas taint. If the image is loaded from a different domain without proper CORS headers, reading its pixel data with getImageData() is blocked by the browser as a security measure. The fix is to ensure your images are served from the same origin or with Access-Control-Allow-Origin headers, and the canvas element is created with crossOrigin: 'anonymous' on the image.
Does elementsFromPoint work with CSS gradients?
Partially. getComputedStyle returns the gradient string (like linear-gradient(...)) as the backgroundImage property, not the actual pixel colors. The backgroundColor will show as transparent underneath it. So you can detect that a gradient exists, but you can’t read its actual rendered colors this way — for that you need canvas pixel sampling.
What is the WCAG luminance threshold and why 0.179?
WCAG relative luminance is a perceptual brightness measure from 0 (absolute black) to 1 (absolute white). The value 0.179 is the midpoint on a perceptual scale where colors below it read as dark and above as light. It’s derived from the WCAG 2.1 contrast ratio spec and aligns with how human vision perceives brightness non-linearly — we’re more sensitive to changes in dark tones than bright ones.
Why use IntersectionObserver instead of a scroll event listener?
Scroll event listeners run on the main thread and trigger on every scroll frame — potentially hundreds of DOM queries and layout recalculations per second. IntersectionObserver runs off the main thread and fires only when elements cross defined boundaries. It’s dramatically more efficient and doesn’t cause layout thrash.
What is the rootMargin trick in IntersectionObserver for header color switching?
rootMargin shrinks or expands the intersection root (the viewport) before calculating intersections. By setting a negative top margin equal to where your header sits, you create a virtual trigger line at that position. When a section crosses that line, the observer fires — meaning your header reacts exactly when a new section’s background reaches it.
What is canvas taint and how do I avoid it?
Canvas taint happens when you draw a cross-origin image onto a canvas — the browser marks it as tainted and blocks getImageData() calls as a security measure. Avoid it by serving images from the same origin, or configuring your image CDN to send Access-Control-Allow-Origin: * headers, and setting crossOrigin = 'anonymous' on any Image object before assigning its src.
What does the hysteresis value in ColorTracker do?
Hysteresis prevents class flickering at transition zones. Without it, an element sitting exactly on the dark/light threshold will toggle classes on every single animation frame as tiny luminance fluctuations push it above and below the cutoff. The 0.02 band means the luminance must shift by more than 2% from the last known value before any class change fires.
Can I use these techniques with SVG icons and not just text?
Yes. For mix-blend-mode, target the SVG fill or stroke properties directly. For class-based approaches, use CSS to swap fill colors via the applied class — .is-dark svg path { fill: white; }. For inline SVGs you can also drive color with currentColor, which inherits from the parent element’s color property, making the class toggle a one-liner.
Is there a React-friendly way to implement adaptive color tracking?
Yes. Wrap the ColorTracker class in a custom hook — useColorTracker(ref) — that instantiates it in a useEffect, cleans up on unmount by calling tracker.stop(), and exposes isDark as state via the onChange callback. The IntersectionObserver approach is also very clean in React since you can manage section refs and observers entirely in hooks.
