, Š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.