BUILT INTO EVERY GOODSPEED APP
Dark Mode
Light and dark themes ship in every app behind one config flag. useTheme persists a light/dark/system preference to AsyncStorage and resolves 'system' live against the OS setting, and both palettes are generated from gas.config.ts design tokens.
- Tier: Core
- Status: Config-toggled
- Config: features.darkMode
WHY IT MATTERS
Most apps bolt dark mode on late: a second set of hardcoded colors, a manual toggle, and a flash of the wrong theme on every cold start while the saved preference loads. The template treats it as part of the design system from the first build. useTheme in hooks/useTheme.ts holds a three-way preference (light, dark, or system) and persists it to AsyncStorage under the key @<app-slug>:theme_preference. When the preference is 'system', the resolved scheme is read live from React Native's useColorScheme(), so changing the OS appearance while the app is open re-renders every screen without a tap. The two palettes, LightColors and DarkColors, are built from gasConfig.design.colors at module load, so editing a brand color in gas.config.ts updates both schemes everywhere at once rather than in two parallel stylesheets.
The cold-start flash is handled on purpose. useTheme initializes from the configured default (gasConfig.features.darkMode.default, 'system' out of the box) on the first synchronous render and exposes a loaded boolean that flips to true only after the AsyncStorage read resolves, so a screen can hold its content until the stored preference is known. The root layout keeps the splash up during that window with SplashScreen.preventAutoHideAsync(). Every change calls captureEvent('theme_changed', { theme }) into PostHog, so the share of users who override the system setting is measurable rather than guessed. Setting features.darkMode.enabled to false hides the Appearance row in settings while leaving the resolved-color system intact, so a single-scheme app still reads correct tokens with no dead code paths.
HOW IT IS WIRED
Real code from the GAS template
The code below is drawn from hooks/useTheme.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.
// hooks/useTheme.ts
export function useTheme() {
const systemScheme = useColorScheme();
const [preference, setPreference] = useState<ColorScheme>(DEFAULT_PREFERENCE);
const [loaded, setLoaded] = useState(false);
// Load persisted preference on mount
useEffect(() => {
AsyncStorage.getItem(THEME_KEY)
.then((val) => {
if (val === 'light' || val === 'dark' || val === 'system') setPreference(val);
setLoaded(true);
})
.catch(() => {
addBreadcrumb('theme', 'Failed to load theme preference from storage');
setLoaded(true);
});
}, []);
const setTheme = useCallback(async (scheme: ColorScheme) => {
setPreference(scheme);
try { await AsyncStorage.setItem(THEME_KEY, scheme); }
catch { addBreadcrumb('theme', 'Failed to persist theme preference'); }
}, []);
// Resolve 'system' to the live OS scheme
const resolved: 'light' | 'dark' =
preference === 'system' ? (systemScheme === 'dark' ? 'dark' : 'light') : preference;
return { preference, resolved, setTheme, loaded };
}Source: goodspeed-apps/gas-template → hooks/useTheme.ts
HONEST LIMITS
When Dark Mode is the wrong choice
Dark mode is the right default for almost every consumer app, but a few should pin a single scheme. If your brand or an accessibility audit mandates one fixed palette (common in regulated finance and healthcare), set gasConfig.features.darkMode.enabled to false and choose the scheme with gasConfig.features.darkMode.default. That hides the Appearance toggle in settings.tsx so users cannot diverge from the approved colors, while the token system still resolves the one palette correctly across every screen. Watch one wiring detail. The dynamic theme comes from useThemeColors(), which subscribes to the context. lib/theme.ts also exports static Colors.light and Colors.dark objects for places that cannot use a hook, and those static imports do not re-render when setTheme runs. If a component reads colors straight from lib/theme instead of useThemeColors(), it will not reflect the user's stored preference live. Use the hook in anything that should respond to a theme change.
Tier: Core · Config-toggled
Evaluate your use case
Check whether dark mode aligns with your target audience, platform constraints, and regulatory environment before enabling it.
Audit the config
The `features.darkMode` flag controls this feature. Set it to false in gas.config.ts to disable the feature entirely with no residual code paths.
Seek alternatives
If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.
APPS USING THIS FEATURE
Apps built with Dark Mode
These apps were generated by Goodspeed and use dark mode as a core part of their experience. Each link goes to the full app marketing page.
CAPABILITIES
Dark Mode capability breakdown
Concrete dimensions of what the built-in dark mode implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| System-appearance follow | When the preference is 'system', resolved is recomputed from React Native's useColorScheme() on every render, so flipping the OS appearance updates the app live with no user action. | Live |
| Persisted override | setTheme('dark' | 'light') writes to AsyncStorage under @<app-slug>:theme_preference; the choice survives restarts and supersedes the system setting until the user picks 'System' again. | AsyncStorage |
| Config-driven tokens | LightColors and DarkColors are built from gasConfig.design.colors at module load, so a palette edit in gas.config.ts propagates to both schemes without touching component code. | gas.config.ts |
| No-flash gate | useTheme exposes a loaded boolean that flips true only after the storage read resolves; the root layout holds SplashScreen.preventAutoHideAsync() so screens can wait for the real preference. | loaded flag |
| Web behavior | useColorScheme() reads the browser's prefers-color-scheme and AsyncStorage maps to localStorage; colors are inline styles, so there is no CSS-variable hydration step and no server-render flash. | Supported |
COMMON QUESTIONS
Does 'system' watch the OS setting in real time or only read it at launch?
In real time. useColorScheme() from React Native is a live hook, and the resolved scheme is recomputed from it on every render. When the preference is 'system', switching the OS appearance while the app is open re-renders every consumer of useThemeColors() immediately, with no relaunch and no manual refresh. Only when the user explicitly picks Light or Dark does the app stop following the OS, and that explicit choice is persisted so it survives a restart.
How is the preference stored, and what is the key?
setTheme calls AsyncStorage.setItem with the key @<app-slug>:theme_preference (for example @my-app:theme_preference) and a value of 'light', 'dark', or 'system'. The key is built once at module load from gasConfig.app.slug. On web, AsyncStorage maps to localStorage, so the same code path persists the preference in the browser. A failed read or write is caught and logged as a Sentry breadcrumb rather than thrown, so a storage error degrades to the default theme instead of crashing the screen.
Is there a flash of the wrong theme on a cold start?
The hook starts from gasConfig.features.darkMode.default (system by default) on the first synchronous render, then updates once the AsyncStorage read resolves. To avoid showing the default before the stored preference loads, useTheme exposes a loaded boolean and the root layout holds the splash screen open with SplashScreen.preventAutoHideAsync(). Gate your first screen on loaded and the user never sees a swap. If you ignore loaded and the stored preference differs from the default, there is a brief window where a swap is visible.
Can I add more than two themes, or custom per-screen palettes?
The template ships exactly two palettes, LightColors and DarkColors, both derived from gasConfig.design.colors. There is no built-in concept of a third named theme. You can extend the system by adding palettes to the ThemeContext and widening the ColorScheme type, but the config-driven default path assumes two. Per-screen overrides are better handled by composing token values locally rather than introducing a new global theme, so the persisted-preference and system-follow logic stays in one place.
GET IT BUILT INTO YOUR APP