, Štefan Húska
Dark mode v Rails s Tailwind CSS 4 a Stimulus: kompletný návod
Tmavý režim už dávno nie je len estetická záležitosť — používatelia ho očakávajú. Ak používate Rails s Tailwind CSS 4, implementácia nie je zložitá, ale vyžaduje si premyslený prístup na niekoľkých úrovniach: CSS konfigurácia, JavaScript toggle, prevencia blikania pri načítaní a systematická úprava všetkých šablón. V tomto článku ukážem, ako sme dark mode pridali do fotografického portfólia postaveného na Rails, Tailwind CSS 4, Stimulus a ViewComponent — v jednom ucelenom commite.
Tailwind CSS 4: @custom-variant namiesto darkMode: 'class'
Tailwind CSS 4 zmenil spôsob, akým sa konfiguruje dark mode. V trojke ste mali darkMode: 'class' v tailwind.config.js. V štvorke je to elegantnejšie — použijete @custom-variant priamo v CSS:
@custom-variant dark (&:where(.dark, .dark *));
@import "tailwindcss";
Tento riadok musí byť pred @import "tailwindcss". Hovorí Tailwindu: „Keď narazíš na dark: prefix v triede, aplikuj ho vtedy, keď element alebo jeho rodič má triedu .dark.” Selektor :where() má nulovú špecificitu, takže neprelomí kaskádu — čisté a predvídateľné.
Pre pagináciu (knižnica Pagy) sme pridali aj jednoduchý CSS override:
.dark .pagy {
--B: -1;
}
Toto invertuje jasovú premennú Pagy komponentu, takže stránkovanie automaticky funguje v tmavom režime bez nutnosti meniť šablónu.
Stimulus controller pre toggle
Prepínanie témy rieši malý Stimulus controller. Kľúčové rozhodnutie bolo použiť localStorage na zapamätanie voľby a classList.toggle() na <html> elemente:
import { Controller } from "@hotwired/stimulus"
const MOON_ICON = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"><path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" /></svg>`
const SUN_ICON = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" /></svg>`
export default class extends Controller {
static targets = ["icon"]
connect() {
this.updateIcon()
}
toggle() {
const isDark = document.documentElement.classList.toggle("dark")
localStorage.setItem("theme", isDark ? "dark" : "light")
this.updateIcon()
}
updateIcon() {
const isDark = document.documentElement.classList.contains("dark")
this.iconTarget.innerHTML = isDark ? SUN_ICON : MOON_ICON
}
}
Niekoľko postrehov k tomuto riešeniu. SVG ikony sú uložené priamo ako template literály — žiadna závislosť na ikonových knižniciach. Controller má jediný target pre ikonu a logika je triviálna: toggle triedy, uloženie do localStorage, aktualizácia ikony. Metóda connect() zabezpečí správnu ikonu pri načítaní stránky.
Tlačidlo v navigácii je jednoduché:
<button data-controller="theme"
data-action="click->theme#toggle"
aria-label="Prepnúť tému"
class="p-1.5 rounded-lg hover:bg-stone-200 dark:hover:bg-stone-800 transition-colors">
<span data-theme-target="icon"></span>
</button>
Prevencia FOUC (Flash of Unstyled Content)
Toto je detail, ktorý mnohí vynechajú. Ak nastavíte .dark triedu až v Stimulus connect(), používateľ uvidí na zlomok sekundy svetlú verziu, kým sa JavaScript načíta. Riešením je inline skript v <head>, ktorý sa vykoná synchrónne pred renderovaním stránky:
<%= javascript_tag nonce: true do %>
(function() {
var theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
<% end %>
Všimnite si dva dôležité aspekty. Po prvé, nonce: true — ak máte Content Security Policy (a mali by ste), inline skripty bez nonce budú zablokované. Rails helper sa o to postará automaticky. Po druhé, fallback na prefers-color-scheme — ak používateľ ešte nemá uloženú preferenciu, rešpektujeme systémové nastavenie. Toto je lepší UX než defaultne „svetlý pre všetkých”.
Systematická úprava šablón
Najväčšia časť práce je manuálna — pridať dark: varianty ku všetkým farebným triedam. Tu je niekoľko vzorov, ktoré sa opakujú:
Textové farby majú jednoduchú inverziu — svetlé stone-900 sa mení na tmavé stone-100 a naopak:
<!-- Nadpisy -->
<h1 class="text-stone-900 dark:text-stone-100">...</h1>
<!-- Sekundárny text -->
<p class="text-stone-500 dark:text-stone-400">...</p>
<!-- Terciárny text (napr. dátumy) -->
<span class="text-stone-400 dark:text-stone-500">...</span>
Pozadia a bordery tiež sledujú konzistentný vzor:
<body class="bg-stone-50 dark:bg-stone-950 text-stone-900 dark:text-stone-100">
<header class="border-b border-stone-200 dark:border-stone-800">
Upozornenia (flash messages) potrebujú oba smery — pozadie aj text:
<!-- Success flash -->
<div class="bg-emerald-50 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200
border-emerald-200 dark:border-emerald-800">
<!-- Draft badge -->
<span class="bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200">
ViewComponent a prose-invert
Ak používate Tailwind Typography plugin (@tailwindcss/typography), dark mode pre prózový obsah je jednoriadková záležitosť — stačí pridať dark:prose-invert:
class PostSectionComponent < ViewComponent::Base
PROSE_CLASSES = "prose prose-lg prose-stone dark:prose-invert max-w-none ..."
end
Trieda prose-invert automaticky invertuje všetky typografické farby. Nemusíte riešiť nadpisy, odkazy, kódové bloky ani obrázky individuálne — plugin to zvládne za vás. Toto je jedna z najväčších úspor času pri implementácii dark mode.
Čo si z toho odniesť
Implementácia dark mode v Rails + Tailwind CSS 4 stacku nie je rocket science, ale má niekoľko nečakaných detailov. @custom-variant syntax v Tailwind 4 je čistejšia ako stará konfigurácia. Inline skript v <head> s nonce: true je nevyhnutný pre plynulý zážitok bez blikania. A prose-invert vám ušetrí desiatky riadkov manuálnych dark variantov pre textový obsah. Najdôležitejšie je však byť systematický — prejsť každý view, každý komponent a každý partial.