Skip to main content

Reusable visual themes (colors, dark/light mode, radii, shadows, typography) for Wagtail pages.

Project description

wagtail-visual-themes

Reusable visual themes for Wagtail pages — colors, dark/light mode, typography, border radii and shadows. Themes are managed as snippets, rendered as a single <style> block of CSS variables, and resolved automatically per page.

⚠️ Alpha. The API may change before 1.0.


Table of contents

Editors: see docs/editor-guide.md for a non-technical walkthrough of creating themes and brand colors in the Wagtail admin.


What you get

Editors Create one or more Theme snippets in the Wagtail admin. Each theme has its own colors (light + dark), typography, radii, shadows, and a list of named brand colors. Live preview shows them all.
Developers Drop {% theme_css %} in your <head> and consume var(--color-bg), var(--color-primary), var(--radius-md), var(--shadow-md) in plain CSS, Tailwind, or anywhere else.
Visitors Toggle between light, dark and system (OS) modes. The choice survives page reloads. No flash of wrong theme on first paint.

The package is zero-coupling: it doesn't know about your tenant model, your CMS structure, or your build pipeline. It just emits CSS variables.


Installation

pip install wagtail-visual-themes

Add to INSTALLED_APPS (after wagtail.snippets):

INSTALLED_APPS = [
    # …
    "wagtail.snippets",
    "wagtail_themes",
]

Add the context processor so {% theme_css %} works without arguments:

TEMPLATES = [{
    "BACKEND": "django.template.backends.django.DjangoTemplates",
    "DIRS": [...],
    "APP_DIRS": True,
    "OPTIONS": {
        "context_processors": [
            "django.template.context_processors.request",
            "django.contrib.auth.context_processors.auth",
            # …
            "wagtail_themes.context_processors.active_theme",
        ],
    },
}]

Run migrations:

python manage.py migrate

That's it. There are no required settings.


Quickstart (5 minutes)

After installing:

1. Create a theme. Go to Snippets → Themes in the Wagtail admin, click Add Theme, fill in a name (e.g. Default) and slug (default), tick is_default, and save. Tweak colors as you like, or accept the defaults.

2. Add a brand color (optional). Snippets → Brand Colors → Add. Pick the theme you just created, name it Primary, give it #3b82f6. Save.

3. Update your base template.

