Albet Novendo

New Updates: Dark Mode and More!

12m

Published at Feb 6, 2025

During this month I have adjusted some of the features on my personal website. First is dependencies management, by moving some dependencies to dev I can save some time and space while building the production bundle in the server.

The resulting command is:

Terminal window
bun i --frozen-lockfile -p --no-progress && bun run build

I also migrated the postinstall command as it is tied to devDependencies and I don’t want to run it during production build time too.

SEO

I have also added some SEO features to my website, first is the robots.txt file and the sitemap integration. This help search engine to index my website better.

Sitemap Integration

Using Astro Sitemap I have successfully added sitemap to my website, hoping search engine could index it. The result can be accessed directly at /sitemap-index.xml.

The implementation details is quite simple, first adding the package:

Terminal window
bunx astro add sitemap

Then since I have localization in my website I have to add the following to astro.config.mjs on the integrations array:

sitemap({
i18n: {
defaultLocale: 'en',
locales: {
'en': 'en-US',
'id': 'id-ID',
},
},
})

This will generate the sitemap for each locale.

Robots.txt

I also added a robots.txt file to my website, the content is quite simple:

User-agent: *
Sitemap: https://albetnv.me/sitemap-index.xml

As you can see the robots.txt file is pointing to the sitemap index file with user-agent allowed for all.

Cursor Detection

Moving on, I also adjusted the Cursor Detection to detect if the user is using a mouse or a touch device. Previously, I used window.width and hide the trailer if the width < 1536 but this is not a good approach as it doesn’t detect the actual device type, in fact if you reduce the browser width the cursor will be hidden upon refresh. That’s why I decided to come up with a better approach.

#isTouchDevice() {
// Check for touch event support
const hasTouch =
"ontouchstart" in window || navigator.maxTouchPoints > 0;
// Check user-agent for common mobile device keywords
const userAgent = navigator.userAgent.toLowerCase();
const isMobileUA =
/iphone|ipad|ipod|android|windows phone|mobile|tablet|mobi/i.test(
userAgent,
);
// Check CSS media queries for touch interaction hints
const isCoarsePointer =
window.matchMedia("(pointer: coarse)").matches;
const hasHoverNone = window.matchMedia("(hover: none)").matches;
// Combine checks: Mobile UA or touch device with coarse pointer/hover-none
return (
isMobileUA || (hasTouch && (isCoarsePointer || hasHoverNone))
);
}

By combining the user-agent, touch event support, and CSS media queries I can now detect the actual device type and hide the cursor if it’s a touch device. The detection still require restart to take effect as I did not listen to window resize event, but I think it’s good enough for now, considering the user will not change their device type frequently.

Dark Mode

Who doesn’t love dark mode? I used dark mode when reading, coding, browsing, and many other things. When I use light mode I feel like my eyes are burning. That’s why I decided to implement dark mode on my website. By doing some research I found a lot of Neu UI design on dark mode used darker variant of it’s color. I did change some colors to match the others and I think it’s quite good.

Setting Up

The implementation is quite simple, first I configured the Tailwind:

tailwind.config.js
export default {
darkMode: 'selector',
}

Then I added the dark mode class to the body:

Layout.astro
<html class="dark">
<!-- ... -->
</html>

All that left is to add dark:* classes to the elements that need to be styled differently in dark mode.

Custom Dropdown

Motivation & Considerations

Implementing the dark mode classes are straightforward and actually done within just hours, but the switcher… not so much. First, the website have the normal select for switching language already:

Language Switcher Dropdown

When on mobile the select will be replaced on the top:

Mobile Language Switcher

It looks good and works well, but if I would add another switcher for the dark mode, it’s quickly… overflowed. I can try to put the switcher in the bottom but it will resulting on increasing height of the header, which is not good. Alternatively, is having sidebar, but I will have to rework the whole header layout including moving some links into the sidebar and leaves the header to be minimal, which is not what I want.

