You've probably seen this one before. You toggle dark mode, and half the page fades while the other half snaps. Buttons, borders, and cards all have their own transition, so they finish at different times. It looks broken.
The fix is to turn all transitions off for a split second, flip the theme, then turn them back on.
This works with any framework. The only assumption is that your theme lives on <html class="dark">, which is how Tailwind's dark: variant works.
The CSS Kill Switch
.no-transitions,
.no-transitions :not([data-theme-motion], [data-theme-motion] *),
.no-transitions :not([data-theme-motion], [data-theme-motion] *)::before,
.no-transitions :not([data-theme-motion], [data-theme-motion] *)::after {
transition: none !important;
}When <html> has the no-transitions class, every element gets transition: none. * doesn't match pseudo-elements, that's why ::before and ::after have their own lines.
Opting out
Sometimes you want one element to keep animating, like the sun and moon icon on the toggle button. Add data-theme-motion to it, and the selector skips that element and everything inside it.
<button onClick={toggleTheme}>
<Sun data-theme-motion className='transition dark:-rotate-90 dark:scale-0' />
<Moon
data-theme-motion
className='transition rotate-90 scale-0 dark:rotate-0 dark:scale-100'
/>
</button>The page snaps instantly, the icon still spins.
The Toggle Helper
export const withTransitionsSuppressed = <T>(applyChanges: () => T): T => {
const root = document.documentElement;
root.classList.add('no-transitions');
const result = applyChanges();
void getComputedStyle(document.body).opacity; // force reflow so it commits
setTimeout(() => root.classList.remove('no-transitions'), 1);
return result;
};
export const toggleTheme = () => {
const dark = withTransitionsSuppressed(() =>
document.documentElement.classList.toggle('dark')
);
try {
localStorage.setItem('theme', dark ? 'dark' : 'light');
} catch {}
return dark;
};The order matters here.
- Add
no-transitions - Change the theme
- Force a reflow, by reading a computed style
- Remove
no-transitionson the next tick
Without step 3, the browser can batch everything together and never see the moment where transitions were off. So the colors would still animate. Reading getComputedStyle makes it apply the styles right away.
It's not just for themes
withTransitionsSuppressed takes any function. Anything that changes lots of styles at once and shouldn't animate can use it, like switching a layout or resetting a filter.
The Inline Head Script
The helper only covers toggling. On a page load, you also need the stored theme to be applied before the first paint. Otherwise you get a flash of the wrong theme, and then it transitions into the right one.
<script>
(() => {
const root = document.documentElement;
root.classList.add('no-transitions');
addEventListener(
'load',
() =>
requestAnimationFrame(() => root.classList.remove('no-transitions')),
{ once: true }
);
let stored = null;
try {
stored = localStorage.getItem('theme');
} catch {}
const dark = stored
? stored === 'dark'
: matchMedia('(prefers-color-scheme: dark)').matches;
root.classList.toggle('dark', dark);
})();
</script>It needs to be an inline script in the head, so it blocks rendering and runs before anything is painted. Transitions stay off until the page has loaded, so nothing animates on the initial render.
The localStorage calls are wrapped in try/catch because they can throw, for example in private mode or when storage is blocked.
Already Using next-themes?
It has the disableTransitionOnChange prop, which does the same thing. The difference is it's all or nothing, so you can't keep the toggle icon animating. If you don't care about that, just use the prop.
