Phoenix - Using a Theme - simplifies Light/Dark Mode

Relatively easily migrate the Core Components to a Theme and allow light and dark mode

Overview

I’ve been been looking to add a theme to Phoenix - in this way simplifying the core components and dark-mode.

Here is my current solution.

Configure Tailwind Color Variables

using CSS & HSL

Define Colors in CSS using HSL (Hue, Saturation, Lightness)

// assets/css/app.css
:root {
  --border-standard: 220 13% 91%; /* Light mode: a very light blue-gray */
  --background-primary: 240 25% 97%; /* Light mode: almost white */
  --text-standard: 220 39% 11%; /* Light mode: dark blue-gray */
}

.dark {
  --border-standard: 220 13% 30%; /* Dark mode: much darker blue-gray */
  --background-primary: 220 27% 13%; /* Dark mode: very dark */
  --text-standard: 210 20% 98%; /* Dark mode: nearly white text */
}

You can use the HSL color picker to find the values you want. To convert from RGB to HSL, you can use an online converter like this one.

Reference in Tailwind with hsl(var(–…)):

In tailwind.config.js, you are telling Tailwind to use the CSS variables and then combining them with hsl() to make them usable:

// config/tailwind.config.js
Copy code
extend: {
  colors: {
    'border-standard': 'hsl(var(--border-standard))', // Tailwind uses this to generate utility classes
  },
},

using Tailwind colors

In order to use the existing tailwind, but allow light and dark mode.

We start by adding the tailwind color module by adding: const colors = require('tailwindcss/colors');

and within the the theme colors area we define colors found in the existing components - ie: 'neutral-strongest': colors.zinc[900],