Therefore, I came up with another solution. How about having 2 buttons on the top right of the header? When clicked, show dropdown similar to the content of the select’s switcher. This way I can keep the header minimal and still have the color mode switcher.

The implementation is quite simple, first I added the buttons:

Header.astro
<button><LocaleIcon /></button>
<button><SunIcon /></button>

But, how will I add the dropdown? Well, creating dropdown is not as simple as adding absolute then put hidden class on it toggling the hidden class upon interaction and call it a day. Nope, there are some considerations to be made:

  1. Anchor Positioning
  2. Auto Shifting Position when overflowed
  3. Invert position when overflowed
  4. Auto hide when out of view

Creating the Dropdown Content

First, creating the Dropdown Content, simply use absolute with top-0 left-0 and that’s it add some padding and stuff if necessary, also let’s not forget of z-index:

DropdownContent.astro
<div class="absolute top-0 left-0 bg-gray-800 px-4 py-2 rounded-lg z-[9999]" data-dropdown="target">
<slot />
</div>

That’s it, the dropdown content is now ready. Moving on, the button/trigger:

Creating the trigger

The trigger is the button that will show the dropdown when clicked, it’s quite simple:

DropdownButton.astro
<button class="rounded-full p-1 focus:outline-none" data-dropdown-trigger="target">
<slot />
</button>

Handling the Dropdown

The last part is to handle the dropdown. Here both data-dropdown and data-dropdown-trigger are used to connect the dropdown content and the trigger.

First we should unmount the Dropdown Content from the current tree and moving it outside, to the body. This is to ensure the dropdown is not affected by the parent’s styling and positioning. But first, let’s start from the triggers:

class Dropdown {
#target: string;
#ref: HTMLButtonElement;
#menu: HTMLDivElement;
constructor(ref: HTMLButtonElement) {
this.#ref = ref;
this.#target = ref.getAttribute("data-dropdown-target")!;
this.#menu = this.#queryMenu();
this.setup();
}
#queryMenu() {
const menu = document.querySelector<HTMLDivElement>(
`div[data-dropdown-menu="${this.#target}"]`,
)!;
if (!menu) {
throw new Error(
`Dropdown menu with target ${this.#target} not found.`,
);
}
return menu;
}
setup() {
this.#menu.remove();
}
static initialize() {
const refs = document.querySelectorAll<HTMLButtonElement>(
"button[data-dropdown-target]",
);
for (const ref of refs) {
new Dropdown(ref);
}
}
}

The code above will handle the dropdown content tree position, when initialized it will remove the dropdown content from the tree, and store it to memory. Then how to show it when the trigger is clicked?

