PhiaUI uses a CSS-first theme system inspired by DaisyUI. Themes are pre-generated as static CSS with [data-phia-theme] attribute selectors. Activating a theme requires only setting an HTML attribute — no JavaScript CSS injection, no runtime recalculation.

Table of Contents


Quick Setup

# 1. Generate the multi-theme CSS file
mix phia.theme install
# → creates assets/css/phia-themes.css
# → injects @import into assets/css/app.css automatically

# 2. Add the unified anti-FOUC script to <head> (see below)

# 3. Activate a preset
# <html data-phia-theme="blue" class="dark">

How it Works

The two CSS files

PhiaUI uses two CSS files:

FilePurpose
priv/static/theme.cssBase @theme tokens, radius, typography, @custom-variant dark
assets/css/phia-themes.cssAll 8 color presets as [data-phia-theme] selectors

In your app.css:

@import "tailwindcss";
@import "../../../deps/phia_ui/priv/static/theme.css";
@import "./phia-themes.css";   ← added by mix phia.theme install

CSS cascade

Tailwind v4 generates bg-primary as background-color: var(--color-primary). CSS custom properties inherit through the DOM. A [data-phia-theme="blue"] on any ancestor makes every bg-primary inside use the blue primary color — automatically.

/* phia-themes.css (generated) */
[data-phia-theme="zinc"] {
  --color-primary: oklch(0.141 0 0);
  --color-primary-foreground: oklch(0.985 0 0);
  /* ... all color tokens ... */
}

.dark [data-phia-theme="zinc"] {
  --color-primary: oklch(0.985 0 0);
  --color-primary-foreground: oklch(0.141 0 0);
}

[data-phia-theme="blue"] {
  --color-primary: oklch(0.546 0.245 262.881);
  /* ... */
}

Dark + color theme composition

<!-- dark mode + blue preset — works correctly -->
<html class="dark" data-phia-theme="blue">

The .dark [data-phia-theme="blue"] selector has specificity 0,2,0 and correctly overrides the light-mode values. ✅


Built-in Color Presets

PresetKeyPersonality
ZinczincNeutral dark — shadcn/ui default
SlateslateCool blue-grey
BlueblueEnterprise blue
RoseroseModern rose/pink
OrangeorangeEnergetic orange
GreengreenSuccess green
VioletvioletPremium violet
NeutralneutralPure grey

Preview all presets:

mix phia.theme list

Activating a Theme

App-wide

Set data-phia-theme on the <html> element in your root.html.heex:

<html lang="en" data-phia-theme="blue" class={if @dark_mode, do: "dark", else: ""}>

Or hard-code it statically:

<html lang="en" data-phia-theme="violet">

In a Phoenix LiveView layout

defmodule MyAppWeb.Layouts do
  use Phoenix.Component

  def app(assigns) do
    ~H"""
    <html lang="en" data-phia-theme={@theme} class={@mode_class}>
      <head>...</head>
      <body>
        <%= @inner_content %>
      </body>
    </html>
    """
  end
end

Scoped Themes (ThemeProvider)

<.theme_provider> sets data-phia-theme on a wrapper div, scoping the theme to only the enclosed content. Ideal for multi-tenant apps, preview panels, or themed sections.

<%!-- Default app uses zinc, but this card uses rose --%>
<.theme_provider theme={:rose}>
  <.card>
    <.card_header>
      <.card_title class="text-primary">Rose Card</.card_title>
    </.card_header>
    <.card_content>
      <.button>Rose Button</.button>
    </.card_content>
  </.card>
</.theme_provider>

With a struct (custom or tenant theme)

# In your LiveView
def mount(_params, %{"org_id" => org_id}, socket) do
  theme = MyApp.Orgs.get_theme(org_id)  # returns %PhiaUi.Theme{}
  {:ok, assign(socket, :org_theme, theme)}
end
<.theme_provider theme={@org_theme}>
  <%= @inner_content %>
</.theme_provider>

nil theme — no-op

<%!-- No data-phia-theme attribute rendered --%>
<.theme_provider theme={nil}>
  <.button>Uses default theme</.button>
</.theme_provider>

Required: phia-themes.css

<.theme_provider> requires phia-themes.css to be imported. Without it, the attribute is set but no CSS custom properties are applied.

mix phia.theme install

Runtime Theme Switching (PhiaTheme)

The PhiaTheme hook enables users to switch color presets without a page reload.

Setup

// assets/js/app.js
import PhiaTheme from "./phia_hooks/theme"

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { PhiaTheme, PhiaDarkMode, /* other hooks */ }
})

With a select element

<select phx-hook="PhiaTheme" id="theme-select">
  <option value="zinc">Zinc</option>
  <option value="slate">Slate</option>
  <option value="blue">Blue</option>
  <option value="rose">Rose</option>
  <option value="orange">Orange</option>
  <option value="green">Green</option>
  <option value="violet">Violet</option>
  <option value="neutral">Neutral</option>
</select>

With buttons

<div class="flex gap-2">
  <.button phx-hook="PhiaTheme" id="t-zinc" data-theme="zinc" variant="outline" size="sm">
    Zinc
  </.button>
  <.button phx-hook="PhiaTheme" id="t-blue" data-theme="blue" variant="outline" size="sm">
    Blue
  </.button>
  <.button phx-hook="PhiaTheme" id="t-rose" data-theme="rose" variant="outline" size="sm">
    Rose
  </.button>
</div>

What it does

  1. Sets document.documentElement.setAttribute('data-phia-theme', theme)
  2. Writes to localStorage['phia-color-theme']
  3. Dispatches phia:color-theme-changed CustomEvent for other hooks (e.g. PhiaChart dark mode)

