GitHub

Theme Switching

Frontile supports light and dark themes with seamless switching and theme inversion for creating visual contrast within your application.

Basic Light/Dark Mode

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.

Implementing Theme Switching

Client-Side Toggle

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 @appearance='outlined' @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.

Respecting System Preference

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);
});

Persisting Theme Choice

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');
}

How Theme Classes Work

Frontile uses CSS selectors that intelligently 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.

Theme Inverse

Create sections with inverted theme colors using the theme-inverse utility. In light mode, these sections display dark theme colors, and vice versa.

Basic Usage

Regular Theme

This section uses the current theme colors.

Inverted Theme

This section uses the opposite theme colors automatically.

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 @intent='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 @intent='primary'>Primary Button</Button>
      </div>
    </div>
  </div>
</template>

Use Cases for Theme Inverse

Theme inverse is perfect for creating visual contrast and emphasis:

  • Hero sections - Dark hero on light page, or vice versa
  • Feature highlights - Draw attention to specific sections
  • Sidebars/panels - Create visual separation
  • Marketing sections - Alternate theme for variety

Component Examples

All Frontile components work automatically with theme-inverse:

Project Status

Active
In Review
Draft
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 @intent='success'>Active</Chip>
        <Chip @intent='warning'>In Review</Chip>
        <Chip @intent='default'>Draft</Chip>
      </div>
      <ProgressBar @progress={{65}} @intent='primary' />
    </div>
  </div>
</template>

Important: Modals and Overlays

Default Behavior

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>

Rendering in Place

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>

Best Practices

Always Set Base Backgrounds

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>

Use Semantic Colors

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>

Test Both Themes

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>

Avoid Hard-Coded Colors

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>

Dark/Light Variants

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-inverse
  • light: applies in light mode AND in .dark .theme-inverse

How Theme Inverse Works

Under the hood:

  • Theme-inverse only works with base themes (light and dark)
  • In light mode, .light .theme-inverse applies dark theme colors
  • In dark mode, .dark .theme-inverse applies light theme colors
  • All CSS variables are automatically swapped
  • Components work without modification

Advanced: Multiple Theme Levels

You 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>

Next Steps

Released under MIT License - Created by Josemar Luedke