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

Цветовые палитры ещё в процессе доработки — показываю концепт, а не финальный вариант.
Идея
Вместо того чтобы прокидывать цвета через пропсы или контексты, я положился на каскад CSS-переменных. Схема простая:
- Определить палитру как CSS-переменные в
@themeблоке Tailwind 4 - Переопределить эти переменные через
[data-theme="..."]селекторы - Переключать тему одним атрибутом на корневом
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 — тёплая тёмная):
/* 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-коде можно писать:
div { class: "bg-surface-900 text-fg-muted border-border" }
И Tailwind сгенерирует утилиты, которые ссылаются на наши переменные.
Шаг 2: Добавляю альтернативные темы через data-theme
Каждая альтернативная тема — это набор переопределений CSS-переменных через атрибут data-theme:
/* 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 — светлые. Каждая переопределяет полный набор переменных, включая аватарки и метки, так что тема выглядит целостно.

Пикер тем: три тёмных и три светлых варианта. Палитры WIP.
Шаг 3: Состояние в Dioxus
На стороне Rust всё тоже минимально. Текущая тема — просто String в общем состоянии приложения:
// 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:
// 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, название, цвет фона и акцент для превью:
// 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 с двумя цветными точками (фон и акцент), чтобы пользователь видел характер темы до переключения:
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 на цветовые свойства:
@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 при необходимости.
Как компоненты используют тему
Самое приятное — компоненты не знают про темы. Они просто используют семантические классы:
// Кнопка «Написать» в сайдбаре 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. Компонент пишется один раз и работает со всеми темами.
Итоговая архитектура
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 цвет окна задаётся при создании и не меняется динамически:
.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 атрибут — простой и мощный подход с нулевым рантайм-оверхедом.