Skip to main content

Overview

The portfolio implements a sophisticated dark mode system using Tailwind CSS’s class-based approach. It features:
  • Instant theme switching with no flash
  • Persistent theme preference (localStorage)
  • System preference detection
  • Smooth transitions between themes
  • Accessible theme toggle button

How Dark Mode Works

Theme Toggle Component

The theme toggle is implemented in src/components/ThemeToggle.astro:
src/components/ThemeToggle.astro
<button
  id="theme-toggle"
  class="p-2 rounded-lg hover:bg-slate-200/50 dark:hover:bg-slate-700/50 transition-colors"
  aria-label="Toggle dark mode"
>
  <!-- Sun icon (shown in dark mode) -->
  <svg id="sun-icon" class="w-5 h-5 hidden dark:block text-white" 
       fill="none" stroke="currentColor" viewBox="0 0 24 24">
    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 
          d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z">
    </path>
  </svg>
  
  <!-- Moon icon (shown in light mode) -->
  <svg id="moon-icon" class="w-5 h-5 block dark:hidden text-slate-600" 
       fill="none" stroke="currentColor" viewBox="0 0 24 24">
    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 
          d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z">
    </path>
  </svg>
</button>

<script>
  const themeToggle = document.getElementById('theme-toggle');
  
  themeToggle?.addEventListener('click', () => {
    const isDark = document.documentElement.classList.toggle('dark');
    localStorage.setItem('theme', isDark ? 'dark' : 'light');
  });
</script>
Key features:
  • Toggle switches between .dark class on <html>
  • Icons automatically swap using dark:hidden and dark:block
  • Theme preference saved to localStorage
  • Accessible with proper aria-label

Theme Initialization

Theme initialization happens in src/layouts/Layout.astro BEFORE the page renders:
src/layouts/Layout.astro
<script is:inline>
  // Dark mode initialization - runs before paint to avoid flash
  (function() {
    const theme = localStorage.getItem('theme');
    if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
  })();
</script>
Why this works:
  • is:inline - Script runs immediately (not bundled)
  • Executes before first paint - No flash of wrong theme
  • Checks localStorage first - Respects user preference
  • Falls back to system preference - Good default behavior
The is:inline directive is critical. Without it, Astro bundles the script and it runs too late, causing a flash of the wrong theme.

Tailwind Configuration

Dark mode is configured in tailwind.config.mjs:
tailwind.config.mjs
export default {
  darkMode: 'class',
  // ... rest of config
};
Options:
  • 'class' - Dark mode toggled by .dark class (recommended)
  • 'media' - Dark mode based on system preference only
  • false - Dark mode disabled

Using Dark Mode in Components

Basic Usage

Tailwind’s dark: variant applies styles only in dark mode:
<div class="bg-white dark:bg-slate-900">
  <h1 class="text-slate-900 dark:text-white">
    This text is dark in light mode, white in dark mode
  </h1>
</div>

Common Patterns

<!-- Light background in light mode, dark in dark mode -->
<div class="bg-slate-50 dark:bg-slate-950">
  Content
</div>

Customizing Dark Mode Colors

Global Background and Text

The base colors are set in the Layout component:
src/layouts/Layout.astro
body {
  background-color: #f8fafc; /* slate-50 */
  color: #1e293b; /* slate-800 */
}

.dark body {
  background-color: #020617; /* slate-950 */
  color: #f1f5f9; /* slate-100 */
}
1

Choose your color palette

Decide on light and dark background colors. Use tools like:
2

Update body styles

Replace the hex values in the Layout component with your chosen colors.
3

Test contrast

Verify text is readable on your backgrounds using WebAIM Contrast Checker.

Component-Specific Colors

Update individual components by modifying their dark: classes:
src/components/Hero.astro
<section class="bg-gradient-to-br from-slate-50 to-blue-50 
                dark:from-slate-950 dark:to-blue-950">
  <h1 class="text-slate-900 dark:text-white">
    Hero Title
  </h1>
  <p class="text-slate-600 dark:text-slate-400">
    Hero description
  </p>
</section>

Custom Color Definitions

Define custom dark mode colors in Tailwind config:
tailwind.config.mjs
export default {
  darkMode: 'class',
  theme: {
    extend: {
      colors: {
        // Light mode colors
        surface: {
          DEFAULT: '#ffffff',
          dark: '#1e293b',
        },
        // Use in components
        'text-primary': {
          DEFAULT: '#1e293b',
          dark: '#f1f5f9',
        },
      },
    },
  },
};
Usage:
<div class="bg-surface dark:bg-surface-dark">
  <p class="text-text-primary dark:text-text-primary-dark">
    Content
  </p>
</div>

Adding Dark Mode to New Components

