Dark Mode

This guide explains how to enable and manage dark mode in a way consistent with Tailwind’s dark-mode documentation.

How it works

Hummingbird uses shared CSS theme variables to power both light and dark modes.

  • The default (light) colors are defined in @theme.
  • The dark palette overrides those same variables inside @variant dark.
  • Toggling a .dark class switches all values globally.

Key Idea

All components rely on the same semantic tokens:

  • --background-color-default
  • --text-color-default
  • --color-primary
  • --border-color-default

Result:

Switching themes requires no markup changes—only variable values update.

Customizing dark mode

Override variables after importing Hummingbird:

@import "@hummingbirdui/hummingbird";

@theme {
--background-color-default: var(--color-white);
--text-color-default: var(--color-gray-800);
--color-primary: var(--color-blue-500);
}

:root, :host { 
  @variant dark {
    --background-color-default: var(--color-gray-950);
    --text-color-default: var(--color-gray-100);
    --color-primary: var(--color-blue-400);
  }
}

Toggling dark mode manually

1. Custom dark mode selector

Hummingbird supports class-based dark mode using predefined color variables. To enable dark mode with a custom selector (instead of prefers-color-scheme), add the following after importing Tailwind and Hummingbird styles:

@custom-variant dark (&:where(.dark, .dark *), .dark);

The .dark class is typically added or removed on the <html> element.

2. Set the initial theme

Add this inline script inside the <head> tag to ensure the correct theme is applied before the page renders. This prevents flash issues and respects both the user’s saved preference in localStorage and their system’s prefers-color-scheme setting.

<script>
  // On page load or when changing themes, best to add inline in head to avoid FOUC
  document.documentElement.classList.toggle(
    "dark",
    localStorage.theme === "dark" ||
      (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches),
  );
</script>

3. Theme toggle script

This script manages switching between light and dark themes. It works by:

  • Adding or removing the .dark class on the <html> element
  • Temporarily disabling transitions and animations to avoid flicker when toggling
  • Storing the user’s preference in localStorage and restoring it automatically on page load
const THEME_KEY = 'theme';

const THEMES = {
  LIGHT: 'light',
  DARK: 'dark',
  SYSTEM: 'system',
};

// helpers for managing theme state
const getSystemTheme = () => (window.matchMedia('(prefers-color-scheme: dark)').matches ? THEMES.DARK : THEMES.LIGHT);
const getStoredTheme = () => localStorage.getItem(THEME_KEY) ?? THEMES.SYSTEM;
const resolveTheme = (theme) => (theme === THEMES.SYSTEM ? getSystemTheme() : theme);

const applyTheme = (theme) => {
  const html = document.documentElement;
  const resolvedTheme = resolveTheme(theme);
  html.classList.add('disable-transition');
  html.classList.toggle('dark', resolvedTheme === THEMES.DARK);
  requestAnimationFrame(() => html.classList.remove('disable-transition'));
};

const toggleTheme = () => {
  const currentTheme = resolveTheme(getStoredTheme());
  const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
  localStorage.setItem(THEME_KEY, newTheme);
  applyTheme(newTheme);
};

document.addEventListener('DOMContentLoaded', () => {
  applyTheme(getStoredTheme());

  // sync theme when os preference changes (only in "system" mode)
  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
  mediaQuery.addEventListener('change', () => {
    if (getStoredTheme() !== THEMES.SYSTEM) return;
    applyTheme(THEMES.SYSTEM);
  });

  // theme toggle button
  document.addEventListener('click', (event) => {
    const toggleBtn = event.target.closest('[data-theme-toggle-btn]');
    if (!toggleBtn) return;
    toggleTheme();
  });
});

4. Toggle button markup (example)

Any preferred markup can be used for toggling icon visibility based on the current state.

<button type="button" data-theme-toggle-btn class="btn btn-circle btn-subtle-neutral">
  <svg class="dark:hidden"><!-- moon icon --></svg>
  <svg class="hidden dark:block"><!-- sun icon --></svg>
</button>

Dark mode variable overrides

A list of theme variables that are overridden when dark mode is active.

@layer theme {
  :root,
  :host {
    @variant dark {
      /* Background colors */
      --background-color-subtle: var(--color-gray-900);
      --background-color-muted: var(--color-gray-800);
      --background-color-default: var(--color-gray-950);
      --background-color-highlight: var(--color-gray-700);
      --background-color-emphasis: var(--color-gray-600);

      /* Text colors */
      --text-color-subtle: var(--color-gray-500);
      --text-color-muted: var(--color-gray-400);
      --text-color-default: var(--color-gray-100);
      --text-color-highlight: var(--color-gray-50);
      --text-color-emphasis: var(--color-white);

      /* neutral */
      --color-light: var(--color-gray-800);
      --color-dark: var(--color-gray-700);
      --color-contrast: var(--color-gray-950);

      /* primary */
      --color-primary-lighter: var(--color-blue-950);
      --color-primary-light: var(--color-blue-700);
      --color-primary: var(--color-blue-400);
      --color-primary-dark: var(--color-blue-300);
      --color-primary-darker: var(--color-blue-100);

      /* secondary */
      --color-secondary-lighter: var(--color-purple-950);
      --color-secondary-light: var(--color-purple-700);
      --color-secondary: var(--color-purple-400);
      --color-secondary-dark: var(--color-purple-300);
      --color-secondary-darker: var(--color-purple-100);

      /* danger */
      --color-danger-lighter: var(--color-red-950);
      --color-danger-light: var(--color-red-600);
      --color-danger: var(--color-red-400);
      --color-danger-dark: var(--color-red-300);
      --color-danger-darker: var(--color-red-200);

      /* warning */
      --color-warning-lighter: var(--color-orange-950);
      --color-warning-light: var(--color-orange-800);
      --color-warning: var(--color-orange-400);
      --color-warning-dark: var(--color-orange-300);
      --color-warning-darker: var(--color-orange-200);

      /* success */
      --color-success-lighter: var(--color-green-950);
      --color-success-light: var(--color-green-700);
      --color-success: var(--color-green-400);
      --color-success-dark: var(--color-green-300);
      --color-success-darker: var(--color-green-200);

      /* info */
      --color-info-lighter: var(--color-sky-950);
      --color-info-light: var(--color-sky-700);
      --color-info: var(--color-sky-400);
      --color-info-dark: var(--color-sky-300);
      --color-info-darker: var(--color-sky-200);

      /* borders */
      --border-color-subtle: var(--color-gray-800);
      --border-color-default: var(--color-gray-700);
      --border-color-emphasis: var(--color-gray-600);

      /* actions */
      --color-active: var(--color-gray-500);
      --color-hover: var(--color-gray-700);
      --color-selected: var(--color-gray-900);
      --color-disabled-color: var(--color-gray-500);
      --color-disabled: var(--color-gray-700);
      --color-focus: var(--color-gray-700);

      /* Shadows */
      --shadow-xl:
        0px 12px 51px 0px rgba(0, 0, 0, 0.6), 0px 3px 24px 0px rgba(0, 0, 0, 0.56), 0px 1px 16px 0px rgba(0, 0, 0, 0.1);
    }
  }
}