{# templates/base.html #}
{% load wagtail_themes %}
<!DOCTYPE html>
<html {% theme_html_attrs %}>
<head>
    <meta charset="utf-8">
    {% theme_no_flash %}
    {% theme_css %}
    <link rel="stylesheet" href="{% static 'css/site.css' %}">
</head>
<body>
    <header style="background: var(--color-surface); color: var(--color-text-primary); padding: 1rem;">
        My site
        {% theme_switcher %}
    </header>
    <main>{% block content %}{% endblock %}</main>
</body>
</html>

4. Reload the page. You should see your theme applied. Click the switcher to toggle light/system/dark — the page rerenders instantly with no flash on reload.

That's the whole minimum integration. Read on for the details.


Concepts

Theme

A Theme is a snippet that holds all design tokens. It has:

  • Surface colors (light and dark): background, surface, text-primary, text-secondary, text-muted, border.
  • Semantic colors: success, warning, error, info, link, focus-ring (each with optional dark override).
  • Brand colors: many-to-one — see below.
  • Typography: heading font stack, body font stack, weights, scale (small/normal/large), optional URL to an external font CSS (e.g. Google Fonts).
  • Radii: --radius-sm, --radius-md, --radius-lg, --radius-full.
  • Shadows: --shadow-sm, --shadow-md, --shadow-lg.
  • Default mode: light, dark, or system. The mode the site lands on for visitors with no saved preference.
  • is_default flag: when set, this theme is used for any page that doesn't otherwise have one. Only one theme can be default at a time — saving a new default automatically clears it on others.

BrandColor

A BrandColor is a named color owned by a Theme — Primary, Accent, Aurora, whatever you want. Each BrandColor emits three CSS variables:

Variable Example Notes
--color-<slug> --color-primary: #3b82f6; The raw value as authored.
--color-<slug>-rgb --color-primary-rgb: 59 130 246; RGB triplet, only if the value is a solid color (not a gradient). Powers Tailwind opacity.
--color-<slug>-contrast --color-primary-contrast: #ffffff; Auto-computed via WCAG luminance. Use for text on top of the brand color.

You can give a brand color a separate dark-mode value:

  • color_value: light mode — required.
  • color_value_dark: dark mode — optional. Falls back to the light value when blank.

Gradients are supported (linear-gradient(...), radial-gradient(...)) — they just don't get an -rgb companion.

Modes

Three modes, controlled via data-theme on <html>:

  • data-theme="light" — light tokens.
  • data-theme="dark" — dark tokens.
  • data-theme="system" — light by default, swaps to dark inside @media (prefers-color-scheme: dark).

The theme has a default_mode. The visitor can override it via the switcher; the choice is persisted in localStorage under wagtail-themes:mode. The {% theme_no_flash %} tag reads that key synchronously before paint.

Resolution

Each request needs to find one Theme to render. The resolver walks this order:

  1. The current page's own theme (if it inherits from ThemedPageMixin).
  2. Each ancestor page's theme, walking up to the root.
  3. The Wagtail Site's ThemeSiteSetting.theme (if wagtail.contrib.settings is installed).
  4. The Theme row with is_default=True.
  5. Nothing — the tags render empty strings; your CSS still works using its own fallbacks.

Wiring up your base template

The single most important block of your project's HTML, in full:

{% load wagtail_themes static %}
<!DOCTYPE html>
<html {% theme_html_attrs %}>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    {# Read saved mode from localStorage and apply data-theme BEFORE paint. #}
    {% theme_no_flash %}

    {# Render the active theme as <style>…</style> with all CSS variables. #}
    {% theme_css %}

    {# Your own stylesheets — they can already use var(--…) below this point. #}
    <link rel="stylesheet" href="{% static 'css/site.css' %}">

    <title>{% block title %}{{ page.title }}{% endblock %}</title>
</head>
<body>
    {% block body %}{% endblock %}
</body>
</html>

{% theme_html_attrs %} expands to something like:

data-theme="system" data-theme-name="default" class="theme-default"

You can author CSS that targets a specific theme by slug:

.theme-marketing .hero { background: var(--color-primary); }
.theme-blog      .hero { background: var(--color-surface); }

Order matters

Three rules:

  1. {% theme_no_flash %} must come before any visible HTML. Otherwise the visitor's saved mode applies after paint, causing a flash. It's a tiny synchronous <script> — that's intentional.
  2. {% theme_css %} must come before your own <link rel="stylesheet">. The variables need to be defined before any rule references them.
  3. Don't put {% theme_css %} in a child template that renders late (e.g. inside {% block content %}). It belongs in <head>.

Attaching themes to pages

You have three patterns. Pick whichever fits.

Pattern A — page mixin (most flexible)

Every editorial page can pick its own theme; child pages inherit unless they override. Best for sites where different sections (marketing, docs, blog) want different looks.

# myapp/models.py
from wagtail.models import Page
from wagtail_themes.models import ThemedPageMixin


class HomePage(ThemedPageMixin, Page):
    # Show the theme picker in the editor's "Promote" tab,
    # or wherever you want.
    promote_panels = Page.promote_panels + ThemedPageMixin.theme_panels


class BlogIndexPage(ThemedPageMixin, Page):
    promote_panels = Page.promote_panels + ThemedPageMixin.theme_panels


class BlogPostPage(ThemedPageMixin, Page):
    promote_panels = Page.promote_panels + ThemedPageMixin.theme_panels

Then run makemigrations + migrate. The page now has a theme FK; setting it on BlogIndexPage propagates to all BlogPostPage children automatically (resolver walks the tree).

Pattern B — site setting (simplest, one theme per Wagtail Site)

For a one-site, one-look project. Add wagtail.contrib.settings to INSTALLED_APPS:

INSTALLED_APPS = [
    # …
    "wagtail.contrib.settings",
    "wagtail_themes",
]

…and the contrib's context processor:

TEMPLATES[0]["OPTIONS"]["context_processors"].append(
    "wagtail.contrib.settings.context_processors.settings",
)

Editors will now see Settings → Themes in the admin where they can pick one theme per site. No code changes to your Page models needed.

Pattern C — both

You can use both at the same time. Page-level wins; site-level is the fallback when no page in the tree has a theme. This is what the resolver does by default.

Pattern D — request-time override

If you want to flip themes on certain conditions (preview, A/B test, query string), set request.active_theme somewhere — middleware, a view, anywhere — and the resolver will use it. Example:

# middleware.py
from wagtail_themes.models import Theme

class PreviewThemeMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if request.GET.get("theme"):
            try:
                request.active_theme = Theme.objects.get(slug=request.GET["theme"])
            except Theme.DoesNotExist:
                pass
        return self.get_response(request)

Theme switcher

The package ships a minimal three-state switcher (light / system / dark):

{% load wagtail_themes %}
{% theme_switcher %}

It writes to localStorage under the key wagtail-themes:mode and updates data-theme on <html> instantly.

Custom switcher

The contract is small. Write your own UI; the only job is to:

  1. Read the current state from <html data-theme> or localStorage["wagtail-themes:mode"].
  2. On user action, set data-theme="light"|"dark"|"system" on <html> and write the same value to localStorage.

A minimal hand-rolled toggle in vanilla JS:

<button id="toggle">🌓</button>
<script>
const KEY = "wagtail-themes:mode";
const root = document.documentElement;
const cycle = { light: "dark", dark: "system", system: "light" };
document.getElementById("toggle").addEventListener("click", () => {
    const next = cycle[localStorage.getItem(KEY) || root.dataset.theme || "system"];
    root.dataset.theme = next;
    localStorage.setItem(KEY, next);
});
</script>

Or React/Vue/Svelte — same contract. You don't need to use {% theme_switcher %}.


Using the CSS variables

The full set, available on :root (light) and overridden under [data-theme="dark"]:

Surface

var(--color-bg)              /* page background */
var(--color-surface)         /* cards, panels */
var(--color-text-primary)
var(--color-text-secondary)
var(--color-text-muted)
var(--color-border)

Semantic

var(--color-success)
var(--color-warning)
var(--color-error)
var(--color-info)
var(--color-link)
var(--color-focus-ring)

Brand

var(--color-<slug>)            /* e.g. var(--color-primary) */
var(--color-<slug>-rgb)        /* RGB triplet, solid colors only */
var(--color-<slug>-contrast)   /* foreground color */

/* Tailwind-aligned 50→950 shade scale, auto-derived from each base color: */
var(--color-<slug>-50)         /* lightest tint */
var(--color-<slug>-100)

var(--color-<slug>-500)        /* same as --color-<slug>, the base */

var(--color-<slug>-900)
var(--color-<slug>-950)        /* darkest shade */

Typography

var(--font-heading)
var(--font-body)
var(--font-weight-heading)
var(--font-weight-body)
var(--font-size-base)          /* in px, controlled by font_scale */

/* Modular size scale (rems, anchored to font-size-base) */
var(--font-size-xs)            /* 0.75rem  */
var(--font-size-sm)            /* 0.875rem */
var(--font-size-base)          /* 1rem     */
var(--font-size-lg)            /* 1.125rem */
var(--font-size-xl)            /* 1.25rem  */
var(--font-size-2xl)           /* 1.5rem   */
var(--font-size-3xl)           /* 1.875rem */
var(--font-size-4xl)           /* 2.25rem  */

/* Line height & letter spacing */
var(--leading-tight)           /* 1.25 */
var(--leading-normal)          /* 1.5  */
var(--leading-relaxed)         /* 1.75 */

var(--tracking-tight)          /* -0.025em */
var(--tracking-normal)         /* 0em      */
var(--tracking-wide)           /* 0.05em   */

Spacing

var(--space-0)                 /* 0       */
var(--space-px)                /* 1px     */
var(--space-1)                 /* 0.25rem */
var(--space-2)                 /* 0.5rem  */
var(--space-3)                 /* 0.75rem */
var(--space-4)                 /* 1rem    */
var(--space-5)                 /* 1.25rem */
var(--space-6)                 /* 1.5rem  */
var(--space-8)                 /* 2rem    */
var(--space-10)                /* 2.5rem  */
var(--space-12)                /* 3rem    */
var(--space-16)                /* 4rem    */
var(--space-20)                /* 5rem    */
var(--space-24)                /* 6rem    */

Geometry

var(--radius-sm)
var(--radius-md)
var(--radius-lg)
var(--radius-full)             /* 9999px — for pills/circles */

var(--shadow-sm)
var(--shadow-md)
var(--shadow-lg)

var(--border-1)                /* 1px */
var(--border-2)                /* 2px */
var(--border-4)                /* 4px */
var(--border-8)                /* 8px */

Z-index scale

var(--z-base)                  /* 0  */
var(--z-dropdown)              /* 10 */
var(--z-sticky)                /* 20 */
var(--z-fixed)                 /* 30 */
var(--z-overlay)               /* 40 */
var(--z-modal)                 /* 50 */
var(--z-popover)               /* 60 */
var(--z-tooltip)               /* 70 */
var(--z-toast)                 /* 80 */

Transitions

var(--duration-fast)           /* 150ms */
var(--duration-normal)         /* 200ms */
var(--duration-slow)           /* 300ms */

var(--ease-out)                /* cubic-bezier(0, 0, 0.2, 1)     */
var(--ease-in-out)             /* cubic-bezier(0.4, 0, 0.2, 1)   */

State overlays

Opacity values for hover / active / disabled states. Combine with the -rgb color companions or with opacity:.

var(--state-hover-overlay)     /* 0.08 */
var(--state-active-overlay)    /* 0.16 */
var(--state-disabled-opacity)  /* 0.5  */

/* Practical use: */
.btn-primary:hover {
    background: rgb(var(--color-primary-rgb) / calc(1 - var(--state-hover-overlay)));
}
.btn[disabled] { opacity: var(--state-disabled-opacity); }

Practical example

body {
    background: var(--color-bg);
    color: var(--color-text-primary);
    font-family: var(--font-body);
    font-size: var(--font-size-base);
    font-weight: var(--font-weight-body);
}

h1, h2, h3 {
    font-family: var(--font-heading);
    font-weight: var(--font-weight-heading);
}

.btn-primary {
    background: var(--color-primary);
    color: var(--color-primary-contrast);
    border-radius: var(--radius-md);
    box-shadow: var(--shadow-sm);
    transition: opacity 0.2s;
}

.btn-primary:hover {
    /* Tailwind-style opacity: requires --color-primary-rgb (auto-emitted for solids) */
    background: rgb(var(--color-primary-rgb) / 0.9);
}

.card {
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    box-shadow: var(--shadow-md);
}

a { color: var(--color-link); }
*:focus-visible { outline: 2px solid var(--color-focus-ring); }

Tailwind integration

wagtail-visual-themes plays cleanly with Tailwind v3+. The trick: tell Tailwind your theme tokens are CSS variables containing RGB triplets.

In tailwind.config.js:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./templates/**/*.html", "./src/**/*.{js,ts,jsx,tsx}"],
  theme: {
    extend: {
      colors: {
        bg:      "rgb(var(--color-bg-rgb) / <alpha-value>)",
        surface: "rgb(var(--color-surface-rgb) / <alpha-value>)",
        border:  "rgb(var(--color-border-rgb) / <alpha-value>)",
        primary: "rgb(var(--color-primary-rgb) / <alpha-value>)",
        success: "rgb(var(--color-success-rgb) / <alpha-value>)",
        // …add the rest of your brand color slugs
      },
      borderRadius: {
        sm:   "var(--radius-sm)",
        DEFAULT: "var(--radius-md)",
        lg:   "var(--radius-lg)",
        full: "var(--radius-full)",
      },
      boxShadow: {
        sm: "var(--shadow-sm)",
        DEFAULT: "var(--shadow-md)",
        lg: "var(--shadow-lg)",
      },
      fontFamily: {
        heading: "var(--font-heading)",
        body:    "var(--font-body)",
      },
    },
  },
  // For dark-mode utilities (`dark:bg-surface`):
  darkMode: ["class", '[data-theme="dark"]', '[data-theme="system"]'],
};

You can now write Tailwind that respects the theme:

<button class="bg-primary text-white hover:bg-primary/80 rounded px-4 py-2 shadow">
    Click me
</button>
<div class="bg-surface text-foreground border border-border rounded-lg p-6">
    A card
</div>

The bg-primary/80 syntax requires the -rgb companion variables — wagtail-visual-themes emits these automatically for every solid color.


Brand colors

Naming & slugs

Brand color slugs are auto-derived from the name: Primaryprimary, Aurora Sunriseaurora-sunrise, Brand 2025brand-2025. Pick names that read well in CSS.

Names must be unique within a Theme.

Solid colors vs gradients

Both work. Solids accept hex (#3b82f6, #fff), rgb(), rgba(). Gradients accept any CSS gradient (linear-gradient(...), radial-gradient(...), conic-gradient(...)).

Color type Gets -rgb companion? Gets -contrast companion?
Solid (hex/rgb) ✅ (computed from luminance)
Gradient ✅ (defaults to #ffffff)

For gradient brand colors, set the color_value_dark to a different gradient if your light-mode gradient looks bad in dark mode.

Brand color choosers (for FK fields)

If you have a model with FK fields pointing at BrandColor, use BrandColorChooserPanel so editors only see colors belonging to the active theme:

from wagtail.models import Page
from wagtail.admin.panels import FieldPanel
from wagtail_themes.models import BrandColor, ThemedPageMixin
from wagtail_themes.panels import BrandColorChooserPanel
from django.db import models


class CampaignPage(ThemedPageMixin, Page):
    accent = models.ForeignKey(
        BrandColor, null=True, blank=True, on_delete=models.SET_NULL,
        related_name="+",
    )
    content_panels = Page.content_panels + [
        FieldPanel("theme"),
        BrandColorChooserPanel("accent"),  # filters by theme set above
    ]

The panel reads the theme from instance.theme (or, if the model is a Theme, from instance itself) and limits the dropdown.


Theme resolution rules

When {% theme_css %} runs, it asks the resolver: "which theme should I render?" The decision tree:

Is `active_theme` already in the template context?         → use it
   │ no
   ▼
Is the current page a ThemedPageMixin with a theme set?   → use it
   │ no
   ▼
Walk up the page tree — does any ancestor have a theme?    → use the closest
   │ no
   ▼
Is wagtail.contrib.settings installed?
  Does the matching Wagtail Site have a ThemeSiteSetting?  → use its theme
   │ no
   ▼
Is there a Theme row with is_default=True?                 → use it
   │ no
   ▼
Render nothing (template tags emit empty strings).

The context processor (wagtail_themes.context_processors.active_theme) does steps 3–5 once per request and stores the result in the context as active_theme.

Forcing a specific theme

Skip resolution by passing the theme explicitly:

{% theme_css theme=my_theme %}

Or set request.active_theme = my_theme in middleware/a view.


Permissions

The package distinguishes editing a Theme record (full CRUD) from picking which Theme applies to a page or site. That split lets you give editorial staff the ability to assign existing themes without trusting them to redesign the system.

Permissions registered

Standard Django CRUD perms are auto-generated for both models:

  • wagtail_themes.add_theme / change_theme / delete_theme / view_theme
  • wagtail_themes.add_brandcolor / change_brandcolor / delete_brandcolor / view_brandcolor

Plus one custom permission:

  • wagtail_themes.set_active_theme — grants the ability to assign a Theme to a ThemedPageMixin page or to the Wagtail Site setting. Without it, the Theme field on the page edit form is hidden.

Preset groups

The package ships a management command that creates two pre-configured groups:

python manage.py wagtail_themes_setup_groups
# Re-run with --reset to overwrite any drift on the groups' permissions.
Group What members can do
Theme Editor Full CRUD on Theme and BrandColor, plus set_active_theme. Use for designers and tech-aware editors.
Theme Selector View-only on Theme and BrandColor, plus set_active_theme. Use for editorial staff who pick existing themes for pages but shouldn't be tweaking the design system.

The command is idempotent — safe to re-run after package upgrades to reattach any newly added permissions.

How the gating works

ThemedPageMixin.theme_panels already declares the gate:

theme_panels = [FieldPanel("theme", permission="wagtail_themes.set_active_theme")]

So users without set_active_theme simply don't see the field. ThemeSiteSetting.panels does the same for the site-level FK.

For the snippet menu (where Theme/BrandColor records are edited), Wagtail's standard model-level permissions apply: a user without at least view_theme won't see the Themes menu group at all.

Custom workflows

If neither preset fits, build your own group in the Django admin (or via fixtures) using any combination of the permissions above. Common variations:

  • Designer-only Theme Editor, no set_active_theme. Designers iterate on themes; editors apply them.
  • Locked-down Selector, only set_active_theme. No view of Theme records — they pick from the FK dropdown only.

Template tag reference

All tags live under {% load wagtail_themes %}.

{% theme_css %}

{% theme_css %}                              {# auto-resolved theme, with fallback #}
{% theme_css theme=my_theme %}               {# explicit override   #}
{% theme_css include_style_tag=False %}      {# raw CSS body, no <style> #}
{% theme_css include_fonts=False %}          {# don't emit <link> for custom_fonts_css_url #}
{% theme_css fallback=False %}               {# emit nothing if no Theme is configured #}

Renders one <style> block (and optionally a <link> for custom fonts) containing every CSS variable. When no Theme matches the resolution chain, falls back to an in-memory Theme() so the model's field defaults still emit usable CSS — opt out with fallback=False.

{% theme_html_attrs %}

<html {% theme_html_attrs %}>
{# or, to emit nothing when no Theme is configured: #}
<html {% theme_html_attrs fallback=False %}>

Outputs data-theme="<default_mode>" data-theme-name="<slug>" class="theme-<slug>". Use this on <html> so visitor mode preferences and theme-scoped CSS classes both work. Same fallback semantics as {% theme_css %} — emits data-theme="system" defaults rather than nothing, unless fallback=False.

{% theme_no_flash %}

<head>
    {% theme_no_flash %}
    {% theme_css %}

</head>

Inline <script> that reads localStorage["wagtail-themes:mode"] and applies it to <html data-theme> synchronously. Place at the very top of <head>.

{% theme_switcher %}

{% theme_switcher %}

Built-in three-state toggle (light/system/dark). Pulls in its own CSS and a small JS file. Skip if you're rolling your own.


Python API reference

from wagtail_themes.models import Theme, BrandColor, ThemedPageMixin
from wagtail_themes.resolver import (
    resolve_theme_for_page,
    resolve_theme_for_request,
    resolve_theme_for_site,
)
from wagtail_themes.panels import BrandColorChooserPanel
from wagtail_themes.widgets import BrandColorChooserWidget
from wagtail_themes.constants import ThemeMode, FontWeight, FontScale

Theme.emit_css(selector_root=":root") -> str

Returns the full CSS body (no <style> wrapper) for this theme. Useful for caching the CSS or generating it offline.

theme = Theme.objects.get(slug="default")
print(theme.emit_css())

Theme.get_default() -> Theme | None

Returns the row with is_default=True, or None.

BrandColor.css_var_name -> str

Read-only — --color-<slug>.

BrandColor.contrast_color -> str and contrast_color_dark

Read-only #000000 or #ffffff chosen via WCAG luminance.

ThemedPageMixin.get_active_theme() -> Theme | None

Walks the page tree + falls back to site/default. Same logic the template tags use.

Resolver functions

resolve_theme_for_page(page)        # walks up tree, then site, then default
resolve_theme_for_request(request)  # request.active_theme → site → default
resolve_theme_for_site(site)        # site setting only

Recipes

Cache the emitted CSS

For high-traffic sites, emit theme CSS once per theme version and serve it from cache:

# views.py
from django.core.cache import cache
from django.http import HttpResponse
from wagtail_themes.models import Theme

def theme_css_view(request, slug):
    theme = Theme.objects.get(slug=slug)
    key = f"wagtail-themes:css:{theme.pk}:{theme.updated_at.timestamp()}"
    css = cache.get(key)
    if css is None:
        css = theme.emit_css()
        cache.set(key, css, timeout=60 * 60 * 24)
    return HttpResponse(css, content_type="text/css")

Then reference it as a <link> instead of inline <style>:

<link rel="stylesheet" href="{% url 'theme_css' slug=active_theme.slug %}?v={{ active_theme.updated_at|date:'U' }}">

Dump CSS to a static file at build time

Useful if you ship a static landing page or mobile app:

# scripts/dump_theme_css.py
import django; django.setup()
from pathlib import Path
from wagtail_themes.models import Theme

out = Path("static/css/themes")
out.mkdir(parents=True, exist_ok=True)
for theme in Theme.objects.all():
    (out / f"{theme.slug}.css").write_text(theme.emit_css())

Per-section themes (marketing vs blog)

class MarketingIndexPage(ThemedPageMixin, Page):
    """Set theme=Marketing on this page; all marketing children inherit."""
    promote_panels = Page.promote_panels + ThemedPageMixin.theme_panels


class BlogIndexPage(ThemedPageMixin, Page):
    """Set theme=Blog here; all posts inherit."""
    promote_panels = Page.promote_panels + ThemedPageMixin.theme_panels

A BlogPostPage deep under BlogIndexPage will resolve to the Blog theme without each post needing to set it.

A "preview theme" for stakeholders

Let editors preview an unpublished theme without affecting visitors:

# middleware.py
from wagtail_themes.models import Theme

class PreviewThemeMiddleware:
    def __init__(self, get_response): self.get_response = get_response
    def __call__(self, request):
        slug = request.GET.get("preview_theme")
        if slug and request.user.is_staff:
            try:
                request.active_theme = Theme.objects.get(slug=slug)
            except Theme.DoesNotExist:
                pass
        return self.get_response(request)

Now ?preview_theme=halloween overrides the live theme for staff only.

Customising the default theme on first install

Use a data migration:

# myapp/migrations/0002_seed_theme.py
from django.db import migrations


def seed_theme(apps, schema_editor):
    Theme = apps.get_model("wagtail_themes", "Theme")
    BrandColor = apps.get_model("wagtail_themes", "BrandColor")
    theme, _ = Theme.objects.get_or_create(
        slug="default",
        defaults={"name": "Default", "is_default": True, "default_mode": "system"},
    )
    BrandColor.objects.get_or_create(
        theme=theme, name="Primary", defaults={"color_value": "#3b82f6"}
    )


class Migration(migrations.Migration):
    dependencies = [("wagtail_themes", "0001_initial"), ("myapp", "0001_initial")]
    operations = [migrations.RunPython(seed_theme, migrations.RunPython.noop)]

Testing your integration

The package ships its own pytest suite. To test your code:

# tests/test_my_pages.py
import pytest
from wagtail_themes.models import Theme
from myapp.models import HomePage


@pytest.mark.django_db
def test_homepage_uses_marketing_theme(rf, root_page):
    marketing = Theme.objects.create(name="Marketing", slug="marketing")
    home = root_page.add_child(
        instance=HomePage(title="Home", slug="home", theme=marketing)
    )
    assert home.get_active_theme() == marketing


@pytest.mark.django_db
def test_homepage_inherits_default_theme(rf, root_page):
    default = Theme.objects.create(name="Default", slug="default", is_default=True)
    home = root_page.add_child(instance=HomePage(title="Home", slug="home"))
    assert home.get_active_theme() == default

Troubleshooting / FAQ

The page renders without a theme — var(--color-bg) is undefined. This shouldn't happen on a fresh install: when the resolver returns None, {% theme_css %} falls back to the model's baked-in field defaults (white surfaces, slate text, navy dark mode, etc.) and {% theme_html_attrs %} falls back to data-theme="system". If you're still seeing undefined variables, either (a) you're inside a non-request context (the tag couldn't reach the resolver), or (b) you've explicitly opted out via {% theme_css fallback=False %}. To customise the fallback, create a Theme and tick is_default.

Dark mode doesn't switch on click.

  • Check <html> actually changes data-theme. Open devtools → Elements.
  • Verify localStorage["wagtail-themes:mode"] is being written. If not, the page is loaded from file:// (localStorage may be sandboxed) or the switcher's JS hasn't loaded yet.
  • Make sure you have {% theme_no_flash %} and {% theme_switcher %} both loaded; the switcher writes the value, the no-flash script reads it on next paint.

Tailwind opacity (bg-primary/50) doesn't work. Tailwind needs the RGB triplet form. Use rgb(var(--color-primary-rgb) / <alpha-value>) in the config (see Tailwind integration). Gradients don't get an -rgb companion — that's by design.

Editors saved a brand color but it doesn't appear in CSS. Brand colors with is_active=False are skipped. Also confirm the brand color belongs to the active theme — they're scoped per-theme.

Pages don't see my changes after editing a Theme. The CSS is regenerated on each request — no caching by default. If you added the emit-once cache recipe, bump or invalidate the cache key.

Custom fonts URL is loaded twice. Make sure {% theme_css %} appears only once per page. Or pass include_fonts=False to it and link the font file yourself.

Why aren't button styles tokenised (shape, animation)? Intentional. v1 only ships pure tokens (colors, radii, shadows, typography). Components (buttons, cards) compose those tokens however your design system wants.


Development

git clone https://github.com/ujeenet/wagtail-visual-themes.git
cd wagtail-visual-themes
uv venv && source .venv/bin/activate
uv pip install -e ".[dev]"

# Tests
PYTHONPATH=src python -m pytest tests/ -v

# Build wheel + sdist
python -m build

# Lint
ruff check src tests

PRs welcome. Keep changes tightly scoped to visual styling; component-specific logic (button shape enums, hero patterns, navigation, logos) is out of scope for this package.


Releasing

CI runs on every push and PR via .github/workflows/ci.yml. Publishing is automated via .github/workflows/publish.yml — pushing a v* tag triggers a build, runs tests, publishes to PyPI, and creates a GitHub Release.

One-time setup (PyPI Trusted Publishing — no API tokens needed):

  1. Create a PyPI account at https://pypi.org/account/register/.
  2. Go to https://pypi.org/manage/account/publishing/ and click Add a new pending publisher.
  3. Fill in:
    • PyPI Project Name: wagtail-visual-themes
    • Owner: ujeenet
    • Repository name: wagtail-visual-themes
    • Workflow name: publish.yml
    • Environment name: pypi
  4. In the GitHub repo, go to Settings → Environments → New environment, name it pypi. (You can add manual approval here if you want a gate before each release.)

Cutting a release:

# 1. Bump version in pyproject.toml (e.g. 0.1.0 → 0.1.1)
# 2. Update CHANGELOG.md
git add pyproject.toml CHANGELOG.md
git commit -m "release: v0.1.1"
git push

# 3. Tag and push the tag — this triggers publish.yml:
git tag v0.1.1
git push origin v0.1.1

Watch the Actions tab. On success, the package is on PyPI and a GitHub Release exists with the wheel + sdist attached.

Without trusted publishing (manual): replace the pypa/gh-action-pypi-publish step's id-token: write permission with a PYPI_API_TOKEN secret and pass it via password: ${{ secrets.PYPI_API_TOKEN }}. Trusted publishing is strictly better — recommended.


License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

wagtail_visual_themes-0.2.1.tar.gz (63.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

wagtail_visual_themes-0.2.1-py3-none-any.whl (54.2 kB view details)

Uploaded Python 3

File details

Details for the file wagtail_visual_themes-0.2.1.tar.gz.

File metadata

  • Download URL: wagtail_visual_themes-0.2.1.tar.gz
  • Upload date:
  • Size: 63.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for wagtail_visual_themes-0.2.1.tar.gz
Algorithm Hash digest
SHA256 fc2c6aca07ce9635000bee78819f0e5ae024f1f6c8f9689665133712fbbf4892
MD5 98fa138eefcd5aebd71cf7365a01b836
BLAKE2b-256 22473948cebbdb987b2774ebb1067c0d05293cb1f3d4721d1b8dfe9aabf89971

See more details on using hashes here.

Provenance

The following attestation bundles were made for wagtail_visual_themes-0.2.1.tar.gz:

Publisher: publish.yml on ujeenet/wagtail-visual-themes

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file wagtail_visual_themes-0.2.1-py3-none-any.whl.

File metadata

File hashes

Hashes for wagtail_visual_themes-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 0c9bf7d80e854510d9773ff4851e677a5f2b6925fa9e9509627265ddb2fbc2ab
MD5 974a742164dcf64cb5890284abe70caf
BLAKE2b-256 ba117c42048dfd554e7cd622d01ddb20aa3ddb39f6dd18aee3bde87650eec9d1

See more details on using hashes here.

Provenance

The following attestation bundles were made for wagtail_visual_themes-0.2.1-py3-none-any.whl:

Publisher: publish.yml on ujeenet/wagtail-visual-themes

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page