When creating new components, follow this checklist:
Every background should have a dark variant:
<div class="bg-white dark:bg-slate-900">
All text should be readable in both modes:
<p class="text-slate-900 dark:text-white">
<span class="text-slate-600 dark:text-slate-400">
Borders need sufficient contrast:
<div class="border-slate-200 dark:border-slate-700">
Shadows should be visible or removed:
<div class="shadow-lg dark:shadow-slate-900/50">
Icons need appropriate colors:
<svg class="text-slate-600 dark:text-slate-400">
Hover, focus, and active states:
<button class="hover:bg-slate-100 dark:hover:bg-slate-800
               focus:ring-blue-500 dark:focus:ring-blue-400">

Advanced Dark Mode Patterns

Gradients

Create beautiful gradients that work in both themes:
<div class="bg-gradient-to-r 
            from-blue-500 to-purple-600 
            dark:from-blue-600 dark:to-purple-700">
  Gradient background
</div>

Images and Media

Handle images that may not look good in dark mode:
<img src="logo.svg" 
     class="dark:invert dark:brightness-0" 
     alt="Logo" />

Transitions

Add smooth transitions when switching themes:
/* In Layout.astro or global CSS */
* {
  transition: background-color 200ms ease-in-out,
              color 200ms ease-in-out,
              border-color 200ms ease-in-out;
}
Or per-component:
<div class="transition-colors duration-200">
  Smooth color transitions
</div>

Theme Persistence

How It Works

The theme is stored in localStorage:
// Save theme
localStorage.setItem('theme', 'dark');

// Read theme
const theme = localStorage.getItem('theme');

// Clear theme (use system preference)
localStorage.removeItem('theme');

Respecting System Preferences

The initialization script checks system preference:
if (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches) {
  document.documentElement.classList.add('dark');
}
To listen for system theme changes:
window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    if (!localStorage.getItem('theme')) {
      if (e.matches) {
        document.documentElement.classList.add('dark');
      } else {
        document.documentElement.classList.remove('dark');
      }
    }
  });

Enhanced Theme Toggle

Three-State Toggle

Implement Light / Auto / Dark toggle:
ThemeToggleEnhanced.astro
<button id="theme-toggle" aria-label="Toggle theme">
  <svg id="sun-icon" class="w-5 h-5"><!-- Sun --></svg>
  <svg id="moon-icon" class="w-5 h-5"><!-- Moon --></svg>
  <svg id="auto-icon" class="w-5 h-5"><!-- Computer --></svg>
</button>

<script>
  let theme = localStorage.getItem('theme') || 'auto';
  
  function updateTheme(newTheme) {
    theme = newTheme;
    
    if (theme === 'auto') {
      localStorage.removeItem('theme');
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      document.documentElement.classList.toggle('dark', prefersDark);
    } else {
      localStorage.setItem('theme', theme);
      document.documentElement.classList.toggle('dark', theme === 'dark');
    }
    
    updateIcon();
  }
  
  function updateIcon() {
    // Show appropriate icon based on current theme
    // Implementation left as exercise
  }
  
  document.getElementById('theme-toggle')?.addEventListener('click', () => {
    const themes = ['light', 'auto', 'dark'];
    const currentIndex = themes.indexOf(theme);
    const nextTheme = themes[(currentIndex + 1) % themes.length];
    updateTheme(nextTheme);
  });
  
  updateIcon();
</script>

Testing Dark Mode

1

Manual testing

Toggle between themes and check:
  • All text is readable
  • No white/dark boxes that shouldn’t be there
  • Images look good (or have dark variants)
  • Interactive elements are visible
2

Browser DevTools

Use Chrome DevTools:
  1. Open DevTools (F12)
  2. Cmd/Ctrl + Shift + P
  3. Type “Render”
  4. Select “Emulate CSS prefers-color-scheme: dark”
3

Accessibility check

Verify contrast ratios:
Always test dark mode with actual users. What looks good to you might be too bright or too dark for others.

Common Issues

Cause: Theme initialization script runs too late.Solution: Ensure script has is:inline attribute and is in <head> before any content.
Cause: localStorage not being saved.Solution: Check browser console for errors. Ensure localStorage is accessible (not disabled or in private mode).
Cause: Missing dark: variants on those elements.Solution: Add appropriate dark: classes to all visible elements.
Cause: Too many elements transitioning at once.Solution: Use transition-colors instead of transition-all, or reduce transition duration.

Best Practices

  1. Consistent color scale: Use the same color families (e.g., slate) across light and dark modes
  2. Sufficient contrast: Ensure WCAG AA minimum (4.5:1 for text)
  3. Test both modes: Design and develop with both themes visible
  4. Progressive enhancement: Site should work even if JavaScript fails
  5. Respect user choice: Don’t override system preferences without good reason
  6. Smooth transitions: Use transition-colors for color changes
  7. Accessible toggle: Include proper ARIA labels and keyboard support

Next Steps

Customization

Customize more aspects of your portfolio

Deployment

Deploy your themed portfolio to production

Build docs developers (and LLMs) love