New Updates: Dark Mode and More!
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:
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:
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:
export default { darkMode: 'selector',}
Then I added the dark mode class to the body:
<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:
When on mobile the select will be replaced on the top:
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:
<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:
- Anchor Positioning
- Auto Shifting Position when overflowed
- Invert position when overflowed
- 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:
<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:
<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:
<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!
Table of Contents
- SEO
- Sitemap Integration
- Robots.txt
- Cursor Detection
- Dark Mode
- Setting Up
- Custom Dropdown
- Motivation & Considerations
- Creating the Dropdown Content
- Creating the trigger
- Handling the Dropdown
- Enter AbortController
- Open/Close Toggle
- Handling Closing When Clicked Outside
- Handling The Color Switcher
- Binding The Color Mode Switcher
- Handling Language Switcher
- Conclusion