Когда я начал делать десктопный почтовый клиент на Rust (Dioxus + Tailwind CSS), мне хотелось, чтобы переключение темы работало мгновенно, без перезагрузки, и чтобы при этом ни один компонент не знал о существовании тем. На деле всё оказалось проще, чем я ожидал.

В этой статье я покажу подход, который использую в DxMail: 6 тем (3 тёмные + 3 светлые), переключение в один клик, плавные переходы — и ни одного пропса theme в компонентах.

Oxide — тёплая тёмная тема почтового клиента

Цветовые палитры ещё в процессе доработки — показываю концепт, а не финальный вариант.

Идея

Вместо того чтобы прокидывать цвета через пропсы или контексты, я положился на каскад CSS-переменных. Схема простая:

  1. Определить палитру как CSS-переменные в @theme блоке Tailwind 4
  2. Переопределить эти переменные через [data-theme="..."] селекторы
  3. Переключать тему одним атрибутом на корневом div

Tailwind-классы вроде bg-surface-900 или text-accent ссылаются на var(--color-surface-900) и var(--color-accent). Когда CSS-переменные меняются — весь UI перекрашивается автоматически. Компоненты об этом ничего не знают.

Почему семантические имена, а не цвета?

Это ключевое решение всего подхода, поэтому сразу о нём.

Я не использую --color-blue-500 или --color-gray-800. Вместо этого — роли: surface, fg, accent, border. Когда light-тема переопределяет --color-surface-950 как #F5F0E8 (почти белый), все bg-surface-950 автоматически становятся светлыми. Если бы я писал bg-gray-900, пришлось бы менять классы в каждом компоненте.

Именно семантические токены делают возможным то, что компоненты вообще не знают о темах — они оперируют ролями, а не конкретными цветами.

Шаг 1: Определяю дизайн-токены

В Tailwind CSS 4 есть блок @theme, который регистрирует кастомные токены прямо в конфиге. Я использую его как «тему по умолчанию» (Oxide — тёплая тёмная):

css
/* tailwind.css */
@import "tailwindcss";
@source "./crates/**/*.rs";

@theme {
    --font-sans: 'Inter', system-ui, sans-serif;

    /* Поверхности (от самой тёмной к светлой) */
    --color-surface-950: #151110;
    --color-surface-900: #1c1714;
    --color-surface-850: #221c18;
    --color-surface-800: #2a231d;
    --color-surface-700: #362e26;
    --color-surface-600: #443a30;

    /* Акцентный цвет */
    --color-accent: #D47A2E;
    --color-accent-light: #E89040;
    --color-accent-dark: #B86420;
    --color-accent-muted: rgba(212,122,46,0.15);
    --color-accent-fg: #ffffff;

    /* Текст (семантическая иерархия) */
    --color-fg: #F0E8E0;
    --color-fg-secondary: #DDD2C5;
    --color-fg-muted: #B8A898;
    --color-fg-dim: #8A7B6C;
    --color-fg-faint: #6A5D50;
    --color-fg-ghost: #4A4038;

    /* Границы и ховеры */
    --color-border: rgba(180,140,100,0.08);
    --color-border-bold: rgba(180,140,100,0.15);
    --color-hover: rgba(180,140,100,0.05);
    --color-hover-bold: rgba(180,140,100,0.08);

    /* Цвета аватарок (9 штук) */
    --color-avatar-1: #8B5E3C;
    --color-avatar-2: #6B7B4A;
    /* ... */

    /* Метки */
    --color-label-red: #C65D4A;
    --color-label-blue: #5B8DB8;
    --color-label-green: #6AAB72;
    --color-label-purple: #9A78B8;
    --color-label-amber: #C9944A;
}

Обратите внимание на @source "./crates/**/*.rs" — Tailwind 4 сканирует Rust-файлы и находит используемые классы прямо в RSX-разметке. Никаких content из конфига v3.

После этого в Rust-коде можно писать:

rust
div { class: "bg-surface-900 text-fg-muted border-border" }

И Tailwind сгенерирует утилиты, которые ссылаются на наши переменные.

Шаг 2: Добавляю альтернативные темы через data-theme

Каждая альтернативная тема — это набор переопределений CSS-переменных через атрибут data-theme:

css
/* main.css */

