Как сделать темную тему на сайте

Как сделать темную тему на сайте

Недавно передо мной встала задача — добавить темную тему на несколько проектов сразу: обычный лендинг, сайт на WordPress и сайт на Laravel. Хочу поделиться опытом и показать, как это реализовать без фреймворков, чистым CSS с современными подходами.

Почему CSS-переменные — это база

Забудьте про дублирование стилей для светлой и темной темы. Весь секрет в CSS custom properties (переменных) и атрибуте data-theme. Это современный стандарт, который поддерживают все актуальные браузеры.

Темная тема на лендинге

Начну с самого простого случая — обычного лендинга без CMS.

Шаг 1. Определяем переменные

:root {
  --bg-color: #ffffff;
  --text-color: #1a1a1a;
  --card-bg: #f5f5f5;
  --accent-color: #3b82f6;
  --border-color: #e0e0e0;
  --shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

[data-theme="dark"] {
  --bg-color: #121212;
  --text-color: #e8e8e8;
  --card-bg: #1e1e1e;
  --accent-color: #60a5fa;
  --border-color: #333333;
  --shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}

* {
  transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}

body {
  background: var(--bg-color);
  color: var(--text-color);
}

.card {
  background: var(--card-bg);
  border: 1px solid var(--border-color);
  box-shadow: var(--shadow);
}

Шаг 2. Переключатель темы

HTML:

<button id="theme-toggle" aria-label="Переключить тему">🌓</button>

JS:

const toggle = document.getElementById('theme-toggle');
const currentTheme = localStorage.getItem('theme') || 
  (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');

document.documentElement.setAttribute('data-theme', currentTheme);

toggle.addEventListener('click', () => {
  const theme = document.documentElement.getAttribute('data-theme');
  const newTheme = theme === 'dark' ? 'light' : 'dark';
  document.documentElement.setAttribute('data-theme', newTheme);
  localStorage.setItem('theme', newTheme);
});

Обратите внимание на prefers-color-scheme — это медиа-запрос, который считывает системные настройки пользователя. Хорошая практика — уважать выбор ОС по умолчанию.

Шаг 3. Избегаем мигания при загрузке

Чтобы страница не «мигала» светлой темой перед применением темной, скрипт установки темы нужно вставить в <head> до подключения основных стилей:

<script>
  (function() {
    const theme = localStorage.getItem('theme') || 
      (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
    document.documentElement.setAttribute('data-theme', theme);
  })();
</script>

Темная тема в WordPress

С WordPress все немного сложнее из-за структуры темы.

Шаг 1. Подключаем стили в functions.php

function enqueue_theme_styles() {
    wp_enqueue_style('main-style', get_stylesheet_uri());
    wp_enqueue_script(
        'theme-switcher',
        get_template_directory_uri() . '/js/theme-switcher.js',
        array(),
        '1.0',
        false
    );
}
add_action('wp_enqueue_scripts', 'enqueue_theme_switcher');

Шаг 2. Добавляем переменные в style.css темы

:root {
  --wp-bg: #ffffff;
  --wp-text: #23282d;
  --wp-header-bg: #ffffff;
  --wp-link: #0073aa;
}

[data-theme="dark"] {
  --wp-bg: #1a1a1a;
  --wp-text: #f0f0f0;
  --wp-header-bg: #0d0d0d;
  --wp-link: #4db8ff;
}

body {
  background-color: var(--wp-bg);
  color: var(--wp-text);
}

.site-header {
  background-color: var(--wp-header-bg);
}

a {
  color: var(--wp-link);
}

Шаг 3. Виджет переключения через шорткод

Чтобы переключатель можно было вставить в любое место через редактор Gutenberg, так же добавляем в functions.php:

function theme_switcher_shortcode() {
    return '<button id="wp-theme-toggle" class="theme-toggle-btn" aria-label="Тема">
              <span class="icon-sun">☀️</span>
              <span class="icon-moon">🌙</span>
            </button>';
}
add_shortcode('theme_toggle', 'theme_switcher_shortcode');

Использование: [theme_toggle] на любой странице.

Шаг 4. JS для WordPress с учетом кастомайзера

Если хотите дать администратору возможность выбирать тему по умолчанию через Customizer:

function register_theme_customizer($wp_customize) {
    $wp_customize->add_setting('default_theme_mode', array(
        'default' => 'light',
    ));
    $wp_customize->add_control('default_theme_mode', array(
        'label' => 'Тема по умолчанию',
        'section' => 'colors',
        'type' => 'select',
        'choices' => array('light' => 'Светлая', 'dark' => 'Темная'),
    ));
}
add_action('customize_register', 'register_theme_customizer');

Если используете кастомные блоки Gutenberg, переменные нужно продублировать и в редакторе через theme.json (json файл нужно создать в корне вашей темы):

{
  "version": 2,
  "settings": {
    "color": {
      "palette": [
        {
          "slug": "background",
          "color": "var(--wp-bg)",
          "name": "Фон"
        }
      ]
    }
  }
}

Темная тема на Laravel

Покажу, как реализовать темную тему используя Blade-компоненты и нативный JS без Alpine или Livewire — чтобы подход оставался универсальным.

Шаг 1. Регистрируем логику в AppServiceProvider

Файл app/Providers/AppServiceProvider.php создается автоматически и уже зарегистрирован в bootstrap/providers.php, поэтому дополнительно ничего подключать не нужно:

<?php

namespace App\Providers;

use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Blade::directive('themeAttribute', function () {
            return "<?php echo 'data-theme'; ?>";
        });
    }
}

Шаг 2. Layout с поддержкой темы

resources/views/layouts/app.blade.php:

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    
    <script>
        (function() {
            const theme = localStorage.getItem('theme') 
                || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
            document.documentElement.setAttribute('data-theme', theme);
        })();
    </script>

    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
    <header class="site-header">
        <x-theme-toggle />
    </header>

    <main>
        {{ $slot ?? '' }}
        @yield('content')
    </main>
</body>
</html>

Скрипт установки темы стоит выше @vite, чтобы страница не «мигала» светлым фоном перед применением темной темы.

Шаг 3. Blade-компонент переключателя

Создаем компонент стандартной командой artisan:

php artisan make:component ThemeToggle

Laravel создаст два файла: класс-компонент и шаблон. Для нашей задачи класс app/View/Components/ThemeToggle.php можно оставить пустым — вся логика на фронтенде:

<?php

namespace App\View\Components;

use Illuminate\View\Component;
use Illuminate\Contracts\View\View;

class ThemeToggle extends Component
{
    public function render(): View
    {
        return view('components.theme-toggle');
    }
}

Шаблон resources/views/components/theme-toggle.blade.php:

<button 
    id="theme-toggle" 
    class="theme-toggle"
    type="button"
    aria-label="Переключить тему"
>
    <svg class="icon-sun" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
        <circle cx="12" cy="12" r="5"/>
        <path d="M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72l1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
    </svg>
    <svg class="icon-moon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
        <path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
    </svg>
</button>

Шаг 4. Стили

Проект по умолчанию поставляется с настроенным Vite, поэтому просто редактируем основной файл стилей resources/css/app.css:

:root {
  --laravel-bg: #ffffff;
  --laravel-surface: #f8fafc;
  --laravel-text: #1e293b;
  --laravel-border: #e2e8f0;
  --laravel-accent: #f53003;
}

[data-theme="dark"] {
  --laravel-bg: #0f172a;
  --laravel-surface: #1e293b;
  --laravel-text: #f1f5f9;
  --laravel-border: #334155;
  --laravel-accent: #ff6b4a;
}

body {
  background: var(--laravel-bg);
  color: var(--laravel-text);
  transition: background 0.25s ease, color 0.25s ease;
}

.card {
  background: var(--laravel-surface);
  border: 1px solid var(--laravel-border);
  border-radius: 8px;
  padding: 1.5rem;
}

.theme-toggle {
  background: transparent;
  border: 1px solid var(--laravel-border);
  border-radius: 50%;
  width: 40px;
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  color: var(--laravel-text);
}

.icon-sun { display: none; }
.icon-moon { display: block; }

[data-theme="dark"] .icon-sun { display: block; }
[data-theme="dark"] .icon-moon { display: none; }

Шаг 5. JS-логика

Добавляем в resources/js/app.js:

document.addEventListener('DOMContentLoaded', () => {
    const toggle = document.getElementById('theme-toggle');
    
    toggle?.addEventListener('click', () => {
        const html = document.documentElement;
        const current = html.getAttribute('data-theme');
        const next = current === 'dark' ? 'light' : 'dark';
        
        html.setAttribute('data-theme', next);
        localStorage.setItem('theme', next);
    });
});

Если в проекте уже используется Axios для API-запросов (он подключен по умолчанию в файле bootstrap.js), можно добавить синхронизацию с сервером — покажу это в следующем шаге.

Шаг 6. Сохранение темы на сервере через маршрут

Для авторизованных пользователей удобно хранить выбор темы в БД, чтобы она подхватывалась на любом устройстве.

Добавляем миграцию:

php artisan make:migration add_theme_preference_to_users_table
public function up(): void
{
    Schema::table('users', function (Blueprint $table) {
        $table->string('theme_preference')->default('light');
    });
}

Выполняем миграцию:

php artisan migrate

Добавляем роут в routes/web.php:

use Illuminate\Http\Request;

Route::post('/theme/toggle', function (Request $request) {
    $request->validate(['theme' => 'required|in:light,dark']);
    
    $request->user()?->update(['theme_preference' => $request->theme]);
    
    return response()->json(['status' => 'ok']);
})->middleware(['auth']);

Обновляем JS для отправки запроса:

toggle?.addEventListener('click', async () => {
    const html = document.documentElement;
    const current = html.getAttribute('data-theme');
    const next = current === 'dark' ? 'light' : 'dark';
    
    html.setAttribute('data-theme', next);
    localStorage.setItem('theme', next);

    if (window.axios) {
        await window.axios.post('/theme/toggle', { theme: next });
    }
});

И подтягиваем сохраненное значение в layout для авторизованных пользователей:

<html data-theme="{{ auth()->check() ? auth()->user()->theme_preference : 'light' }}">

Такая связка работает без лишних зависимостей: Blade-компоненты дают чистую структуру, Vite собирает стили и скрипты «из коробки», а CSS-переменные остаются универсальным механизмом независимо от версии фреймворка.

Итоги и рекомендации

За годы работы я выделил несколько правил:

  1. Никогда не забывайте про prefers-color-scheme — это уважение к выбору пользователя на уровне ОС.
  2. Transition только на нужные свойства, а не на все через * в продакшене — это может замедлить рендеринг на слабых устройствах.
  3. localStorage — хорошо, но для авторизованных пользователей лучше синхронизировать с БД.
  4. Проверяйте контрастность — темная тема не значит просто «инвертировать цвета», тексты должны оставаться легко читаемыми.
  5. Скрипт установки темы должен быть первым в <head> — это избавит от неприятного мигания при загрузке страницы.

Такой подход с CSS-переменными универсален и легко переносится между разными технологиями — будь то простой лендинг, WordPress или Laravel-приложение. Никаких лишних зависимостей, только базовые возможности CSS и JS.