Theme System

Last updated: 04/17/2026 · Written by Agent0

Theme System & theme.css

StackCTL ships with a lightweight theme system that handles light/dark mode switching, stores the user's preference, and fires a theme:ready event that other scripts (like TinyMCE) can listen for. It also includes theme.css — a small set of reusable UI classes that sit alongside Tailwind and reduce repetition in views.

Both are loaded automatically in layouts/base.php — no configuration required.


How the Theme System Works

The theme system runs in two stages on every page load:

  1. Instant flash prevention — An inline script in <head> reads the stored preference from localStorage and applies the dark class to <html> before the page renders. This prevents a white flash on dark mode loads.
  2. Full initialization — After the page loads, the window.Theme controller initializes, wires up the toggle button, listens for OS-level preference changes, and fires the theme:ready event.

Tailwind's dark mode is configured to use the class strategy, so any Tailwind dark: utility will respond to the dark class on <html> automatically.


Three Theme Modes

The theme cycles through three states:

  • Light — Forces light mode regardless of OS setting. Icon: ☀️
  • Dark — Forces dark mode regardless of OS setting. Icon: 🌙
  • System — Follows the operating system preference. Icon: 🖥️

The preference is stored in localStorage under the key 'theme' and persists across sessions.


The Toggle Button

The theme toggle button in the top navigation has the id themeToggle. Clicking it cycles through the three modes automatically — no additional JavaScript needed. The button label updates to reflect the current mode.

<button id="themeToggle" class="theme-btn">🖥️</button>

Manual Theme Buttons

To build a settings panel with explicit Light / Dark / System buttons, use data-theme attributes. The theme system will automatically apply the theme-btn-active class to whichever button matches the current mode.

<button class="theme-btn" data-theme="light">Light</button>
<button class="theme-btn" data-theme="dark">Dark</button>
<button class="theme-btn" data-theme="system">System</button>

<script>
document.querySelectorAll('[data-theme]').forEach(btn => {
    btn.addEventListener('click', () => {
        Theme.set(btn.dataset.theme);
    });
});
</script>

The Theme JavaScript API

The window.Theme object is available globally on every page. You can call it from any view script.

Theme.get()        // Returns current mode: 'light', 'dark', or 'system'
Theme.isDark()     // Returns true if the UI is currently in dark mode
Theme.set('dark')  // Set a specific mode ('light', 'dark', 'system')
Theme.cycle()      // Advance to the next mode (light → dark → system → light)
Theme.getTinyMCEConfig()  // Returns the correct TinyMCE skin/content_css for the current mode

theme:ready event

The theme:ready event fires after Theme.init() completes. Use it as the entry point for any script that needs the theme to be fully loaded first — most importantly, TinyMCE initialization.

document.addEventListener('theme:ready', () => {
    // Theme is fully loaded and Theme.isDark() is reliable here
    console.log('Dark mode:', Theme.isDark());
});

See the TinyMCE doc for a full example of using theme:ready with the editor.


theme.css — UI Class Reference

theme.css provides a small set of semantic CSS classes that work on top of Tailwind. They handle light/dark mode internally, so you don't have to repeat dark: variants on every element. Use them in views exactly like Tailwind classes.

Buttons

<button class="btn btn-primary">Save</button>
<button class="btn btn-secondary">Cancel</button>
<button class="btn btn-danger">Delete</button>
<button class="btn btn-primary" disabled>Disabled</button>
  • btn — Base button style (required on all buttons)
  • btn-primary — Blue, for primary actions
  • btn-secondary — Light gray, for secondary actions and cancel buttons
  • btn-danger — Red, for destructive actions like delete

Form Inputs

<input type="text" class="input" name="title">
<input type="text" class="input input-error" name="email">  <!-- validation failure -->
<textarea class="input" name="content"></textarea>
  • input — Full-width input with border, padding, focus ring, and dark mode support
  • input-error — Red border variant, applied automatically by has_error() when a validation error exists

Labels

<label class="label">Article Title</label>
  • label — Block-level label with appropriate font size, weight, and bottom margin

Cards

<div class="card">
    <!-- your content -->
</div>
  • card — White background, rounded corners, subtle shadow. Switches to dark gray in dark mode automatically.

Grid / Row System

A lightweight responsive grid that doesn't require writing breakpoint classes on every element.

<!-- Single column (default, stacks on mobile) -->
<div class="row row-2">
    <div>Column one</div>
    <div>Column two</div>
</div>

<!-- Three columns -->
<div class="row row-3"> ... </div>

<!-- Auto-fit tiles (responsive, no fixed column count) -->
<div class="row-auto"> ... </div>
  • row — Base grid with gap-6, single column on mobile
  • row-2 through row-6 — Number of columns on tablet and above
  • row-auto — Auto-fit responsive tiles, minimum 200px wide

Navigation

<a href="/articles" class="nav-link <?= is_active('/articles') ?>">Articles</a>

<div class="nav-section">
    <span class="nav-section-label">Content</span>
    <a href="/articles" class="nav-link">Articles</a>
    <a href="/categories" class="nav-link">Categories</a>
</div>
  • nav-link — Block link with hover background, works in light and dark mode
  • nav-section — Adds a top border separator and spacing between nav groups
  • nav-section-label — Small uppercase gray label for grouping nav items

Combine nav-link with the is_active() helper to highlight the current page. See the Views & Layouts doc for usage details.

Alerts

<div class="alert alert-success">Changes saved successfully.</div>
<div class="alert alert-error">Something went wrong.</div>
  • alert — Base alert style with padding and rounded corners
  • alert-success — Green tones, dark mode aware
  • alert-error — Red tones, dark mode aware

Note: flash toast notifications (the auto-dismissing popups) are handled automatically by the base layout using Tailwind classes. The alert classes are for inline alerts you place manually in a view.

Text & Spacing Utilities

<p class="text-muted">Supporting copy or helper text.</p>

<div class="mt-sm">...</div>  <!-- margin-top: 0.5rem -->
<div class="mt-md">...</div>  <!-- margin-top: 1rem   -->
<div class="mt-lg">...</div>  <!-- margin-top: 1.5rem -->
  • text-muted — Medium gray in light mode, lighter gray in dark mode. Use for secondary or supporting text.
  • mt-sm, mt-md, mt-lg — Simple margin-top helpers for quick spacing

Extending theme.css

theme.css is intentionally minimal. If you find yourself repeating a Tailwind pattern frequently across views, that's a good signal to add a new class here. Keep new additions focused on reusable primitives — layout, form elements, and containers — rather than page-specific styles.

All custom styles for your app should go into theme.css or a separate stylesheet you load in base.php. Avoid inline <style> blocks in view files.

Was this helpful?