(Astro) Applying Dark Mode

Jan 25, 2024

Introduction

In many websites including blogs, providing comfortable environment for users is important, so they offer the darkmode. Let’s see how to apply darkmode in Astro.

There are several ways to apply darkmode to a page. This post is going to proceed with the following specs, and please refer to Utilizing Dark Mode, Like a Pro for detailed strategies.

  • Applies the style through tailwind.
  • Supports the darkmode selection dropdown button.
  • Adds the dark classname to <body> case of a darkmode.
  • The current theme state is stored in localStage, and the theme that was set last applies to the the page when it is accessed again.

Tailwind Settings

Surely, darkmode can be simply applied by using tailwind.

Applying class strategy in the tailwind setting allows the application of different styles under the .dark class. Then, we can add .dark class to the root tags like <html>, <body> in case of darkmode, hence the darkmode is applied to the page.

tailwind.config.cjs
/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: ['class'],
  // ...
};

Please refer tailwind official documentation for detailed information.

Creating a Dark Mode Selection Dropdown

When users want to apply darkmode, we can just add .dark class to <html> tag.

Using shadcn-ui dropdown, the .dark class should be added or removed in <html> tag according to the theme user selected. Meanwhile, let’s pre-process to keep the last selected theme maintained even when re-visit the page by storing it in localStorage.

As going to create theme-dropdown.tsx which is a React component, please refer to Astro official guide for React environment settings in Astro.

components/theme-dropdown.tsx
import { Button } from '~/components/ui/button';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '~/components/ui/dropdown-menu';
 
import { MoonIcon, SunIcon } from './ui/icons';
 
export default function ThemeDropdown() {
  const onClickLight = () => {
    document.documentElement.classList.remove('dark');
    localStorage.setItem('theme', 'light');
  };
 
  const onClickDark = () => {
    document.documentElement.classList.add('dark');
    localStorage.setItem('theme', 'dark');
  };
 
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon">
          <SunIcon className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <MoonIcon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={onClickLight}>Light</DropdownMenuItem>
        <DropdownMenuItem onClick={onClickDark}>Dark</DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

It’s simple to use React Components in Astro.
Just import them.

However, you should properly verify Client Directives which is Astro’s unique feature. Since Astro essentially deals with HTML only, React codes are not delivered to users. A button without React code becomes empty and unresponsive no matter how much you click it. Therefore, you should set Client Directives so that when the page is loaded, React code runs in the user’s browser.

---
import ThemeDropdown from './theme-dropdown';
---
 
<ThemeDropdown client:load />

Although the explanation is long, you only need to add client:load to the component tag. So that it can interact immediately when the page is loaded. When declared this way, Astro automatically hydrates the component so the button works as intended.

Applying Initial Theme State

The settings of the theme should be maintained when users refresh the the page or move to another page.

You can also use the server cookies, but let’s use a simpler method here - apply the initial theme state to HTML through script. We can use the data of localStorage that we saved when users selected the theme before. If there are no settings, we apply the device’s theme information using window.matchMedia.

theme-head-script.astro
<script is:inline>
  const theme = localStorage.getItem('theme');
 
  if (
    theme === 'dark' ||
    (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)
  ) {
    document.documentElement.classList.add('dark');
  }
</script>

The script should be executed in the browser, and the execution timing of the script is terribly important.

In conclusion, the script needs to be run before the screen is rendered.
The browser should know what theme to apply to the page before loading the page. If not, the browser first comes up with a blank screen, checks what theme to draw, and then draws, which triggers a screen flickering phenomenon in darkmode.

By placing the script at the bottom of <head> as below, you can solve the issue by making the browser execute the script before loading the page.

---
import ThemeHeadScript from '~/components/theme-head-script.astro';
---
 
<html>
  <head>
    <!-- ... -->
    <ThemeHeadScript />
  </head>
  <body>
    <!-- ... -->
  </body>
</html>

You should pay attention to having only the essential operations in ThemeHeadScript. When the browser encounters a script tag, it suspends the creation of DOM tree and runs the script. Therefore, the heavier the script, the slower the page loading speed.

You should also note Astro’s unique feature, Script & Style Directives. By default, Astro extracts scripts as module bundles(type="module"). Module bundles are executed after the page is loaded, so the screen flashing will occur.

In Astro, you can declare is:inline on the script tag to prevent the script from being optimized into a module bundle, and directly insert it into HTML. When working that way, server-side data can be passed using define:vars.

theme-head-script.astro
---
const theme = 'dark';
---
 
<script is:inline define:vars={{ theme }}>
  console.log(theme);
</script>

In Conclusion

So far, we looked at how to apply darkmode to a page through Astro. I personally liked Astro better than Next.js, as it more intuitively deals with HTML.

However, if you can’t understand its unique language, it could be quite a headache. If you’re going to develop, remember client:load, is:inline.

Actually, the journey of applying darkmode isn’t over yet.

  • Sharing the current theme as a global state across multiple components
  • Adding a ‘system’ option to the theme

We will look into these in the next post.