RailsFast Native - Dark mode
RailsFast ships system-following dark mode across web, Android, and iOS, plus a light/dark/system override that keeps web content and native chrome coherent. The preference has one source of truth—the Rails page’s localStorage.theme—and each native shell keeps only a cold-start mirror.
It is already wired in all three starter repositories. The dashboard header and Settings page use the same resolver, and both native shells register the theme bridge component.
The model
| Surface | Source | How it changes |
|---|---|---|
| Rails content | window.__rfTheme + data-theme |
Authored Tailwind dark ramp; never pixel-inverted |
| Android View chrome | XML Material3 roles | AppCompatDelegate DayNight mode + in-place restyle |
| Android Compose screens | The same XML roles | RailsFastTheme, which adapts those roles to Compose |
| iOS UIKit/SwiftUI chrome | Semantic colors | Window trait via overrideUserInterfaceStyle |
| Fixed-palette native “island” | Its own screen | Explicit fixed-light Activity theme / VC override |
The direction is deliberately one-way:
localStorage.theme (web SSOT)
│
├── data-theme + Tailwind content
│
└── Hotwire "theme" bridge
├── Android SharedPreferences → AppCompat
└── iOS UserDefaults → UIWindow trait
Native storage exists only because native views render before a Rails document connects. Every themed page reasserts the web value, so a stale native mirror self-heals.
Web SSOT (railsfast-base)
The core files are:
| File | Responsibility |
|---|---|
layouts/html_head/_color_scheme.html.erb |
Anti-FOUC resolver, storage, cross-document sync, preference event |
assets/tailwind/application.css |
Neutral + semantic dark ramps, physical-overlay exceptions, and .keep-light |
railsfast/ui/theme_toggle_controller.js |
Header/Settings UI state only |
railsfast/native/theme_controller.js |
Web → native bridge sender |
native/_theme_bridge.html.erb |
Hidden page-scoped bridge element |
window.__rfTheme owns five operations:
resolve()returns the renderedlight/darkscheme.apply()writes it to<html data-theme>.set(preference)is the only writer oflocalStorage.theme.current()returnslight,dark, orsystem.snapshot()atomically returns the preference, resolved scheme, and durability source (storage,memory, orunavailable) for the native bridge.
system is represented by an absent storage key. RailsFast follows prefers-color-scheme on web and native by default. If your public web design must default to light while native still follows the device, _color_scheme documents the one-line isNative variant.
Scope and anti-FOUC
A page darkens only when its layout renders _color_scheme and its body has data-app-themed. The dashboard, devise, and native_handoff layouts opt in; the public application layout stays authored-light. apply() resets an unthemed body to light after Turbo navigation so the <html> attribute cannot leak from app UI into marketing UI.
The resolver stays inline in <head> because Stimulus connects after paint. Turbo compares head elements with CSP nonces removed, retains the identical script across app-to-app visits, and the resolver binds each listener once.
Multiple controls and multiple documents
Every call to set() dispatches railsfast:theme-preference. All theme-toggle controller instances listen to it, so changing the header control also updates a Settings control already present in the same document.
Hotwire’s tab shells eagerly own separate same-origin documents. A browser storage event fires in the other documents when one writes localStorage; the resolver listens for it, reapplies the page, updates its controls, and reasserts the same preference to that document’s native bridge. This avoids a stale preloaded tab becoming visible later.
If storage throws (private browsing, full storage, or a locked-down WebView), the resolver keeps a session-only in-memory preference. Failed writes, same-value writes, and sandboxed recipients cannot rely on storage, so every explicit choice also crosses a same-origin BroadcastChannel. A snapshot signature suppresses duplicate channel + Storage delivery. A document whose initial read failed requests a peer snapshot; a durable response outranks any later memory-only response, while a memory-only recovery snapshot remains session-only and never mutates Storage merely because it answered first. When a fresh user choice arrives from a sandboxed peer, a writable receiver promotes it and broadcasts a durable acknowledgement. That acknowledgement is required even for a same-value write (which emits no storage event), so the sender and native cold-start mirror learn that the SSOT is now persistent. Broadcast Channel API.
The durability source is part of the bridge message. Native applies a memory choice live without persisting it, so it cannot turn a rejected web write into an invented cold-start preference. unavailable means even the initial read failed; native clears a potentially stale mirror and follows the system. Missing source defaults to durable storage for compatibility with an older web bundle.
Bridge startup and restore behavior
The theme controller sends on connect without checking this.enabled. That is intentional: @hotwired/hotwire-native-bridge 1.2.2 queues Bridge.send() while no native adapter exists and flushes the queue from setAdapter(). Guarding on enabled would bypass the public queue and lose the initial value on eagerly-created tabs. Unsupported clients do not instantiate the controller because BridgeComponent.shouldLoad checks the component list in the native User-Agent.
Hotwire’s BridgeComponent.restore() invokes connect() without a Stimulus disconnect. The theme controller therefore creates its preference handler once in initialize; repeated restores reassert once without accumulating event listeners.
The shared bridge partial can also appear on an intentionally unthemed public page. When window.__rfTheme is absent, the controller does nothing—it must never invent system and erase an explicit native mirror merely because the user visited a light-only page.
Exact sources: bridge pending queue, component gating and lifecycle.
Tailwind ramps, contrast, and fixed web islands
Tailwind v4 utilities read theme variables, so one scoped neutral ramp changes the whole app without editing every view:
html[data-theme="dark"] {
--color-white: #1c1c1e; /* card */
--color-black: #ffffff; /* alpha ink */
--color-gray-50: #000000; /* page base */
/* gray-100 … gray-950 */
}
The default elevation ladder matches iOS: base #000, elevated #1c1c1e, raised #2c2c2e.
The alternate neutral families (slate, zinc, neutral, and stone) alias that same gray elevation/ink ladder while dark mode is active. RailsFast views use several of them, and leaving a stock text-slate-700 unchanged would make it dark-on-dark. One ladder also prevents subtly different “black” page surfaces from creating seams against native chrome.
Chromatic utilities need role-aware handling too. A stock bg-red-50 text-red-800 alert is light-surface/dark-ink; leaving those values unchanged in dark mode can produce less than 2:1 contrast. RailsFast swaps the low surface/border rungs (50/100/200) with their dark counterparts and every high ink rung (700/800/900/950) with its light counterpart for all standard Tailwind chromatic families, from red through rose. Do not omit 950: real warning and admin headings commonly pair bg-*-50 with text-*-950, which otherwise collapses to essentially 1:1. Because low rungs can also be intentionally pale text on a dark badge, exact text-*-50/100/200 utilities retain their authored ink at the color property; exact text-*-500/600 utilities lift to *-400. High-rung action backgrounds—including bg-*-950—are likewise corrected at the background-color property, not by overriding an inherited theme variable; otherwise a hover:bg-red-700 token can darken a nested text-red-700 before hover. The literal values come from Tailwind’s canonical theme.
--color-white is intentionally adaptive: it is the elevated card surface in dark mode and lets neutral bg-gray-900 text-white actions invert coherently. White ink on an unchanged dark chromatic fill is different, so the stylesheet restores literal #fff only when the element has an actual base fill whose canonical Tailwind shade measures at least 4.5:1 against white. It deliberately does not infer foreground from a future hover:bg-* token before hover. Bright amber/green-style actions should use explicit stable dark ink and a brighter hover; for example keep-light bg-amber-600 text-gray-950 hover:bg-amber-500. Do not assume a class named text-white is readable—the runtime system tests measure the rendered foreground over every translucent ancestor and enforce a 4.5:1 minimum.
Likewise, --color-black becomes adaptive light ink for hairlines, but a modal veil is physical black. Full-screen bg-black/40, /50, and /85 overlays, backdrop:bg-black/80, and black image-gradient stops have narrowly scoped literal-black corrections. Add a matching correction and contrast contract when introducing another full-screen opacity.
Use .keep-light for a logo, brand/accent chip, image overlay, or other subtree whose authored palette must not flip. It restores every neutral and chromatic token that the dark SSOT remaps, including the property-level text-*-500/600 lift; it is a real palette boundary rather than a white/gray special case. Keep it on the smallest leaf subtree: pinning an app page or modal container also freezes every adaptive child token.
Android (railsfast-android)
Preference application
ThemeComponent receives { preference, source, reason }, normalizes the atomic snapshot in ThemePreference, persists only durable values, and maps it to:
| Preference | AppCompat mode |
|---|---|
dark |
MODE_NIGHT_YES |
light |
MODE_NIGHT_NO |
system / invalid |
MODE_NIGHT_FOLLOW_SYSTEM |
RailsFastApplication applies the mirror before any Activity inflates. The static system splash may appear before app code and can only follow its resource/OS theme; the guarantee begins with the first process-owned Activity frame.
The mirror file (railsfast_theme.xml) and app_webview/ are both excluded from Android backup. The WebView store is excluded to avoid migrating cookies/session state, so restoring only the native mirror would create a stale first frame.
Seamless switch: no Activity or WebView recreation
Both Hotwire host activities declare android:configChanges="uiMode". When setDefaultNightMode changes mode, AppCompat 1.7.1 takes its resource-update branch rather than ActivityCompat.recreate(). The Activity, Hotwire navigators, WebView document, scroll position, and Turbo state survive.
There is an important AppCompat nuance: updateResourcesConfiguration() reapplies the theme only when its private mThemeResId is nonzero. A manifest-supplied theme commonly leaves it zero. NativeChromeTheme calls ResourcesCompat.ThemeCompat.rebase() before reading attributes—direct Theme.rebase() on API 29+ and AndroidX’s compatibility shim on API 23–28—then rewrites colors baked into already-inflated views. Do not reapply ActivityInfo.theme as an API-28 fallback: a launch Activity’s manifest resource can be its temporary Splash theme, already replaced by installSplashScreen() with postSplashScreenTheme.
- status/navigation bar icon contrast;
- bottom-navigation background, selected indicator, icons, labels, and pressed/focus/hover state layers;
- top app bar, title, back arrow, overflow, and menu icons;
- an open Material bottom sheet.
Resolve from the Activity context, not a MaterialToolbar/dialog theme-overlay context: an overlay context can retain its pre-change configuration. Also use MaterialToolbar.setNavigationIconTint; assigning a tinted drawable alone is overwritten when Material reapplies its own navigation tint.
The bottom-navigation ripple is an inflation-time ColorStateList, just like its icon and label tints. Re-loading Material's XML selector through AppCompatResources after an in-place switch is not sufficient: its resource cache can return the pre-switch list even after ThemeCompat.rebase(). NativeChromeTheme therefore rebuilds Material 1.14.0's exact state table from the freshly resolved colorOnSurface (10% pressed/focus, 8% hover). Keep that table and its API-35 device regression aligned with m3_navigation_bar_ripple_color_selector.xml when upgrading Material.
Exact source: AppCompat 1.7.1 sources JAR, methods AppCompatDelegateImpl.updateAppConfiguration and updateResourcesConfiguration. See also configChanges, ThemeCompat.rebase(), and SplashScreen’s postSplashScreenTheme.
WebView and open sheets
On modern targetSdk 33+ WebView, prefers-color-scheme comes from the Activity theme’s isLightTheme; do not enable algorithmic darkening. Hotwire Native 1.2.8 also initializes a legacy FORCE_DARK media-query strategy once in HotwireWebView for older providers. Because the seamless path preserves that WebView, refreshThemeSignal reapplies Hotwire’s authored-CSS-only strategy on API 32 and below before calling window.__rfTheme.apply(). It rebases the Activity theme first, then derives dark/light from the effective public android:isLightTheme attribute rather than Hotwire’s raw Configuration.uiMode check; otherwise either the stale pre-swap theme or a fixed-light Activity on an OS-dark device can report the wrong legacy scheme.
An open FreezableWebBottomSheetFragment survives the same configuration change. It re-tints the native design_bottom_sheet, refreshes its WebView signal, and reapplies the resolver. Theme bridge messages from dialogs are therefore applied immediately; deferring them would leave the sheet’s web and native halves in different modes.
Compose is not automatically adaptive
A root Compose MaterialTheme {} does not inherit the Android View theme; Material3’s composition-local fallback is lightColorScheme(). RailsFastTheme observes LocalConfiguration for recomposition, chooses its light/dark baseline from the Activity’s effective android:isLightTheme (not raw uiMode, which can remain night inside a fixed-light Activity), resolves the XML semantic roles, and supplies a full scheme including surfaceContainer*. Native Compose screens must use it. Omitting the container roles is how lavender/tonal cards reappear even after View chrome is neutral.
Exact current source: Material3 1.3.2 sources JAR, MaterialTheme.kt / ColorScheme.kt.
Material roles
| XML role | Main consumer | Light | Dark |
|---|---|---|---|
colorSurface |
app/nav bar, page backing | white | #000 |
colorOnSurface |
primary native ink | gray-900 | near-white |
colorSurfaceContainerLow |
sheets/cards | white | #1c1c1e |
colorSurfaceContainerHigh |
raised dialogs/cards | white | #2c2c2e |
colorSecondaryContainer |
selected nav indicator | neutral | #2c2c2e |
colorOnSurfaceVariant |
secondary/unselected ink | gray-500 | system gray |
Material3’s baseline selected-nav pair is lavender (#4A4458 / #E8DEF8 in dark). Keep the explicit neutral colorSecondaryContainer / colorOnSecondaryContainer override unless lavender is your brand.
iOS (railsfast-ios)
ThemeComponent receives the same { preference, source, reason } snapshot and ThemePreferenceStore maps it to a window override. As on Android, a memory value changes the live trait but is not persisted:
| Preference | UIUserInterfaceStyle |
|---|---|
dark |
.dark |
light |
.light |
system / invalid |
.unspecified |
SceneDelegate applies the persisted mirror after constructing the UIWindow and before making it visible. Live changes apply to every connected scene window. UIKit semantic colors, SwiftUI semantic colors, navigation/tab bar appearances, and WKWebView traits then update in place. Hotwire’s BridgeComponent is already @MainActor in 1.3.0-beta, so the endpoint applies synchronously; wrapping each message in an unstructured Task can reorder rapid selections.
The mirror uses UserDefaults, which Apple includes in device backups. It is still not an authority: every page connect reasserts the WebKit value and repairs drift after restore. Apple UserDefaults.
Do not add UIUserInterfaceStyle to Info.plist—that would pin the whole app and break system. The static launch screen cannot read the web override because app code has not run; its named color follows the OS appearance. The runtime StartupLoadingView and every subsequent frame inherit the window override.
Exact source: Hotwire Native iOS BridgeComponent, UIKit override API.
Fixed-light native islands
If a fully-native screen uses fixed brand colors (for example, a yellow map UI), pin that screen rather than changing the app-wide preference:
- Android: assign an Activity theme whose parent is
Theme.Material3.Light.NoActionBar; do not inherit a base redefined invalues-night. Use fixed light tokens and dark system-bar icons. Never call process-widesetDefaultNightModeto pin one screen. - iOS: set
overrideUserInterfaceStyle = .lighton the UIViewController. If it presents an independent controller/navigation stack containing a WKWebView, pin that presented controller too; do not rely on presentation inheritance.
Verification checklist
Test each matrix row from a cold launch and from an already-open page:
| Device/OS | Web choice | Expected |
|---|---|---|
| light | system | all light |
| dark | system | all dark |
| light | dark | web + native dark |
| dark | light | web + native light |
Also verify:
- no Activity
onCreate, navigation, reload, scroll reset, or white flash on Android toggle; - back/overflow icons, bottom-navigation pressed/focus/hover state layers, and an already-open bottom sheet recolor;
- every eagerly-preloaded tab shows the new choice when selected;
- header and Settings controls agree;
native:restoreproduces one reassertion, not duplicate change messages;- blocked storage keeps every preloaded document coherent through
BroadcastChannel, marks the bridge valuememory, and does not overwrite the durable native mirror; - a writable peer promotes a fresh memory-only choice, broadcasts a durable
source: storageacknowledgement even for a same-value write, and a durable recovery response outranks later memory-only snapshots; - an unavailable initial Storage read clears a stale native mirror to
system; - public/light-only layouts do not reset native preference;
- soft error/warning/success/info/role surfaces, pale low-rung ink, and standalone/deepest-
950semantic text, literal-white chromatic actions, adaptive neutral actions, hover states, and nested semantic ink all meet 4.5:1 rendered contrast; - modal/dialog backdrops remain physical black (including translucent alpha) rather than becoming adaptive white veils;
- Android API 28–32 and 33+ WebView paths;
- iOS launch → runtime boundary, nav/tab bars, sheets, and downstream fixed-light islands;
- Android backups exclude both
app_webview/and the native theme mirror.
Do not enable algorithmic darkening
WebSettingsCompat.setAlgorithmicDarkeningAllowed(true) does not enable authored dark mode. It pixel-inverts pages that do not support dark, diverges from iOS, and mangles intentionally light public/legal content. WebView already reports its host trait through prefers-color-scheme; let Rails CSS paint the page.
Contract: WebSettings#setAlgorithmicDarkeningAllowed.
References
- Tailwind dark mode · theme variables
prefers-color-scheme·color-schememeta- Hotwire Native bridge components
- Android dark theme/WebView · Material color roles
- Apple HIG color ·
UIUserInterfaceStyle
See also: Native server · Android · iOS · Safe areas & fixed docks