lenec ru

← все посты

Тёмная тема в Astro без flash на SSR

14K

Тёмная тема выглядит просто, пока не выясняется, что при первом рендере страница на полсекунды моргает белым, потом перекрашивается в тёмное. Этот эффект называют 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.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.