/* Nord — холодная полярная тема */
[data-theme="nord"] {
    --color-surface-950: #2E3440;
    --color-surface-900: #353C4A;
    --color-surface-850: #3B4252;
    --color-surface-800: #434C5E;
    --color-surface-700: #4C566A;
    --color-surface-600: #556178;

    --color-accent: #88C0D0;
    --color-accent-light: #9DD0DE;
    --color-accent-dark: #6EAABB;
    --color-accent-muted: rgba(136,192,208,0.15);
    --color-accent-fg: #2E3440;

    --color-fg: #ECEFF4;
    --color-fg-secondary: #D8DEE9;
    --color-fg-muted: #A3ADB8;
    --color-fg-dim: #7B8794;
    --color-fg-faint: #5C6672;
    --color-fg-ghost: #4C566A;

    --color-border: rgba(200,215,230,0.08);
    --color-border-bold: rgba(200,215,230,0.15);
    --color-hover: rgba(200,215,230,0.05);
    --color-hover-bold: rgba(200,215,230,0.08);

    /* Аватарки, метки... */
}

/* Latte — светлая тёплая тема */
[data-theme="latte"] {
    --color-surface-950: #F5F0E8;
    --color-surface-900: #EDE6DB;
    /* ... */
    --color-fg: #3C3328;
    --color-fg-secondary: #564A3C;
    /* ... */
}

Тут нет никакого JavaScript-рантайма, никаких CSS-in-JS библиотек. Чистый CSS каскад: атрибут data-theme на родительском элементе переопределяет переменные, и все дочерние элементы подхватывают новые значения.

В проекте 6 тем: Oxide (дефолт), Nord, Amethyst — тёмные; Latte, Frost, Meadow — светлые. Каждая переопределяет полный набор переменных, включая аватарки и метки, так что тема выглядит целостно.

Theme picker — выбор из шести тем

Пикер тем: три тёмных и три светлых варианта. Палитры WIP.

Шаг 3: Состояние в Dioxus

На стороне Rust всё тоже минимально. Текущая тема — просто String в общем состоянии приложения:

rust
// state.rs
#[derive(Debug, Clone)]
pub struct AppState {
    pub current_theme: String,
    // ...другие поля
}

impl AppState {
    pub fn new(config: AppConfig) -> Self {
        Self {
            current_theme: "oxide".into(),
            // ...
        }
    }
}

Шаг 4: Привязываю тему к DOM

В корневом компоненте App тема из стейта транслируется в data-theme атрибут. Для дефолтной темы (Oxide) атрибут пустой — работают значения из @theme:

rust
// app.rs
#[component]
pub fn App() -> Element {
    let state = use_signal(|| AppState::new(config.read().clone()));

    let theme = state.read().current_theme.clone();
    let data_theme = if theme == "oxide" { String::new() } else { theme };

    rsx! {
        document::Stylesheet { href: asset!("/assets/tailwind.css") }
        document::Stylesheet { href: asset!("/assets/main.css") }

        div {
            class: "bg-surface-950 text-fg-muted h-screen flex flex-col overflow-hidden",
            "data-theme": "{data_theme}",

            TitleBar { state, sync_tx }
            // ...остальной UI
        }
    }
}

Вот и вся связка. Компонент App — единственное место, которое знает про data-theme. Все остальные компоненты просто используют Tailwind-классы, и им всё равно, какая тема активна.

Шаг 5: UI переключения тем

Пикер тем живёт в тайтл-баре. Темы описаны как константы — ID, название, цвет фона и акцент для превью:

rust
// title_bar.rs
const DARK_THEMES: &[(&str, &str, &str, &str)] = &[
    ("oxide",    "Oxide",    "#1c1714", "#D47A2E"),
    ("nord",     "Nord",     "#3B4252", "#88C0D0"),
    ("amethyst", "Amethyst", "#1E1B32", "#C792EA"),
];

const LIGHT_THEMES: &[(&str, &str, &str, &str)] = &[
    ("latte",    "Latte",    "#EDE6DB", "#C77830"),
    ("frost",    "Frost",    "#EBF0F5", "#4A8FD4"),
    ("meadow",   "Meadow",   "#E8EDE5", "#5A9A5A"),
];

Каждая кнопка — квадратик 32×32 с двумя цветными точками (фон и акцент), чтобы пользователь видел характер темы до переключения:

rust
fn theme_button(
    id: &str,
    title: &str,
    dot_color: &str,
    accent_color: &str,
    current: &str,
    mut state: Signal<AppState>,
    mut show_themes: Signal<bool>,
) -> Element {
    let is_active = id == current;
    let btn_class = if is_active { "theme-btn active" } else { "theme-btn" };
    let id_owned = id.to_string();

    rsx! {
        button {
            class: "{btn_class}",
            title: "{title}",
            onclick: move |_| {
                state.write().current_theme = id_owned.clone();
                show_themes.set(false);
            },
            // Цвет фона темы
            span {
                style: "width:11px;height:11px;border-radius:3px;position:absolute;top:3px;left:3px;background:{dot_color}",
            }
            // Акцентный цвет темы
            span {
                style: "width:9px;height:9px;border-radius:2px;position:absolute;bottom:3px;right:3px;background:{accent_color}",
            }
        }
    }
}