The preference persists across page reloads via localStorage and is restored by the anti-FOUC script.


Anti-FOUC Script

Without this script, users may briefly see the default theme on page load before the hook runs. Add it to the <head> of root.html.heex before any stylesheets:

<head>
  <!-- Anti-FOUC: restore dark mode + color preset before first paint -->
  <script>
    (function() {
      var mode = localStorage.getItem('phia-mode') || localStorage.getItem('phia-theme');
      if (mode === 'dark' || (!mode && matchMedia('(prefers-color-scheme: dark)').matches)) {
        document.documentElement.classList.add('dark');
      }
      var ct = localStorage.getItem('phia-color-theme');
      if (ct) document.documentElement.setAttribute('data-phia-theme', ct);
    })();
  </script>
  <!-- stylesheets below -->
  <link rel="stylesheet" href={~p"/assets/app.css"} />
</head>

Customising Themes

Apply a preset to your theme.css

# Overwrites your assets/css/theme.css with the blue preset's variables
mix phia.theme apply blue

Export a preset for editing

# JSON format (default)
mix phia.theme export blue > my-brand-base.json

# CSS with attribute selectors
mix phia.theme export blue --format css > my-brand-base.css

Import a custom theme

Create a JSON file (based on the export format):

{
  "name": "my-brand",
  "label": "My Brand",
  "radius": "0.375rem",
  "colors": {
    "light": {
      "background": "oklch(1 0 0)",
      "foreground": "oklch(0.09 0 0)",
      "primary": "oklch(0.55 0.20 230)",
      "primary_foreground": "oklch(1 0 0)",
      "secondary": "oklch(0.94 0 0)",
      "secondary_foreground": "oklch(0.09 0 0)",
      "muted": "oklch(0.95 0 0)",
      "muted_foreground": "oklch(0.45 0 0)",
      "accent": "oklch(0.93 0.02 230)",
      "accent_foreground": "oklch(0.09 0 0)",
      "destructive": "oklch(0.55 0.22 27)",
      "border": "oklch(0.89 0 0)",
      "input": "oklch(0.89 0 0)",
      "ring": "oklch(0.55 0.20 230)",
      "card": "oklch(1 0 0)",
      "card_foreground": "oklch(0.09 0 0)",
      "popover": "oklch(1 0 0)",
      "popover_foreground": "oklch(0.09 0 0)",
      "sidebar_background": "oklch(0.97 0 0)",
      "sidebar_foreground": "oklch(0.36 0 0)"
    },
    "dark": {
      "background": "oklch(0.12 0 0)",
      "foreground": "oklch(0.97 0 0)",
      "primary": "oklch(0.75 0.18 230)",
      "primary_foreground": "oklch(0.09 0 0)"
    }
  },
  "typography": {
    "font_sans": "\"Inter\", system-ui, sans-serif"
  }
}

Import and apply:

mix phia.theme import ./my-brand.json

Generate CSS from Elixir

# Generate CSS for a specific theme with custom selector
theme = PhiaUi.Theme.get!(:blue)
css = PhiaUi.ThemeCSS.generate(theme, selector: "#my-app")
File.write!("assets/css/my-section.css", css)

# Generate all themes as attribute selectors
css = PhiaUi.ThemeCSS.generate_all()
File.write!("assets/css/phia-themes.css", css)

# Generate just one theme as attribute selector
css = PhiaUi.ThemeCSS.generate_for_selector(theme)
# => "[data-phia-theme=\"blue\"] { ... } .dark [data-phia-theme=\"blue\"] { ... }"

Custom Color Presets

To create a reusable preset module:

defmodule MyApp.Themes.Brand do
  @moduledoc "My Brand color preset for PhiaUI"

  def theme do
    %PhiaUi.Theme{
      name: "brand",
      label: "My Brand",
      radius: "0.375rem",
      colors: %{
        light: %{
          background: "oklch(1 0 0)",
          foreground: "oklch(0.09 0 0)",
          primary: "oklch(0.55 0.20 230)",
          primary_foreground: "oklch(1 0 0)",
          # ... add remaining tokens
        },
        dark: %{
          background: "oklch(0.12 0 0)",
          foreground: "oklch(0.97 0 0)",
          primary: "oklch(0.75 0.18 230)",
          primary_foreground: "oklch(0.09 0 0)",
          # ... add remaining tokens
        }
      },
      typography: %{
        font_sans: ~s("Inter", system-ui, sans-serif)
      }
    }
  end
end

Then generate its CSS:

theme = MyApp.Themes.Brand.theme()
css = PhiaUi.ThemeCSS.generate_for_selector(theme)

Mix Tasks Reference

# List all built-in presets with primary color values
mix phia.theme list

# Generate phia-themes.css and inject @import into app.css
mix phia.theme install

# Generate only specific themes
mix phia.theme install --themes zinc,blue,rose

# Custom output path
mix phia.theme install --output priv/static/phia-themes.css

# Apply a preset to assets/css/theme.css
mix phia.theme apply zinc

# Export preset as JSON
mix phia.theme export blue > blue.json

# Export preset as CSS (with attribute selectors)
mix phia.theme export blue --format css

# Import a custom theme from JSON
mix phia.theme import ./my-brand.json

localStorage Reference

KeyWritten byValuePurpose
phia-modePhiaDarkMode hook"dark" or "light"Dark mode preference (canonical)
phia-themePhiaDarkMode hooksame as phia-modeLegacy key (backward compat)
phia-color-themePhiaTheme hookpreset name, e.g. "blue"Color preset preference

Back to README | Dashboard Tutorial