class Dropdown {
// rest of the code
#isOpen: boolean = false;
open() {
this.#isOpen = true;
document.body.prepend(this.#menu);
}
// rest of the code
}

That’s it, the dropdown is now working. Right? Well not quite. The dropdown is now showing, but it’s not positioned correctly. This is where Floating UI comes in:

reposition() {
return computePosition(this.#ref, this.#menu, {
placement: "bottom",
middleware: [flip(), shift(), offset(3)],
}).then(({ x, y }) => {
Object.assign(this.#menu.style, {
left: `${x}px`,
top: `${y}px`,
});
});
}
open() {
3 collapsed lines
this.#isOpen = true;
document.body.prepend(this.#menu);
const newMenu = this.#queryMenu();
const cleanup = autoUpdate(
this.#ref,
newMenu,
this.reposition.bind(this),
);
}

Notice how I used queryMenu again? This is because the #menu hold reference to old initial menu content which already been removed from the tree. So therefore, we have to query it again to get the new menu content that just prepended to the body.

Now the dropdown is working perfectly, in a way that we want? Not yet, we have to hide the dropdown when the trigger is clicked again and doing all the cleanup.

Enter AbortController

Since we want to do some cleanup, let’s take advantage of AbortController. Have you used it before? It’s quite useful for cancelling fetch request. But, it can also be used for other stuff as well. Let’s take a look.

The AbortController expose signal and abort(). The signal holds a few method, one of them is onabort which is called when abort() is called. This is quite useful for cleaning up event listeners, or any other stuff that need to be cleaned up, by simply attach the cleanup to onabort.

class Dropdown {
// rest of the code
#controller?: AbortController;
close(menu: HTMLDivElement, cleanup: () => void) {
if (!this.#isOpen) return;
this.#isOpen = false;
menu.remove();
cleanup();
}
open() {
11 collapsed lines
this.#isOpen = true;
document.body.prepend(this.#menu);
const newMenu = this.#queryMenu();
const cleanup = autoUpdate(
this.#ref,
newMenu,
this.reposition.bind(this),
);
this.#controller = new AbortController();
this.#controller.signal.onabort = () => {
this.close(newMenu, cleanup);
};
}
// rest of the code
}

Now, whenever we call abort() on the controller, the dropdown will be closed and the cleanup will be called.

Info

One might ask, why re-create the AbortController everytime the dropdown is opened? It’s because we need to ensure the AbortController stays fresh, the aborted controller cannot be reused, so we have to create a new one everytime the dropdown is opened.

Open/Close Toggle

With AbortController ready, let’s adjust our setup to bind into the trigger click event in order to toggle the dropdown content:

// -- DROPDOWN SCRIPT --
class Dropdown {
// rest of the code
setup() {
this.#menu.remove();
this.#ref.addEventListener("click", () => {
if (this.#isOpen) {
this.#controller?.abort();
} else {
this.open();
}
});
}
// rest of the code
}

Thanks to AbortController, all we need is just calling abort when the controller present.

Handling Closing When Clicked Outside

Of course, the role of AbortController does not end here. I also need to bind another event listener to close the dropdown when clicked outside of it:

open() {
16 collapsed lines
this.#isOpen = true;
document.body.prepend(this.#menu);
const newMenu = this.#queryMenu();
const cleanup = autoUpdate(
this.#ref,
newMenu,
this.reposition.bind(this),
);
this.#controller = new AbortController();
this.#controller.signal.onabort = () => {
this.close(newMenu, cleanup);
};
const handler = (e: Event) => {
if (!this.#isOpen) return;
if (
!this.#ref.contains(e.target as Node) &&
!newMenu.contains(e.target as Node)
) {
this.#controller?.abort();
}
};
window.addEventListener("click", handler, {
signal: this.#controller.signal,
});
}

Take a look at how signal is passed to the window event listener as well, ensuring the event listener is cleaned up when the controller is aborted. How convenient!

Handling The Color Switcher

We almost finished, the dropdown is finish. But, the color switcher implementation? Not yet. So let’s move on to the color switcher HTML first:

ColorSwitcher.astro
<DropdownButton target="color-switcher">
// Button Content
<SunIcon data-mode-icon="light" />
<MoonIcon data-mode-icon="dark" class="hidden" />
<LaptopIcon data-mode-icon="system" class="hidden" />
</DropdownButton>
// Dropdown Content
<div data-dropdown="color-switcher">
<button data-mode-trigger="light">Light</button>
<button data-mode-trigger="dark">Dark</button>
<button data-mode-trigger="system">System</button>
</div>

With the attributes set, let’s query and attach the event listeners:

class ColorMode {
2 collapsed lines
static readonly MODE_KEY = "color-mode";
mode: ColorModeValues;
constructor(mode?: ColorModeValues) {
2 collapsed lines
this.mode = mode ?? ColorMode.getPreference();
this.updateDOM();
}
static getSystemPreference(): boolean {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
static getPreference(): ColorModeValues {
9 collapsed lines
const savedMode = localStorage.getItem(
this.MODE_KEY,
) as ColorModeValues;
if (savedMode) {
return savedMode;
}
return "system";
}
updateDOM(): void {
18 collapsed lines
let isDark = this.mode === "dark";
if (this.mode === "system") {
isDark = ColorMode.getSystemPreference();
}
document.documentElement.classList.toggle("dark", isDark);
const icons = document.querySelectorAll("svg[data-mode-icon]");
for (const icon of icons) {
const iconMode = icon.getAttribute(
"data-mode-icon",
) as ColorModeValues;
icon.classList.toggle("hidden", iconMode !== this.mode);
}
this.#savePreference();
}
#savePreference(): void {
6 collapsed lines
if (this.mode === "system") {
localStorage.removeItem(ColorMode.MODE_KEY);
return;
}
localStorage.setItem(ColorMode.MODE_KEY, this.mode);
}
static bindTrigger(controller: AbortController) {
const triggers = document.querySelectorAll<HTMLButtonElement>(
"button[data-mode-trigger]",
);
const colorMode = new ColorMode();
for (const trigger of triggers) {
const mode = trigger.getAttribute("data-mode-trigger") as
| "light"
| "dark"
| "system";
if (!mode) {
console.error(
"Mode trigger missing mode attribute:",
trigger,
);
return;
}
const handler = () => {
colorMode.mode = mode;
colorMode.updateDOM();
controller?.abort();
};
trigger.addEventListener("click", handler, {
signal: controller?.signal,
});
}
}
}

The above class is the core logic of color mode switching. It will toggle the dark mode class on the html element, and hide/show the icons based on the mode. It will also save the preference to the local storage.

The bindTrigger method is used to bind the event listener to the color mode switcher dropdown content. Notice we pass in the same AbortController from the open to this method to ensure any side effect will be binded to the lifecycle of the dropdown.

In this case, trigger.addEventListener will be cleared when the dropdown is closed, ensuring no memory leak.

Binding The Color Mode Switcher

Let’s modify the Dropdown class again and add some nice #binds array to automatically binds and trigger side effect when needed:

class Dropdown {
#binds: Record<string, (controller: AbortController) => void> = {
"color-switch": ColorMode.bindTrigger,
};
open() {
31 collapsed lines
this.#isOpen = true;
document.body.prepend(this.#menu);
const newMenu = this.#queryMenu();
const cleanup = autoUpdate(
this.#ref,
newMenu,
this.reposition.bind(this),
);
this.#controller = new AbortController();
this.#controller.signal.onabort = () => {
this.close(newMenu, cleanup);
};
const handler = (e: Event) => {
if (!this.#isOpen) return;
if (
!this.#ref.contains(e.target as Node) &&
!newMenu.contains(e.target as Node)
) {
this.#controller?.abort();
}
};
window.addEventListener("click", handler, {
signal: this.#controller.signal,
});
this.#binds[this.#target]?.(this.#controller);
}
}

Now, whenever the dropdown is opened, the #binds associated to #target will be called ensuring the side effect is binded to the lifecycle of the dropdown. The Color Mode switcher is now working perfectly.

Handling Language Switcher

Similar to the Color Mode switcher, the language switcher is also implemented in the same way. The only difference is the logic to switch the language:

class Localization {
static setLocale(lang: string) {
if (lang !== "en" && lang !== "id") {
console.error("Invalid language code:", lang);
return;
}
const url = new URL(location.href);
url.pathname = url.pathname.replace(/^\/[a-z]{2}/, `/${lang}`);
location.pathname = url.pathname;
}
static bindTrigger(controller: AbortController) {
const triggers =
document.querySelectorAll<HTMLButtonElement>(
"button[data-lang]",
);
for (const trigger of triggers) {
trigger.addEventListener(
"click",
() => {
const lang = trigger.getAttribute("data-lang")!;
Localization.setLocale(lang);
controller.abort();
},
{
signal: controller.signal,
},
);
}
}
}

Because the language switcher is quite simple and involved page reload, the logic is quite straightforward. The setLocale method will replace the current language code in the URL and reload the page.

Conclusion

You know, React/Vue or any other framework can solve this easily, but I think it’s quite fun to implement it from scratch (with help of FloatingUI of course). The dropdown is now working perfectly, and the color mode switcher and language switcher is also working as expected. I hope you enjoy reading this post and maybe you can implement it on your own website too!