Theme toggle
UI::ThemeToggleA light/dark/system theme switcher.
# VelvetKit
## Theme toggle (`UI::ThemeToggle`)
A light/dark/system theme switcher.
Add it: `bin/rails generate velvetkit:add theme_toggle`
Also adds: `icon`, `spinner`, `button`, `menu`
Stimulus: `ui--theme`
### Arguments
```ruby
UI::ThemeToggle.new(label: nil, class: nil, **html_attrs)
```
### Notes
UI::ThemeToggle — System / Light / Dark picker.
The preference lives in localStorage under `ui-theme` and is applied by the
`ui--theme` Stimulus controller, which sets `.dark` on <html>. The initial
class is set pre-paint by layouts/_theme_script to avoid a flash.
Usage:
<%= render(UI::ThemeToggle.new) %>
### Example: In A Header
```erb
<%# Mirrors the real app header: sidebar trigger left, theme toggle right. %>
<div class="rounded-lg border overflow-hidden">
<header class="flex border-b h-14 shrink-0 items-center gap-2">
<div class="flex items-center gap-2 px-2">
<%= render(UI::Sidebar::Trigger.new(sidebar_id: "preview-sidebar")) %>
</div>
<div class="ml-auto flex items-center gap-2 px-2">
<%= render(UI::ThemeToggle.new) %>
</div>
</header>
<div class="p-6">
<%= render(UI::Text.new) { "Switch the theme with the control above — the whole page follows." } %>
</div>
</div>
```
### Example: Palette
```erb
<%# Every semantic token side by side. The fastest way to review both themes:
flip the toggle and check nothing goes muddy or illegible. %>
<% pairs = [
["background", "foreground"], ["card", "card-foreground"],
["popover", "popover-foreground"], ["primary", "primary-foreground"],
["secondary", "secondary-foreground"], ["muted", "muted-foreground"],
["accent", "accent-foreground"], ["sidebar", "sidebar-foreground"],
["sidebar-accent", "sidebar-accent-foreground"]
] %>
<div class="flex flex-col gap-6">
<div class="flex items-center gap-3">
<%= render(UI::ThemeToggle.new) %>
<%= render(UI::Typography::Muted.new) { "Flip the theme, then scan for anything unreadable." } %>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<% pairs.each do |bg, fg| %>
<div class="rounded-md border p-4" style="background: var(--<%= bg %>); color: var(--<%= fg %>);">
<div class="text-sm font-medium">--<%= bg %></div>
<div class="text-xs opacity-80">--<%= fg %></div>
</div>
<% end %>
</div>
<div class="flex flex-wrap items-center gap-3">
<%= render(UI::Button.new) { "Primary" } %>
<%= render(UI::Button.new(variant: :secondary)) { "Secondary" } %>
<%= render(UI::Button.new(variant: :outline)) { "Outline" } %>
<%= render(UI::Button.new(variant: :ghost)) { "Ghost" } %>
<%= render(UI::Button.new(variant: :destructive)) { "Destructive" } %>
</div>
<div class="flex flex-wrap items-center gap-3">
<% %i[default secondary destructive outline success warning].each do |v| %>
<%= render(UI::Badge.new(variant: v)) { v.to_s.capitalize } %>
<% end %>
</div>
<div class="flex flex-col gap-3 max-w-md">
<% %i[default destructive warning success info].each do |v| %>
<%= render(UI::Alert.new(variant: v)) do |alert| %>
<% alert.with_title { v.to_s.capitalize } %>
<% alert.with_description { "How the #{v} alert reads in this theme." } %>
<% end %>
<% end %>
</div>
</div>
```
bin/rails generate velvetkit:add theme_toggle
- Stimulus
- ui--theme
- Installs
- Nothing
Examples
In A Header
Switch the theme with the control above — the whole page follows.
in_a_header.html.erb
<%# Mirrors the real app header: sidebar trigger left, theme toggle right. %>
<div class="rounded-lg border overflow-hidden">
<header class="flex border-b h-14 shrink-0 items-center gap-2">
<div class="flex items-center gap-2 px-2">
<%= render(UI::Sidebar::Trigger.new(sidebar_id: "preview-sidebar")) %>
</div>
<div class="ml-auto flex items-center gap-2 px-2">
<%= render(UI::ThemeToggle.new) %>
</div>
</header>
<div class="p-6">
<%= render(UI::Text.new) { "Switch the theme with the control above — the whole page follows." } %>
</div>
</div>
Palette
Flip the theme, then scan for anything unreadable.
--background
--foreground
--card
--card-foreground
--popover
--popover-foreground
--primary
--primary-foreground
--secondary
--secondary-foreground
--muted
--muted-foreground
--accent
--accent-foreground
--sidebar
--sidebar-foreground
--sidebar-accent
--sidebar-accent-foreground
Default
Secondary
Destructive
Outline
Success
Warning
Default
How the default alert reads in this theme.
Destructive
How the destructive alert reads in this theme.
Warning
How the warning alert reads in this theme.
Success
How the success alert reads in this theme.
Info
How the info alert reads in this theme.
palette.html.erb
<%# Every semantic token side by side. The fastest way to review both themes:
flip the toggle and check nothing goes muddy or illegible. %>
<% pairs = [
["background", "foreground"], ["card", "card-foreground"],
["popover", "popover-foreground"], ["primary", "primary-foreground"],
["secondary", "secondary-foreground"], ["muted", "muted-foreground"],
["accent", "accent-foreground"], ["sidebar", "sidebar-foreground"],
["sidebar-accent", "sidebar-accent-foreground"]
] %>
<div class="flex flex-col gap-6">
<div class="flex items-center gap-3">
<%= render(UI::ThemeToggle.new) %>
<%= render(UI::Typography::Muted.new) { "Flip the theme, then scan for anything unreadable." } %>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<% pairs.each do |bg, fg| %>
<div class="rounded-md border p-4" style="background: var(--<%= bg %>); color: var(--<%= fg %>);">
<div class="text-sm font-medium">--<%= bg %></div>
<div class="text-xs opacity-80">--<%= fg %></div>
</div>
<% end %>
</div>
<div class="flex flex-wrap items-center gap-3">
<%= render(UI::Button.new) { "Primary" } %>
<%= render(UI::Button.new(variant: :secondary)) { "Secondary" } %>
<%= render(UI::Button.new(variant: :outline)) { "Outline" } %>
<%= render(UI::Button.new(variant: :ghost)) { "Ghost" } %>
<%= render(UI::Button.new(variant: :destructive)) { "Destructive" } %>
</div>
<div class="flex flex-wrap items-center gap-3">
<% %i[default secondary destructive outline success warning].each do |v| %>
<%= render(UI::Badge.new(variant: v)) { v.to_s.capitalize } %>
<% end %>
</div>
<div class="flex flex-col gap-3 max-w-md">
<% %i[default destructive warning success info].each do |v| %>
<%= render(UI::Alert.new(variant: v)) do |alert| %>
<% alert.with_title { v.to_s.capitalize } %>
<% alert.with_description { "How the #{v} alert reads in this theme." } %>
<% end %>
<% end %>
</div>
</div>
API
Arguments
UI::ThemeToggle.new(label: nil, class: nil, **html_attrs)
Notes
UI::ThemeToggle — System / Light / Dark picker.
The preference lives in localStorage under `ui-theme` and is applied by the
`ui--theme` Stimulus controller, which sets `.dark` on <html>. The initial
class is set pre-paint by layouts/_theme_script to avoid a flash.
Usage:
<%= render(UI::ThemeToggle.new) %>