Frontile supports light and dark themes, and theme inversion for creating visual contrast within your application.
Toggle between light and dark themes by adding or removing the dark class on a parent element (typically <html> or <body>):
<!-- Dark mode -->
<html class="dark">
<!-- All content uses dark theme -->
</html>
<!-- Light mode -->
<html class="light">
<!-- All content uses light theme -->
</html>
The light class is optional since light mode is the default. However, it's useful when you want to be explicit.
Here's a basic implementation. It stays a static snippet rather than a live demo
because it writes to localStorage and swaps classes on <html>, which would
fight this site's own theme switcher.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Button } from 'frontile';
export default class ThemeToggle extends Component {
@tracked isDark = false;
constructor(owner: unknown, args: Record<string, unknown>) {
super(owner as never, args);
// Saved choice wins; fall back to the system preference.
const saved = localStorage.getItem('theme');
this.isDark = saved
? saved === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
this.applyTheme();
}
@action
toggleTheme(): void {
this.isDark = !this.isDark;
this.applyTheme();
localStorage.setItem('theme', this.isDark ? 'dark' : 'light');
}
applyTheme(): void {
const html = document.documentElement;
// Both classes are meaningful — `light` is what makes `theme-inverse`
// work inside a dark region — so set one and clear the other.
html.classList.toggle('dark', this.isDark);
html.classList.toggle('light', !this.isDark);
}
<template>
<Button @variant='outline' @onPress={{this.toggleTheme}}>
{{if this.isDark '☀️ Light Mode' '🌙 Dark Mode'}}
</Button>
</template>
}
Frontile's own documentation site does exactly this — see
docfy-theme-switcher.gts
for a version that also reacts to the system preference changing while the page
is open, and guards against running during server-side rendering.
Detect and respond to the user's system theme preference:
// Check system preference on load
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
const isDark = e.matches;
// Update theme
document.documentElement.classList.toggle('dark', isDark);
document.documentElement.classList.toggle('light', !isDark);
});
Store the user's theme preference:
// Save preference
localStorage.setItem('theme', 'dark');
// Load preference
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.documentElement.classList.add(savedTheme);
}
// Or use system preference if no saved preference
if (!savedTheme) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.add(prefersDark ? 'dark' : 'light');
}
Frontile uses CSS selectors that apply theme-specific styles:
/* Light theme styles apply to: */
.light *, /* Elements in light mode */
.dark .theme-inverse * /* Theme-inverse within dark mode */
/* Dark theme styles apply to: */
.dark *, /* Elements in dark mode */
.light .theme-inverse * /* Theme-inverse within light mode */
This means themes properly cascade and can be nested anywhere in your application.
Create sections with inverted theme colors using the theme-inverse utility. In light mode, these sections display dark theme colors, and vice versa.
import { Button } from 'frontile';
<template>
<div class='space-y-8 p-8'>
{{! Regular theme section }}
<div class='p-6 bg-surface-overlay-subtle rounded-lg'>
<h3 class='text-header-md text-neutral-strong mb-4'>Regular Theme</h3>
<p class='text-body-md text-neutral-firm mb-4'>
This section uses the current theme colors.
</p>
<Button @color='primary'>Primary Button</Button>
</div>
{{! Inverted theme section }}
<div class='theme-inverse p-6 bg-surface-canvas rounded-lg'>
<div class='p-6 bg-surface-overlay-subtle rounded-lg'>
<h3 class='text-header-md text-neutral-strong mb-4'>Inverted Theme</h3>
<p class='text-body-md text-neutral-firm mb-4'>
This section uses the opposite theme colors automatically.
</p>
<Button @color='primary'>Primary Button</Button>
</div>
</div>
</div>
</template>
Theme inverse is perfect for creating visual contrast and emphasis:
All Frontile components work automatically with theme-inverse:
import { Button, Chip, ProgressBar } from 'frontile';
<template>
<div class='space-y-6'>
{{! Cards with status chips }}
<div class='theme-inverse p-6 bg-surface-canvas rounded-lg'>
<h4 class='text-header-sm text-neutral-strong mb-3'>Project Status</h4>
<div class='flex flex-wrap gap-2 mb-4'>
<Chip @color='success'>Active</Chip>
<Chip @color='warning'>In Review</Chip>
<Chip @color='neutral'>Draft</Chip>
</div>
<ProgressBar @progress={{65}} @color='primary' />
</div>
</div>
</template>
By default, Modal and Drawer components render at the top level of the DOM using portals. This means they inherit the theme from <html> or <body>, not from theme-inverse sections:
import { Modal, Button, toggleState } from 'frontile';
const state = toggleState(false);
<template>
<div class='theme-inverse p-6 bg-surface-canvas rounded-lg'>
<p class='text-neutral-firm mb-4'>
This section uses inverted theme, but the modal uses root theme.
</p>
<Button @onPress={{state.toggle}}>Open Modal</Button>
{{! Modal renders at top level - uses root theme }}
<Modal @isOpen={{state.current}} @onClose={{state.toggle}} as |modal|>
<modal.Header>Modal Header</modal.Header>
<modal.Body>
This modal inherits from html/body, not the theme-inverse parent.
</modal.Body>
</Modal>
</div>
</template>
To make modals inherit theme-inverse, render them in place:
import { Modal, Button, toggleState } from 'frontile';
const state = toggleState(false);
<template>
<div class='theme-inverse p-6 bg-surface-canvas rounded-lg'>
<Button @onPress={{state.toggle}}>Open Modal</Button>
{{! Modal renders in place - inherits theme-inverse }}
<Modal
@isOpen={{state.current}}
@onClose={{state.toggle}}
@renderInPlace={{true}}
as |modal|
>
<modal.Header>Modal Header</modal.Header>
<modal.Body>
This modal inherits the theme-inverse from its parent.
</modal.Body>
</Modal>
</div>
</template>
Surface overlay colors are semi-transparent. Always provide a solid base background:
<!-- ✓ Good - has base background -->
<div class="theme-inverse bg-surface-canvas">
<div class="bg-surface-overlay-subtle p-4">
Content
</div>
</div>
<!-- ✗ Bad - overlay has no base -->
<div class="theme-inverse">
<div class="bg-surface-overlay-subtle p-4">
Content (might be invisible!)
</div>
</div>
Theme-inverse works automatically with semantic colors:
<div class="theme-inverse p-6 bg-surface-canvas">
<h3 class="text-neutral-strong">Heading</h3>
<p class="text-neutral-firm">Body text</p>
<button class="bg-primary text-on-primary">
Action
</button>
</div>
Always test your UI in both light and dark modes:
<!-- Test light mode -->
<html class="light">
<!-- Your app -->
</html>
<!-- Test dark mode -->
<html class="dark">
<!-- Your app -->
</html>
Don't use hard-coded Tailwind colors that don't adapt to themes:
<!-- ✗ Bad - doesn't adapt to theme -->
<div class="bg-gray-900 text-white">
Content
</div>
<!-- ✓ Good - adapts automatically -->
<div class="bg-surface-canvas text-neutral-strong">
Content
</div>
Tailwind's dark: and light: variants work correctly with theme-inverse:
<div class="theme-inverse p-6 bg-surface-canvas">
<div class="bg-white dark:bg-gray-900">
<!-- In light mode with theme-inverse: shows gray-900 (dark theme) -->
<!-- In dark mode with theme-inverse: shows white (light theme) -->
Content
</div>
</div>
The variants understand theme-inverse contexts:
dark: applies in dark mode AND in .light .theme-inverselight: applies in light mode AND in .dark .theme-inverseUnder the hood:
.light .theme-inverse applies dark theme colors.dark .theme-inverse applies light theme colorsYou can nest theme-inverse multiple times, though it's rarely needed:
<html class="dark">
<!-- Dark theme -->
<div class="theme-inverse">
<!-- Light theme -->
<div class="theme-inverse">
<!-- Dark theme again -->
</div>
</div>
</html>