Skip to content

Lesson 3 — Dark mode

How it works

Dark mode in Tailwind v4 is pure CSS — no JavaScript needed at this stage. We override the token values inside a @media (prefers-color-scheme: dark) block. Because every component references tokens rather than raw palette values, they adapt automatically — no dark-mode-specific component code needed.

Adding dark mode to application.css

Add the @media block after your @theme block:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
@import "tailwindcss";

@theme {
  /* light mode tokens — the defaults */
  --color-primary:        #2563eb;
  --color-primary-hover:  #1d4ed8;
  --color-primary-ring:   #3b82f6;
  --color-surface:        #ffffff;
  --color-surface-alt:    #f9fafb;
  --color-surface-raised: #ffffff;
  --color-border:         #e5e7eb;
  --color-border-strong:  #d1d5db;
  --color-text:           #111827;
  --color-text-muted:     #6b7280;
  --color-text-subtle:    #9ca3af;
  /* ... all other tokens ... */
}

/* Dark mode — applied automatically when OS is set to dark */
@media (prefers-color-scheme: dark) {
  :root {
    /* Primary */
    --color-primary:        #3b82f6;
    --color-primary-hover:  #2563eb;
    --color-primary-ring:   #60a5fa;

    /* Secondary */
    --color-secondary:      #22c55e;
    --color-secondary-hover:#16a34a;
    --color-secondary-ring: #4ade80;

    /* Danger */
    --color-danger:         #f87171;
    --color-danger-hover:   #ef4444;
    --color-danger-ring:    #fca5a5;

    /* Outline */
    --color-outline-text:   #d1d5db;
    --color-outline-border: #4b5563;
    --color-outline-hover:  #374151;

    /* Surfaces */
    --color-surface:        #1f2937;
    --color-surface-alt:    #111827;
    --color-surface-raised: #374151;

    /* Borders */
    --color-border:         #374151;
    --color-border-strong:  #4b5563;

    /* Text */
    --color-text:           #f9fafb;
    --color-text-muted:     #9ca3af;
    --color-text-subtle:    #6b7280;

    /* Status */
    --color-success:        #4ade80;
    --color-success-bg:     #052e16;
    --color-warning:        #fbbf24;
    --color-warning-bg:     #1c1400;
    --color-info:           #60a5fa;
    --color-info-bg:        #0d1f3c;
  }
}

Set your OS to dark mode and reload the browser — every component should switch colours automatically.

Updating AppLayout

Add bg-surface and text-text to body so the page background and base text colour follow the active theme:

1
2
3
4
5
6
body(class: "bg-surface text-text min-h-screen flex flex-col") do
  render_nav
  render_flash
  main(class: "px-8 py-8 flex-1 w-full") { yield }
  render_footer
end

Also add a color-scheme meta tag to the <head> so the browser renders native UI elements (scrollbars, form inputs) in the correct mode:

1
meta(name: "color-scheme", content: "light dark")

What we don’t have yet

At this point dark mode is automatic — it follows the OS. There is no way for the user to override it from within the app. In Lesson 5 we add a ThemeToggle Stimulus controller that lets users switch manually, with their preference saved to localStorage.


The controller looks good but needs updating to handle the three-way interaction between system preference, manual override, and localStorage. Here’s the revised lesson: