Adding Dark Mode to My Astro Site with viewtransitions using plain Javascript

15 min read

The Button

The Astro Component for the dark / light mode switching button. Freaking love Astro!

It has a class='btn--mode-switch' for styling, a [data-mode-button] for interactivity and a title tag for accessibility.

ModeButton.astro
<button class='btn--mode-switch' data-mode-button title='Switch between Darkmode and Lightmode'>
  <span class='sun'>
    <svg width='24px' height='24px' viewBox='0 0 24 24' fill='currentColor' xmlns='http://www.w3.org/2000/svg'>
      <path
        d='M12 7.5C9.51472 7.5 7.5 9.51472 7.5 12C7.5 14.4853 9.51472 16.5 12 16.5C14.4853 16.5 16.5 14.4853 16.5 12C16.5 9.51472 14.4853 7.5 12 7.5ZM6 12C6 8.68629 8.68629 6 12 6C15.3137 6 18 8.68629 18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12Z'
      ></path>
      <path d='M12.75 3V5.25H11.25V3H12.75Z'></path>
      <path d='M21 12.75L18.75 12.75L18.75 11.25L21 11.25L21 12.75Z'></path>
      <path d='M18.8943 6.16637L17.3033 7.75736L16.2426 6.6967L17.8336 5.10571L18.8943 6.16637Z'></path>
      <path d='M17.8336 18.8944L16.2426 17.3034L17.3033 16.2428L18.8943 17.8337L17.8336 18.8944Z'></path>
      <path d='M12.75 18.75V21H11.25V18.75H12.75Z'></path>
      <path d='M5.25 12.75L3 12.75L3 11.25L5.25 11.25L5.25 12.75Z'></path>
      <path d='M7.75732 17.3033L6.16633 18.8943L5.10567 17.8337L6.69666 16.2427L7.75732 17.3033Z'></path>
      <path d='M6.69666 7.75744L5.10567 6.16645L6.16633 5.10579L7.75732 6.69678L6.69666 7.75744Z'></path>
    </svg>
  </span>
  <span class='moon'>
    <svg width='24px' height='24px' viewBox='0 0 24 24' fill='currentColor' xmlns='http://www.w3.org/2000/svg'>
      <path
        d='M11.203 6.02337C7.59276 6.99074 5.45107 10.6948 6.41557 14.2943C7.38006 17.8938 11.0868 20.0307 14.6971 19.0634C16.1096 18.6849 17.2975 17.8877 18.1626 16.8409C15.1968 17.3646 12.2709 15.546 11.4775 12.585C10.7644 9.92365 12.0047 7.20008 14.3182 5.92871C13.3186 5.72294 12.2569 5.74098 11.203 6.02337ZM4.96668 14.6825C3.78704 10.2801 6.40707 5.75553 10.8148 4.57448C12.968 3.99752 15.1519 4.3254 16.9581 5.32413L16.6781 6.72587C16.4602 6.75011 16.241 6.79108 16.0218 6.8498C13.6871 7.47537 12.303 9.8703 12.9264 12.1968C13.5497 14.5233 15.9459 15.9053 18.2806 15.2797C18.7257 15.1604 19.1351 14.9774 19.5024 14.7435L20.5991 15.6609C19.6542 17.9633 17.6796 19.8171 15.0853 20.5123C10.6776 21.6933 6.14631 19.085 4.96668 14.6825Z'
      ></path>
    </svg>
  </span>
</button>

The SVG Icons are from Iconship Interface Icons Collectionon SVG Repo.

And now for the part that we’ve all been waiting for:


The Javascript to Add Dark Mode Functionality to Astro

Sorry, actually, before we get to the good stuff, I just wanted to share how I am currently importing the js code.

I threw it into the Layout page.

Layout.astro
...
<script>
    import "@assets/js/main.js";
</script>
...

I may end up putting it into a <script is:inline></script> instead. I don’t know if there will be any performance gains, but inline would remove the need to load an external file. As it is, at the moment it is the only js that I have on the site. [edit: I lied, the viewtransition component is js]

“What’s that @ symbol, in your import code?”

Oh, I’m so glad you asked. I’m almost done the post to explain just that very thing. So there should be a link here… some where… some day…

Now back to the good part:

Finally, the actual Javascript Code that changes Dark and Light Mode

main.js
document.addEventListener("astro:page-load", (ev) => {
  pageLoaded();
});

const pageLoaded = () => {
  if (!window.matchMedia) return;

  const MODENAME = "dchmode";
  const allowedModes = ["light", "dark"];

  const initMode = () => {
    let mode = getMode();
    if (mode === null) {
      mode = window.matchMedia("prefers-color-scheme: dark").matches ? "dark" : "light";
      setMode(mode);
    } else {
      doMode(mode);
    }
  };

  const setMode = (mode) => {
    if (!allowedModes.includes(mode)) return;

    localStorage.setItem(MODENAME, mode);
    doMode(mode);
  };

  const getMode = () => {
    return localStorage.getItem(MODENAME);
  };

  const changeMode = () => {
    const mode = getMode();
    const oppModeOf = {
      light: "dark",
      dark: "light",
    };
    setMode(oppModeOf[mode]);
  };

  const doMode = (mode) => {
    const html = document.querySelector("html");
    if (mode === "dark") {
      if (html.classList.contains("darkmode")) return;
      html.classList.add("darkmode");
    }
    if (mode === "light") {
      if (html.classList.contains("darkmode")) html.classList.remove("darkmode");
    }
  };

  initMode();

  const modeBtn = document.querySelector("[data-mode-button]");
  modeBtn.addEventListener("click", (e) => {
    e.preventDefault();
    changeMode();
  });
};

That’s it. Good night.


This Is Where We Break it Down

It’s business time.

In the View Transitions component, Astro exposes a few custom events for us to hook into the page transition lifecycle.

Astro View Transition Router Events

document.addEventListener("astro:before-preparation", (ev) => {
  console.log("astro:before-preparation");
});
document.addEventListener("astro:after-preparation", (ev) => {
  console.log("astro:after-preparation");
});
document.addEventListener("astro:before-swap", (ev) => {
  console.log("astro:before-swap");
});
document.addEventListener("astro:after-swap", (ev) => {
  console.log("astro:after-swap");
});
document.addEventListener("astro:page-load", (ev) => {
  console.log("astro:page-load");
});

Astro Page is Loaded

I hooked into the astro:page-load, as I will need to get and set the initial ‘mode’ value from the users preference, and the event fires on ‘initial page navigation’.

document.addEventListener("astro:page-load", (ev) => {
  pageLoaded();
});

When the event "astro:page-load" fires we call the pageLoaded() function that will initialize the script.

I intend on adding the (future) search function to pageLoaded() so I gave the function a generic page is loaded name. Also naming conventions are the hardest part of coding.

Error Checking and Variables

The first thing that we want to do is check to make sure that matchMedia is actually a part of the window object, as this feature is not supported in some older browsers. Read more about Can I Use… matchMedia.

I set MODENAME as the local storage key name. Convention is that for static named variables we make the name all caps.

I needed to only allow the setMode() function to save allowedModes, since mode can only be 1 of 2 modes (light or dark), because I had a bug… the setMode(mode) requires a mode variable and I forgot to provide one in the call setMode() and I kept getting undefined saved to the local storage.

const pageLoaded = () => {
  if (!window.matchMedia) return;

  const MODENAME = "dchmode";
  const allowedModes = ["light", "dark"];

Initialize the Mood, I Mean Mode

The initMode() function needs to be called first on every page load and only once. We check the localStorage (getMode()) to see if the value of MODENAME has been set.

If the value of MODENAME is not set yet (bet jet), then we need to check the users’ dark mode preference through window.matchMedia and set it with the setMode(mode).

Or getMode() does return a value and we just continue to doMode().

The doMode(mode) function takes the users preference, and applies a class .darkmode to <html> or removes it.

const initMode = () => {
  let mode = getMode();
  if (mode === null) {
    mode = window.matchMedia("prefers-color-scheme: dark").matches ? "dark" : "light";
    setMode(mode);
  } else {
    doMode(mode);
  }
};

The setMode(mode) function accepts a variable (which should only be 1 of the allowedModes), and saves it to the local storage using the key MODENAME.

Notice that in initMode() and setMode(mode) they both end by envoking doMode(mode).

const setMode = (mode) => {
  if (!allowedModes.includes(mode)) return;

  localStorage.setItem(MODENAME, mode);
  doMode(mode);
};

The getMode() function retrieves the value of MODENAME from local storage and returns it to where the function was invoked.

const getMode = () => {
  return localStorage.getItem(MODENAME);
};

The doMode(mode) function handles the changing of the mode in the browser for the user by first, accepting the mode then referencing the <html> element on the page, that should only have one instance of, then applying the class="darkmode" if the mode === 'dark' or removing it if the mode === 'light'.

The if statements if (html.classList.contains("darkmode")) simply check if the html element has the class, before adding or removing it.

const doMode = (mode) => {
  const html = document.querySelector("html");
  if (mode === "dark") {
    if (html.classList.contains("darkmode")) return;
    html.classList.add("darkmode");
  }
  if (mode === "light") {
    if (html.classList.contains("darkmode")) html.classList.remove("darkmode");
  }
};

Mode Toggling

Finally, the initMode() function is called after it has been registered into the DOM to start the process of applying the mode that matches window.matchMedia.

The button with the attribute [data-mode-button] is referenced and is giving an event listener for the "click" event, that allows the user to change the mode.

when modeBtn is clicked the changeMode() is called

First the changeMode() gets the previous mode using getMode(), and oppModeOf switches the mode to the opposite of itself, ‘light to dark’ and ‘dark to light’, which gets sent to setMode(oppModeOf[mode]) which calls doMode(mode) and applies the new mode.

const changeMode = () => {
  const mode = getMode();
  const oppModeOf = {
    light: "dark",
    dark: "light",
  };
  setMode(oppModeOf[mode]);
};

initMode();

const modeBtn = document.querySelector("[data-mode-button]");
modeBtn.addEventListener("click", (e) => {
  changeMode();
});

Did that explain it?

Enjoy the rest of your day.