/**
* A comprehensive function to determine if the page is currently in a dark theme.
* Heuristic check. Only work for common patterns. Return false by default.
*/
export function isPageDark() {
try {
if (hasDarkModeClass()) return true
if (hasDarkModeDataAttribute()) return true
if (isColorSchemeDark()) return true
if (isBackgroundDark()) return true
if (isMainContentBackgroundDark()) return true
if (isTextColorLight()) return true
return false
} catch (error) {
console.warn('Error determining if page is dark:', error)
return false
}
}
/**
* Checks for common dark mode CSS classes on the html or body elements.
*/
function hasDarkModeClass() {
const DEFAULT_DARK_MODE_CLASSES = ['dark', 'dark-mode', 'theme-dark', 'night', 'night-mode']
const htmlElement = document.documentElement
const bodyElement = document.body || document.documentElement // can be null in some cases
// Check class names on and
for (const className of DEFAULT_DARK_MODE_CLASSES) {
if (htmlElement.classList.contains(className) || bodyElement?.classList.contains(className)) {
return true
}
}
return false
}
/**
* Some UI frameworks use data attributes to indicate theme
*/
function hasDarkModeDataAttribute() {
const htmlElement = document.documentElement
const bodyElement = document.body || document.documentElement // can be null in some cases
const dataAttrs = ['data-theme', 'data-color-mode', 'data-bs-theme', 'data-mui-color-scheme']
for (const attr of dataAttrs) {
const bodyValue = bodyElement?.getAttribute(attr)
const htmlValue = htmlElement.getAttribute(attr)
if (bodyValue?.toLowerCase() === 'dark' || htmlValue?.toLowerCase() === 'dark') {
return true
}
}
return false
}
/**
* Checks the CSS `color-scheme` property and `` tag.
* Only "dark"/"only dark" counts as dark; "light dark" is ambiguous and ignored.
*/
function isColorSchemeDark() {
// Check
const meta = document.querySelector('meta[name="color-scheme"]')
const metaContent = meta?.content.toLowerCase()
if (metaContent === 'dark' || metaContent === 'only dark') return true
// Check the computed color-scheme CSS property on :root
const rootStyle = window.getComputedStyle(document.documentElement)
const colorScheme = rootStyle.getPropertyValue('color-scheme').trim().toLowerCase()
return colorScheme === 'dark' || colorScheme === 'only dark'
}
/**
* Checks the background color of the body element to determine if the page is dark.
*/
function isBackgroundDark() {
// We check both and because some pages set the color on
const htmlStyle = window.getComputedStyle(document.documentElement)
const bodyStyle = window.getComputedStyle(document.body || document.documentElement)
// Get background colors
const htmlBgColor = htmlStyle.backgroundColor
const bodyBgColor = bodyStyle.backgroundColor
// The body's background might be transparent, in which case we should
// fall back to the html element's background.
if (isColorDark(bodyBgColor)) {
return true
} else if (bodyBgColor === 'transparent' || bodyBgColor.startsWith('rgba(0, 0, 0, 0)')) {
return isColorDark(htmlBgColor)
}
return false
}
/**
* Checks if the text color on the body is light, which implies a dark background.
*/
function isTextColorLight() {
/** Luminance (0-255) above which body text is considered light */
const LIGHT_TEXT_LUMINANCE = 200
const bodyStyle = window.getComputedStyle(document.body || document.documentElement)
const luminance = getLuminance(bodyStyle.color)
// Light text has high luminance (e.g. white text on dark bg)
return luminance !== null && luminance > LIGHT_TEXT_LUMINANCE
}
/**
* Checks the background color of major layout elements (#app, #root, etc.).
* Many SPAs render into a container that may have its own dark background while
* remains transparent.
*/
function isMainContentBackgroundDark() {
const { innerWidth: vw, innerHeight: vh } = window
const minArea = vw * vh * 0.5
const selectors = ['#app', '#root', '#__next']
for (const selector of selectors) {
const el = document.querySelector(selector)
if (!el) continue
const rect = el.getBoundingClientRect()
if (rect.width * rect.height < minArea) continue
if (isColorDark(window.getComputedStyle(el).backgroundColor)) return true
}
return false
}
// --- utils ---
/**
* Parses an RGB or RGBA color string and returns an object with r, g, b properties.
* @param {string} colorString - e.g., "rgb(34, 34, 34)" or "rgba(0, 0, 0, 0.5)"
* @returns {{r: number, g: number, b: number}|null}
*/
function parseRgbColor(colorString: string) {
const rgbMatch = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(colorString)
if (!rgbMatch) {
return null // Not a valid rgb/rgba string
}
return {
r: parseInt(rgbMatch[1]),
g: parseInt(rgbMatch[2]),
b: parseInt(rgbMatch[3]),
}
}
/**
* Calculates the perceived luminance (0-255) of a CSS color string.
* @param {string} colorString - e.g., "rgb(50, 50, 50)" or "rgba(0, 0, 0, 0.5)"
* @returns {number|null} - The luminance, or null if the color is transparent or unparseable.
*/
function getLuminance(colorString: string): number | null {
if (!colorString || colorString === 'transparent' || colorString.startsWith('rgba(0, 0, 0, 0)')) {
return null // Transparent has no meaningful luminance
}
const rgb = parseRgbColor(colorString)
if (!rgb) {
return null // Could not parse color
}
// Standard perceived luminance formula
return 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b
}
/**
* Determines if a color is "dark" based on its calculated luminance.
* @param {string} colorString - The CSS color string (e.g., "rgb(50, 50, 50)").
* @param {number} threshold - A value between 0 and 255. Colors with luminance below this will be considered dark. Default is 128.
*/
function isColorDark(colorString: string, threshold = 128) {
const luminance = getLuminance(colorString)
return luminance !== null && luminance < threshold
}