Then to simplify light / dark mode, we add the following plugin - which allows the color definitions to be appropriate for light and dark:

 // add custom color palette that auto switches between light and dark
    function ({ addBase, theme }) {
      const lightModeColors = {
        '--color-neutral': theme('colors.neutral-strong'),
        '--color-neutral': theme('colors.neutral-accented'),
        '--color-neutral': theme('colors.neutral'),
        '--color-neutral': theme('colors.neutral-muted'),
        '--color-neutral': theme('colors.neutral-faint'),
        '--color-neutral': theme('colors.neutral-strong-reverse'),
        '--color-neutral': theme('colors.neutral-accented-reverse'),
        '--color-neutral': theme('colors.neutral-reverse'),
        '--color-neutral': theme('colors.neutral-muted-reverse'),
        '--color-neutral': theme('colors.neutral-faint-reverse'),
        '--color-primary': theme('colors.primary'),
      };

      const darkModeColors = {
        '--color-neutral': theme('colors.neutral-strong-dark'),
        '--color-neutral': theme('colors.neutral-accented-dark'),
        '--color-neutral': theme('colors.neutral-dark'),
        '--color-neutral': theme('colors.neutral-muted-dark'),
        '--color-neutral': theme('colors.neutral-faint-dark'),
        '--color-neutral': theme('colors.neutral-strong-reverse-dark'),
        '--color-neutral': theme('colors.neutral-accented-reverse-dark'),
        '--color-neutral': theme('colors.neutral-reverse-dark'),
        '--color-neutral': theme('colors.neutral-muted-reverse-dark'),
        '--color-neutral': theme('colors.neutral-faint-reverse-dark'),
        '--color-primary': theme('colors.primary-dark'),
      };
// See the Tailwind configuration guide for advanced usage
// https://tailwindcss.com/docs/configuration

const plugin = require("tailwindcss/plugin")
const fs = require("fs")
const path = require("path")
// needed for using tailwind colors
const colors = require('tailwindcss/colors');

module.exports = {
  content: [
    "./js/**/*.js",
    "../lib/phx_theme_web.ex",
    "../lib/phx_theme_web/**/*.*ex"
  ],
  theme: {
    extend: {
      colors: {
        brand: "#FD4F00",
        //
        // neutral colors (light)
        'neutral-strongest': colors.zinc[900],
        'neutral-heavy': colors.zinc[800],
        'neutral-accented': colors.zinc[700],
        'neutral': colors.zinc[600],
        'neutral-central': colors.zinc[500],
        'neutral-muted': colors.zinc[400],
        'neutral-soft': colors.zinc[300],
        'neutral-subtle': colors.zinc[200],
        'neutral-dim': colors.zinc[100],
        'neutral-faint': colors.zinc[50],
        // 'neutral-strong-reverse': colors.zinc[100],
        // 'neutral-accented-reverse': colors.zinc[200],
        // 'neutral-reverse': colors.zinc[400],
        // 'neutral-muted-reverse': colors.zinc[400],
        // 'neutral-faint-reverse': colors.zinc[900],
        //
        // neutral colors (dark)
        'neutral-strongest-dark': colors.zinc[100],
        'neutral-heavy-dark': colors.zinc[200],
        'neutral-accented-dark': colors.zinc[300],
        'neutral-dark': colors.zinc[400],
        'neutral-central-dark': colors.zinc[500],
        'neutral-muted-dark': colors.zinc[600],
        'neutral-soft-dark': colors.zinc[700],
        'neutral-subtle-dark': colors.zinc[800],
        'neutral-dim-dark': colors.zinc[900],
        'neutral-faint-dark': colors.zinc[950],
        //
        // 'neutral-strong-reverse-dark': colors.zinc[100],
        // 'neutral-accented-reverse-dark': colors.zinc[800],
        // 'neutral-reverse-dark': colors.zinc[700],
        // 'neutral-muted-reverse-dark': colors.zinc[600],
        // 'neutral-faint-reverse-dark': colors.zinc[100],
        //
        // colors (light)
        'primary': colors.blue[500],
        // colors (dark)
        'primary-dark': colors.blue[300],
      }
    },
  },
  plugins: [
    require("@tailwindcss/forms"),
    // Allows prefixing tailwind classes with LiveView classes to add rules
    // only when LiveView classes are applied, for example:
    //
    //     <div class="phx-click-loading:animate-ping">
    //
    plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
    plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
    plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),

    // Embeds Heroicons (https://heroicons.com) into your app.css bundle
    // See your `CoreComponents.icon/1` for more information.
    //
    plugin(function({matchComponents, theme}) {
      let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
      let values = {}
      let icons = [
        ["", "/24/outline"],
        ["-solid", "/24/solid"],
        ["-mini", "/20/solid"],
        ["-micro", "/16/solid"]
      ]
      icons.forEach(([suffix, dir]) => {
        fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
          let name = path.basename(file, ".svg") + suffix
          values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
        })
      })
      matchComponents({
        "hero": ({name, fullPath}) => {
          let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
          let size = theme("spacing.6")
          if (name.endsWith("-mini")) {
            size = theme("spacing.5")
          } else if (name.endsWith("-micro")) {
            size = theme("spacing.4")
          }
          return {
            [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
            "-webkit-mask": `var(--hero-${name})`,
            "mask": `var(--hero-${name})`,
            "mask-repeat": "no-repeat",
            "background-color": "currentColor",
            "vertical-align": "middle",
            "display": "inline-block",
            "width": size,
            "height": size
          }
        }
      }, {values})
    }),
    // add custom color palette that auto switches between light and dark
    function ({ addBase, theme }) {
      const lightModeColors = {
        '--color-neutral': theme('colors.neutral-strong'),
        '--color-neutral': theme('colors.neutral-accented'),
        '--color-neutral': theme('colors.neutral'),
        '--color-neutral': theme('colors.neutral-muted'),
        '--color-neutral': theme('colors.neutral-faint'),
        '--color-neutral': theme('colors.neutral-strong-reverse'),
        '--color-neutral': theme('colors.neutral-accented-reverse'),
        '--color-neutral': theme('colors.neutral-reverse'),
        '--color-neutral': theme('colors.neutral-muted-reverse'),
        '--color-neutral': theme('colors.neutral-faint-reverse'),
        '--color-primary': theme('colors.primary'),
      };

      const darkModeColors = {
        '--color-neutral': theme('colors.neutral-strong-dark'),
        '--color-neutral': theme('colors.neutral-accented-dark'),
        '--color-neutral': theme('colors.neutral-dark'),
        '--color-neutral': theme('colors.neutral-muted-dark'),
        '--color-neutral': theme('colors.neutral-faint-dark'),
        '--color-neutral': theme('colors.neutral-strong-reverse-dark'),
        '--color-neutral': theme('colors.neutral-accented-reverse-dark'),
        '--color-neutral': theme('colors.neutral-reverse-dark'),
        '--color-neutral': theme('colors.neutral-muted-reverse-dark'),
        '--color-neutral': theme('colors.neutral-faint-reverse-dark'),
        '--color-primary': theme('colors.primary-dark'),
      };

      // Add CSS variables to :root for light mode
      addBase({
        ':root': lightModeColors,
      });

      // Add CSS variables to .dark for dark mode
      addBase({
        '.dark': darkModeColors,
      });
    }
  ]
}