Что происходит при клике: новый ID записывается в state.write().current_theme, реактивность Dioxus перерисовывает App, атрибут data-theme обновляется, CSS-переменные каскадируют вниз по DOM — и весь UI перекрашивается.

Шаг 6: Плавные переходы

Чтобы смена темы не была резким переключением, добавляю transition на цветовые свойства:

css
@layer base {
    body, body * {
        transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
    }
}

Теперь при переключении с Oxide на Nord цвета плавно перетекают за 300мс. Блок @layer base гарантирует, что Tailwind-утилиты могут переопределить transitions при необходимости.

Как компоненты используют тему

Самое приятное — компоненты не знают про темы. Они просто используют семантические классы:

rust
// Кнопка «Написать» в сайдбаре
button {
    class: "w-full py-2 rounded-lg bg-accent text-accent-fg \
            hover:bg-accent-light transition",
    "Написать"
}

// Аватарка с детерминированным цветом
div {
    class: "w-8 h-8 rounded-lg bg-avatar-1 flex items-center \
            justify-center text-fg text-xs font-semibold",
    "{initials}"
}

// Активная папка в сайдбаре
button {
    class: if is_active {
        "flex items-center gap-2.5 w-full py-[7px] rounded-lg \
         bg-accent-muted text-accent font-medium"
    } else {
        "flex items-center gap-2.5 w-full py-[7px] rounded-lg \
         hover:bg-hover text-fg-dim"
    }
}

bg-accent — это оранжевый в Oxide, голубой в Nord, фиолетовый в Amethyst, зелёный в Meadow. Компонент пишется один раз и работает со всеми темами.

Итоговая архитектура

text
tailwind.css
  @theme { --color-surface-900: #1c1714; ... }
  (дефолтная палитра = Oxide)
         │  Tailwind генерирует утилиты:
         │  .bg-surface-900 { background: var(--color-surface-900) }
main.css
  [data-theme="nord"]  { --color-surface-900: #353C4A; ... }
  [data-theme="latte"] { --color-surface-900: #EDE6DB; ... }
         │  Каскад CSS-переменных
App (Rust/Dioxus)
  div { "data-theme": "{theme}", ... }
  ├─ TitleBar ─── state.write().current_theme = "nord"
  ├─ Sidebar ──── bg-accent, text-fg
  ├─ MailList ──── text-fg, bg-hover
  └─ MailView ──── bg-surface, text-fg
  (компоненты не знают о темах)

Что стоит учесть

Персистентность выбора. Тема живёт в рантайме — если не сохранить выбор в конфиг, после перезапуска она сбросится на дефолтную. Стоит сразу заложить запись в конфиг при переключении.

Фоновый цвет нативного окна. В Dioxus Desktop цвет окна задаётся при создании и не меняется динамически:

rust
.with_background_color((21, 17, 16, 255))

Если поддерживаете и тёмные, и светлые темы — при переключении на светлую на долю секунды может мелькнуть тёмный фрейм до рендера HTML. Выход — задать нейтральный серый, который не бросается в глаза ни в одном режиме.

Полнота палитры. Каждая тема должна переопределять все переменные, а не только основные. Если забыть про аватарки или метки — они останутся от дефолтной темы и будут выбиваться из общей картины. Проще всего скопировать полный блок переменных и менять значения.

Контрастность в светлых темах. При подборе цветов для светлых тем стоит проверять контраст текста на фоне — особенно для fg-muted и fg-dim, которые в тёмных темах и так неяркие. Я ориентировался на WCAG AA (контраст ≥ 4.5:1 для основного текста).

Резюме

Вся система тем — это:

  • ~55 CSS-переменных в @theme блоке (дефолтная палитра)
  • ~55 переопределений × N тем в [data-theme] селекторах
  • 1 строка в Rust для привязки: "data-theme": "{data_theme}"
  • 1 строка для переключения: state.write().current_theme = "nord".into()

Ни один компонент не принимает theme в пропсах. Просто пишете bg-accent и text-fg-muted — и оно работает в 6 палитрах.

Tailwind CSS 4 + CSS-переменные + data-theme атрибут — простой и мощный подход с нулевым рантайм-оверхедом.