horilla.contrib.theme stores HorillaColorTheme palettes and per-company CompanyTheme. register_html injects runtime Tailwind config into head_end.
This post is Part 23 of 28 in the Horilla CRM Technical Blog series.
What is theming in Horilla CRM?
Theming in Horilla CRM enables you to:
- Per-company theme records
- Runtime Tailwind config endpoint
- Flowbite components with theme tokens
- Admin UI for brand colors
Theme module boundaries
The theming implementation lives in horilla.contrib.theme. It is a Django app, not a collection of client-side preferences, so the selected colors remain consistent for every request in a company. The two persistence models have distinct responsibilities: HorillaColorTheme is the reusable palette definition, while CompanyTheme maps the currently active palette to a company. A color theme contains the named color values consumed by the generated Tailwind configuration and an is_default flag. A company does not need to duplicate those values: its CompanyTheme row points to the palette. This separation makes a centrally managed palette selectable by more than one tenant and makes changing a company selection an inexpensive database update.
HorillaColorTheme.get_default_theme() determines the palette used where no company selection is available, including the login experience. CompanyTheme.get_theme_for_company resolves a company selection with the default as a fallback. Consequently, templates and view code should ask these helpers for an effective theme instead of assuming a CompanyTheme row exists. Theme records are initialized from THEMES_DATA, the application’s seed data. Treat that data as the source for shipped palettes; use the administration flow to create or modify persisted palettes rather than hard-coding a hex value in a page template.
from horilla.contrib.theme.models import CompanyTheme, HorillaColorTheme
theme = HorillaColorTheme.get_default_theme()
active_theme = CompanyTheme.get_theme_for_company(request.active_company)
Dynamic Tailwind configuration
Tailwind utility classes are compiled around a dynamic color configuration rather than one fixed brand palette. The theme app registers an HTML contribution at the head_end extension point. That contribution renders theme/tailwind_dynamic_config.html, which places the active theme’s CSS/Tailwind configuration in the document head early enough for subsequent markup to use it. This is important: the head contribution is the integration boundary. Adding colors only to an arbitrary template can leave pages using cached or default utilities and can cause an initial flash of the wrong palette.
The base templates/index.html also loads Flowbite. Flowbite supplies the interactive component layer used by the UI, while the injected configuration supplies the tenant-aware color tokens. Keep those roles separate when extending the interface: put structural utility classes and Flowbite component markup in normal templates; put theme-token generation in the registered head template. A custom widget should prefer the existing semantic colors and Tailwind utilities over inline style attributes. Inline color declarations bypass company switching and make contrast review difficult.
Management views and permission boundary
ThemeView is a LoginRequiredMixin TemplateView that renders the theme settings screen. It loads all available HorillaColorTheme records, the active company, the company’s current mapping, the resolved active theme, and the global default theme. It is protected by theme.view_horillacolortheme. The page therefore exposes both the tenant-local choice and the login-page default without conflating them.
ChangeThemeView handles the HTMX-backed write operation. It requires both theme.change_companytheme and theme.add_companytheme, reads theme_id, and uses CompanyTheme.objects.update_or_create(company=…, defaults={“theme”: theme}) inside transaction.atomic(). That approach handles a first-time selection and a replacement with one idempotent operation. If the is_default form flag is set, the selected HorillaColorTheme is also marked default; its model save behavior clears the default marker on competing palettes. The response re-renders the theme-card fragment, which lets an HTMX target update without a full navigation.
with transaction.atomic():
CompanyTheme.objects.update_or_create(
company=request.active_company,
defaults={"theme": selected_theme},
)
if request.POST.get(“is_default”) == “on”:
selected_theme.is_default = True
selected_theme.save()
Do not accept a palette name or raw colors from the browser and construct a theme without validation. Resolve the submitted primary key through the model, retain the permission checks, and return the rendered server fragment. Those details preserve the per-company boundary and ensure the next request sees the same selection.
Adding a palette safely
For a product palette, first add a complete entry to THEMES_DATA, including every color variable expected by tailwind_dynamic_config.html; a partial palette can produce valid HTML but broken variants such as hover, focus, or muted text. Seed or migrate that entry into HorillaColorTheme, verify it appears in ThemeView, then select it through ChangeThemeView for a test company. Test a normal authenticated page, the login page if it becomes the default, and Flowbite controls such as dialogs, dropdowns, and form validation. Theme selection is presentation configuration, but the underlying mapping is multi-tenant business data and should be treated with the same permission and transaction discipline as other company-scoped settings.
Benefits of Theme in Horilla CRM
- White-label CRM per company
- No per-tenant CSS deploys
- Consistent component library
Theme settings apply across HTMX-loaded pages instantly — configure once per company in admin.
Continue the series
Previous: Part 22 — A Complete Guide to the Horilla CRM Report Builder
Next: Part 24 — How to Use Keyboard Shortcuts in Horilla CRM for Faster Navigation
More posts are on the Horilla Blogs; share feedback on GitHub.