Alternative Solution

Define Colors in CSS (assets/css/app.css) using HSL colors - this is simpler and easier to manage, but requires using HSL 0 which is not as intuitive (for me).

/* at the end of assets/css/app.css (or in another CSS file) */
:root {
  --standard-border: 220 13% 91%; /* Light mode: a very light blue-gray */
  --background-primary: 240 25% 97%; /* Light mode: almost white */
  --text-standard: 220 39% 11%; /* Light mode: dark blue-gray */
}

.dark {
  --standard-border: 220 13% 30%; /* Dark mode: much darker blue-gray */
  --background-primary: 220 27% 13%; /* Dark mode: very dark */
  --standard-text: 210 20% 98%; /* Dark mode: nearly white text */
}

Reference in Tailwind with hsl(var(–…)):

In tailwind.config.js, you are telling Tailwind to use the CSS variables and then combining them with hsl() to make them usable:

// tailwind.config.js
extend: {
  colors: {
    'standard-border': 'hsl(var(--border-standard))', // Tailwind uses this to generate utility classes
  },
},

Usage in HTML:

You can now use the Tailwind utility classes like border-border-standard, and they will adapt based on the mode:

Copy code
<div class="border border-standard-border p-4">
  <h1 class="text-standard-text">Hello, World!</h1>
</div>

Rename in-line colors

now we can rename colors in the components like: zinc-600 to neutral and we can go through the entire list and do this.

Colors:

Independent of what Phoenix has done it seems to make sense to define colors similar to (and these are all defined for both light :root and dark .dark) - this allow us to avoid using both: text-neutral dark:text-neutral and just use: text-neutral for both (the plugin we wrote handles light and dark mode colors)

Colors

  • primary

  • secondary

  • success

  • danger

  • warning

  • info

  • neutral

  • white

  • black

Neutral Shades - possibly other colors if needed

  • strongest
  • accented
  • standard
  • muted
  • faintest

add dark-mode

See the article:

TLDR Example JavaScript to toggle dark mode:

// Toggles the 'dark' class on the root HTML element
const toggleDarkMode = () => {
  document.documentElement.classList.toggle('dark');
};

Storing User Preference:

You can store the user’s preference (light or dark) in localStorage and use JavaScript to apply it on page load.

const userPrefersDark = localStorage.getItem('theme') === 'dark';
if (userPrefersDark) {
  document.documentElement.classList.add('dark');
}

const toggleDarkMode = () => {
  const htmlElement = document.documentElement;
  htmlElement.classList.toggle('dark');
  localStorage.setItem('theme', htmlElement.classList.contains('dark') ? 'dark' : 'light');
};

Conclusion

It would be cool if Phoenix did this from the start!

Bill Tihen
Bill Tihen
Developer, Data Enthusiast, Educator and Nature’s Friend

very curious – known to explore knownledge and nature