Тёмная тема в Astro без flash на SSR
Тёмная тема выглядит просто, пока не выясняется, что при первом рендере страница на полсекунды моргает белым, потом перекрашивается в тёмное. Этот эффект называют FOUC или «flash of incorrect theme», и он особенно бесит на SSR-сайтах, где ты, казалось бы, должен отдавать готовый HTML.
В этой заметке — как сделать, чтобы тема применялась с самого первого пикселя, и какие нюансы у этой задачи в Astro.
Корень проблемы
Когда у тебя сервер не знает предпочтение пользователя, он рендерит «нейтральный» вариант. Браузер получает HTML, отрисовывает его, потом подключается твой клиентский скрипт, читает localStorage или prefers-color-scheme и решает, что нужно затемнить. Эти миллисекунды между «отрисовали» и «применили класс» — и есть flash.
Чтобы его убрать, нужно решить тему до отрисовки. На SSR это значит — либо узнать предпочтение пользователя на сервере (по cookie), либо выполнить inline-скрипт до парсинга остального DOM.
Подход 1: cookie + класс на html
Самый чистый способ. Когда юзер впервые меняет тему, ты пишешь cookie theme=dark. Сервер при следующем запросе читает его и ставит класс на <html> прямо в HTML. Никакого моргания не будет вообще.
В Astro middleware для этого:
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (ctx, next) => {
const theme = ctx.cookies.get('theme')?.value ?? 'system';
ctx.locals.theme = theme;
return next();
});В layout проставляешь класс:
---
const theme = Astro.locals.theme;
const classFor = theme === 'dark' ? 'dark' : theme === 'light' ? 'light' : '';
---
<html lang="ru" class={classFor}>
<head>...</head>
<body>
<slot />
</body>
</html>Если cookie нет, оставляешь пустой класс — для пользователей «по умолчанию» обрабатываешь системную тему через CSS-медиазапрос.
Подход 2: inline-скрипт в head
Когда cookie нет (первый визит) — это решает проблему. В <head> до любых стилей кладёшь синхронный скрипт, который сам ставит класс:
<script is:inline>
(function () {
const stored = localStorage.getItem('theme');
const dark =
stored === 'dark' ||
(!stored && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', dark);
})();
</script>Атрибут is:inline в Astro обязателен — иначе скрипт обработается через бандлер и будет загружен как модуль с defer, что снова вернёт flash.
Совместить оба способа
На практике использую обе техники. Cookie ловит большинство случаев — после первого выбора пользователь возвращается, тема уже на сервере. Inline-скрипт страхует, когда cookie ещё нет, но в браузере уже сохранён выбор. Если оба отсутствуют — применяется системное предпочтение, и оно же быстро отрабатывает на отрисовке.
CSS-конвенция
Я держу две схемы: класс на html и медиазапрос. Это даёт автоматическое поведение для пользователей без выбора и явный override, когда выбор есть.
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #111111;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0e0e10;
--fg: #f5f5f7;
}
}
html.light {
--bg: #ffffff;
--fg: #111111;
}
html.dark {
--bg: #0e0e10;
--fg: #f5f5f7;
}
body { background: var(--bg); color: var(--fg); }Свойство color-scheme важно — оно сообщает браузеру, что элементы формы и скроллбары можно перекрашивать.
Переключатель
Сам компонент простой. Я делаю его островом с client:load, чтобы переключение работало сразу:
// src/components/ThemeToggle.tsx
import { useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
function apply(t: Theme) {
const dark =
t === 'dark' ||
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', dark);
}
export default function ThemeToggle() {
const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem('theme') as Theme) ?? 'system');
useEffect(() => {
apply(theme);
if (theme === 'system') localStorage.removeItem('theme');
else localStorage.setItem('theme', theme);
document.cookie = `theme=${theme}; path=/; max-age=31536000`;
}, [theme]);
return (
<select value={theme} onChange={(e) => setTheme(e.target.value as Theme)}>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
);
}Тут важно: cookie ставится сразу с длинным max-age, чтобы при следующем заходе сервер прочитал предпочтение и отрисовал страницу в правильной теме.
View Transitions и тема
Если используешь Astro View Transitions, после навигации тема может «дёрнуться», потому что DOM подменяется и класс на html остаётся, но обработчики системного медиазапроса не пересоздаются. Я обхожу это глобальным слушателем, который один раз подключается в inline-скрипте:
<script is:inline>
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
document.documentElement.classList.toggle('dark', e.matches);
}
});
</script>Что проверить, если флаш всё ещё есть
- Скрипт расположен в
<head>и снабжёнis:inline. - Стили подключаются после скрипта, а не до него.
- В CSS нет правил, которые работают на инвертированной палитре до загрузки темы (типа
body { background: white }без класса). - На SSR действительно проставляется класс из cookie — посмотри в DevTools «Network → Doc → Response».
Куда копать дальше
В сложных дизайнах темы — не одна и не две. У меня в одном проекте было четыре: light, dark, sepia и high-contrast. Та же логика расширяется до маппинга theme → className; cookie и localStorage держат значение строкой. Главное — фиксированный список, чтобы санитайзер на сервере не пропустил произвольный класс на html.