I’m building a personal blog with Astro + Tailwind CSS and I successfully implemented a dark/light theme toggle.
After that, I wanted to add smooth transition animations between themes. To avoid transitions affecting all elements permanently, I created a temporary class theme-transition that I add to the <body> during theme switching and remove after 500ms:
.theme-transition {
@apply transition-colors duration-500;
}
This works partially, but I’ve noticed a strange issue:
If I don’t manually set base colors on the <body> (bg-white dark:bg-black text-black dark:text-white), the animation doesn’t work properly. After adding those, most elements transition smoothly.
However, elements with custom Tailwind text colors (e.g., text-gray-900 dark:text-gray-100) don’t animate smoothly. Instead, they stay in the old color and then instantly jump to the new color after other elements have finished animating.
I confirmed that the transition class itself works, because if I keep .theme-transition permanently on <body> (instead of removing it after 500ms), those elements do animate — but still lag behind the others.
/* global.css */
@import 'tailwindcss';
@import 'bootstrap-icons';
@custom-variant dark (&:where(.dark, .dark *));
/* Theme transition animations */
.theme-transition {
@apply !transition-colors !duration-500;
}
What I tried:
bg-white dark:bg-black text-black dark:text-white to <body> → fixes most elements, but not custom colors..theme-transition permanently on <body> → custom-colored elements animate, but still lag.! prefix in Tailwind (@apply !transition-colors !duration-500) → no effect..theme-transition * {
@apply !transition-colors !duration-500;
}
→ still no effect.Why can't elements with custom text colors (text-gray-900 dark:text-gray-100) animate in sync with other elements? How can this issue be fixed so all elements transition smoothly and synchronously when switching themes?
Versions of my tool:
I tried to replace unlayered CSS (.theme-transition) to Tailwind @layer suggested by this answer and removed theme-transition class:
@layer theme {
* {
transition-property:
color, background-color, border-color, outline-color,
text-decoration-color, fill, stroke;
transition-timing-function: ease;
transition-duration: 500ms;
}
}
Causes even stranger animations. For example:
transition-colors duration-200. When I hover over the button, its background color changes smoothly in 200ms as expected, but the text color now takes 500ms to change, which makes the animation look inconsistent.Can't find the page you are looking for...) initially transitions to the target color, but then quickly flashes back to the original color before rapidly transitioning again to the final color.I also deleted the file tailwind.config.ts, since it is useless as I didn't explicitly imported it in Tailwind v4.
I added a reproduction without Astro:
const localStorage = {
items: {},
setItem: (key, value) => localStorage.items[key] = value,
getItem: (key) => localStorage.items[key]
}
const select = document.getElementById('theme-select');
if (select) {
try { select.value = JSON.parse(localStorage.getItem('theme') ?? '"light"'); }
catch { localStorage.removeItem('theme'); }
let timeoutId = null;
const apply = (theme) => {
const root = document.documentElement;
const body = document.body;
body.classList.add('theme-transition');
if (theme === 'dark') {
root.classList.add('dark');
root.style.colorScheme = 'dark';
localStorage.setItem('theme', JSON.stringify('dark'));
} else if (theme === 'light') {
root.classList.remove('dark');
root.style.colorScheme = 'light';
localStorage.setItem('theme', JSON.stringify('light'));
} else { // auto
const preferDark = matchMedia('(prefers-color-scheme: dark)').matches;
root.classList.toggle('dark', preferDark);
root.style.colorScheme = preferDark ? 'dark' : 'light';
localStorage.setItem('theme', JSON.stringify('auto'));
}
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => body.classList.remove('theme-transition'), 500);
};
select.addEventListener('change', (e) => apply(e.target.value));
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (JSON.parse(localStorage.getItem('theme') ?? '"auto"') === 'auto') apply('auto');
});
}
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
@custom-variant dark (&:where(.dark, .dark *));
@layer theme {
* {
transition-property:
color, background-color, border-color, outline-color,
text-decoration-color, fill, stroke;
transition-timing-function: ease;
transition-duration: 500ms;
}
}
</style>
<body class="bg-white text-black dark:bg-black dark:text-white">
<label>
Theme:
<select id="theme-select" class="dark:bg-white dark:text-black">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
<main id="main-content">
<div class="text-gray-900 dark:text-gray-100">
This text has wrong transition animation.
</div>
<div>This text is correct.</div>
</main>
</body>
If you only apply transition-colors to the body, then it will only work on the body. For it to work on every element, you need to add it to each element. But your CSS is too strong because it's unlayered.
TailwindCSS uses layers, which are ordered from weakest to strongest:
theme, base, components, utilities
!important doesn't work for you for several reasons. One is that starting from v4, the exclamation mark has to be placed after instead of before. Another is that if you make something important, it also can't be overridden later.
To ensure proper behavior, I would place your style in either the theme or the base layer. Since it's strongly tied to theme switching, I would put it in the theme layer:
@layer theme {
* {
@apply transition-colors duration-500;
}
}
Avoiding @apply is recommended, and in light of that, I would put something like this in my code:
@layer theme {
* {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke;
transition-timing-function: ease;
transition-duration: 500ms;
}
}
Related:
document.querySelector('button').addEventListener('click', () => {
document.documentElement.classList.toggle('dark');
});
<script src="https://unpkg.com/@tailwindcss/browser"></script>
<style type="text/tailwindcss">
/* changed the behavior of dark: (default: based on prefers-color-scheme) to work based on the presence of the .dark parent class */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-pink: #eb6bd8;
}
@layer theme {
* {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke;
transition-timing-function: ease;
transition-duration: 500ms;
}
:root, :host {
@variant dark {
--color-pink: #8e0d7a;
}
}
}
</style>
<button class="size-20 bg-pink dark:text-white">Click Here</button>
<div class="w-50 h-12 bg-purple-200 dark:bg-purple-900 dark:text-white">
Lorem Ipsum
</div>
For theme switching and handling, these questions are relevant:
light-dark() var(--tw-light, ...) var(--tw-dark, ...)* and when should I use :root, :host as the parent selector?@theme or @theme inline?The JavaScript is causing the flicker because the theme is being applied twice instead of once, which the browser handles incorrectly. You shouldn't manipulate the colorScheme in JavaScript like you did:
root.style.colorScheme = 'dark';
The colorScheme value set on the root element's style restarted the animation, causing it to run twice. This interrupted the first run roughly halfway, producing the "flicker" seen in the reproduction added to the question.
The solution is simple: I didn't mention colorScheme in my answer because I assumed it was obvious that it's unnecessary. You don't need to use color-scheme. For consistent behavior, you need to declare the dark mode colors yourself.
const localStorage = {
items: {},
setItem: (key, value) => localStorage.items[key] = value,
getItem: (key) => localStorage.items[key]
}
const select = document.getElementById('theme-select');
if (select) {
try { select.value = JSON.parse(localStorage.getItem('theme') ?? '"light"'); }
catch { localStorage.removeItem('theme'); }
const apply = (theme) => {
const root = document.documentElement;
const body = document.body;
if (theme === 'dark') {
root.classList.add('dark');
localStorage.setItem('theme', JSON.stringify('dark'));
} else if (theme === 'light') {
root.classList.remove('dark');
localStorage.setItem('theme', JSON.stringify('light'));
} else { // auto
const preferDark = matchMedia('(prefers-color-scheme: dark)').matches;
root.classList.toggle('dark', preferDark);
localStorage.setItem('theme', JSON.stringify('auto'));
}
};
select.addEventListener('change', (e) => apply(e.target.value));
}
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
@custom-variant dark (&:where(.dark, .dark *));
@layer theme {
* {
transition-property:
color, background-color, border-color, outline-color,
text-decoration-color, fill, stroke;
transition-timing-function: ease;
transition-duration: 500ms;
}
}
</style>
<body class="bg-white text-black dark:bg-black dark:text-white">
<label>
Theme:
<select id="theme-select" class="dark:bg-white dark:text-black">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
<main id="main-content">
<div class="text-gray-900 dark:text-gray-100">
This text has wrong transition animation.
</div>
<div>This text is correct.</div>
</main>
</body>