Shadwire

Search the documentation

Search the documentation and component pages by title and by content.

Get started
Introduction
Installation
shadwire.json
Theming
Dark mode
Tools
CLI
Agent skill
Registry
llms.txt
Guides
Composition
Styling
Forms
Icons
Accessibility
Localisation
Blocks
Overview
Components
Overview
Accordion
Alert
Alert Dialog
Aspect Ratio
Avatar
Badge
Breadcrumb
Button
Button Group
Calendar
Card
Carousel
Chart
Checkbox
Collapsible
Combobox
Command
Context Menu
Data Table
Date Picker
Dialog
Drawer
Dropdown Menu
Empty
Field
Hover Card
Icon
Input
Input Group
Input OTP
Item
Kbd
Label
Menubar
Native Select
Navigation Menu
Pagination
Popover
Progress
Radio Group
Resizable
Scroll Area
Select
Separator
Sheet
Sidebar
Skeleton
Slider
Sonner
Spinner
Switch
Table
Tabs
Textarea
Toggle
Toggle Group
Tooltip

Dark mode

A single class on <html> switches the whole site to dark mode, and the components never need to know.

shadwire.css ties Tailwind's dark variant to a class:

@custom-variant dark (&:is(.dark *));

The dark variant applies when an ancestor has the .dark class, so you switch themes by adding or removing that class on <html>. The components only use tokens, and every token has both a light and a dark value, so dark mode needs no code in the components.

The toggle

This five-line Stimulus controller is what switches the theme on this site:

// app/javascript/controllers/theme_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  toggle() {
    const isDark = document.documentElement.classList.toggle("dark")
    localStorage.setItem("theme", isDark ? "dark" : "light")
  }

And the button. The dark variant decides which of its two icons is shown:

<%= ui_button(size: :icon, variant: :ghost,
              data: { controller: "theme", action: "theme#toggle" },
              aria: { label: "Toggle theme" }) do %>
  <%= ui_icon("moon", class: "dark:hidden") %>
  <%= ui_icon("sun", class: "hidden dark:block") %>
<% end %>

dark:hidden and hidden dark:block choose which element is visible, not its colour. That is a fine use of the dark: prefix; using it for colours is not, as explained below.

Avoiding the flash

If the class is only added after the app's JavaScript loads, people who chose the dark theme see a white flash on every page load. To avoid it, put an inline script in <head>, before any stylesheet, so it runs before the page is drawn:

<script>
  (() => {
    const stored = localStorage.getItem("theme")
    const dark = stored
      ? stored === "dark"
      : window.matchMedia("(prefers-color-scheme: dark)").matches
    document.documentElement.classList.toggle("dark", dark)
  })()

It checks the saved preference first and only then falls back to the system setting. That way someone can pick the light theme on a system set to dark, and the choice is remembered.

Don't use dark: for colours

A hand-written dark colour repeats a decision the tokens already make, and it stops matching the theme as soon as someone changes the tokens:

<%# Wrong: light and dark colours written by hand %>
<%= ui_card(class: "bg-white text-gray-900 dark:bg-gray-900 dark:text-white") do %>
<%# Right: the tokens already handle both modes %>
<%= ui_card do %>

If you need a colour the tokens don't cover, add a token. You give it a light and a dark value, and it keeps working when the theme changes.

Testing both modes

Check your screens in both modes. When something looks wrong in dark mode, the colour value itself is rarely the problem. More often two utilities with the same specificity are competing, or one of a pair of utilities is missing, so the class list looks right while the compiled CSS is not. Look at the generated CSS.