---
# System prepended metadata

title: Astro Tips dark mode

---



https://github.com/withastro/blog-tutorial-demo/blob/complete/src/components/ThemeIcon.astro

https://discord.com/channels/1217527207467946087/1217544238397526016/1225065920393908244

Starlight:
- https://github.com/withastro/starlight/blob/b799f409bc941fdbdb41c4808b5aa602717af71b/packages/starlight/components/ThemeSelect.astro#L28
- https://github.com/withastro/starlight/blob/b799f409bc941fdbdb41c4808b5aa602717af71b/packages/starlight/components/ThemeProvider.astro

## Handling the existing PR

@Fryuni and @Oliver talked about how to handle the [open community PR]. What we agreed would be the best approach to have less friction with a community member to rewrite the entire thing would be to either:
- Commit directly to their branch
- Open a new PR includig them as co-author credits

[open community PR]: https://github.com/astrolicious/astro-tips.dev/pull/40

## DX Goal


Ideal API:

```astro
---
import ThemeProvider from '../ThemeProvider.astro';
import ThemeProvider from '../ThemeSelector.astro';
---
<html>
<head>
  <ThemeProvider/>
</head>
<body>
  <ThemeSeletor/>
  
  <script>
    theme.getCurrentTheme();
    theme.setTheme();
    theme.onThemeChange();
  </script>
</body>
</html>
```

```html
<div>
</div>
<script is:inline>
	class StarlightThemeSelect extends HTMLElement {
		constructor() {
			super();
		}
	}

	customElements.define('starlight-theme-select', StarlightThemeSelect);
</script>
```

1 component in the head
- handles logic
- expose a method


## Theme Provider

Guide through the process in incremental steps:

### Defining the API

Points to explain:
- What is the expected final API:
    - `setTheme` that accepts a theme to be set, or `'auto'` (default) that will use the system theme.
    - `getTheme` returning the _selected_ theme. So in case of `'auto'` it will return the string `'auto'`, not the system theme.
- Why `is:inline` is needed (FOUC)
    - Good practice of the iife to not polute the global scope
- `??=` for VT compatibility

Code:

```html
<script is:inline>
    window.theme ??= (() => {
        const defaultTheme = 'dark';
        let selectedTheme = defaultTheme;

        function setTheme(theme = defaultTheme) {
            selectedTheme = theme;

            document.documentElement.dataset.theme = theme;
        }

        function getTheme() {
            return selectedTheme;
        }

        return {
            setTheme,
            getTheme
        };
    })();

    theme.setTheme(theme.getTheme())
```

### Allowing a custom default theme

Point to explain:
- How to pass information from a server-side script to a client-side script

Code

```html
---
type Props = {
    "default-theme"?: "auto" | "dark" | "light";
};

const { "default-theme": defaultTheme = "auto" } = Astro.props;
---
<script is:inline data-default-theme={defaultTheme}>
    window.theme ??= (() => {
        const defaultTheme = document.currentScript.dataset.defaultTheme;

        let selectedTheme = defaultTheme;

        function setTheme(theme = defaultTheme) {
            selectedTheme = theme;

            document.documentElement.dataset.theme = theme;
        }

        function getTheme() {
            return selectedTheme;
        }

        return {
            setTheme,
            getTheme
        };
    })();

    theme.setTheme(theme.getTheme())
</script>
```

### Persistence

Points to explain:
- Why store the theme on `localStorage`
- Why do we need to check if `localStorage` is defined (restricted environments like Brave on maximum shields mode)

Code:

```html
---
type Props = {
    "default-theme"?: "auto" | "dark" | "light";
};

const { "default-theme": defaultTheme = "auto" } = Astro.props;
---
<script is:inline data-default-theme={defaultTheme}>
    window.theme ??= (() => {
        const defaultTheme = document.currentScript.dataset.defaultTheme;

        const storageKey = "theme";
        const store =
            typeof localStorage !== "undefined"
                ? localStorage
                : {
                    getItem: () => null,
                    setItem: () => {},
                };

        function setTheme(theme = defaultTheme) {
            store.setItem(storageKey, theme);

            document.documentElement.dataset.theme = theme;
        }

        function getTheme() {
            return store.getItem(storageKey) || defaultTheme;
        }

        return {
            setTheme,
            getTheme
        };
    })();

    theme.setTheme(theme.getTheme())
</script>
```

### System theme

Points to explain:
- What is the system/auto theme
- How to retrieve the system theme

Code:

```html
---
type Props = {
    "default-theme"?: "auto" | "dark" | "light";
};

const { "default-theme": defaultTheme = "auto" } = Astro.props;
---
<script is:inline data-default-theme={defaultTheme}>
    window.theme ??= (() => {
        const defaultTheme = document.currentScript.dataset.defaultTheme;

        const storageKey = "theme";
        const store =
            typeof localStorage !== "undefined"
                ? localStorage
                : {
                    getItem: () => null,
                    setItem: () => {},
                };
        
        const mediaMatcher = window.matchMedia("(prefers-color-scheme: light)");
        
        const systemTheme = mediaMatcher.matches ? 'light' : 'dark';

        function setTheme(theme = defaultTheme) {
            store.setItem(storageKey, theme);
            
            document.documentElement.dataset.theme =
                theme === 'auto' ? systemTheme : theme;
        }

        function getTheme() {
            return store.getItem(storageKey) || defaultTheme;
        }

        return {
            setTheme,
            getTheme
        };
    })();

    theme.setTheme(theme.getTheme())
</script>
```

### Theme changed event

Points to explain:
- Why dispatch an event (avoiding use of expensive `MutationObserver`)

Code:

```html
---
type Props = {
    "default-theme"?: "auto" | "dark" | "light";
};

const { "default-theme": defaultTheme = "auto" } = Astro.props;
---
<script is:inline data-default-theme={defaultTheme}>
    window.theme ??= (() => {
        const defaultTheme = document.currentScript.dataset.defaultTheme;

        const storageKey = "theme";
        const store =
            typeof localStorage !== "undefined"
                ? localStorage
                : {
                    getItem: () => null,
                    setItem: () => {},
                };
        
        const mediaMatcher = window.matchMedia("(prefers-color-scheme: light)");
        
        const systemTheme = mediaMatcher.matches ? 'light' : 'dark';
        
        function applyTheme(theme) {
            document.documentElement.dataset.theme = theme;
            
            const event = new CustomEvent('theme-changed', {
                details: {
                    appliedTheme: theme,
                    selectedTheme: getTheme(),
                },
            });
            
            document.dispatch(event);
        }

        function setTheme(theme = defaultTheme) {
            store.setItem(storageKey, theme);
            
            applyTheme(theme === 'auto' ? systemTheme : theme);
        }

        function getTheme() {
            return store.getItem(storageKey) || defaultTheme;
        }

        return {
            setTheme,
            getTheme
        };
    })();

    theme.setTheme(theme.getTheme())
</script>
```

### Watch for system theme changes

Points to explain:
- Why should we handle system changes
    - As the palette of the system changes, so should a website using system changes
    - Many systems, especially mobile, have adaptative themes that use light mode from sunrise to sunset and dark mode from sunset to sunrise

Code:

```html
---
type Props = {
    "default-theme"?: "auto" | "dark" | "light";
};

const { "default-theme": defaultTheme = "auto" } = Astro.props;
---
<script is:inline data-default-theme={defaultTheme}>
    window.theme ??= (() => {
        const defaultTheme = document.currentScript.dataset.defaultTheme;

        const storageKey = "theme";
        const store =
            typeof localStorage !== "undefined"
                ? localStorage
                : {
                    getItem: () => null,
                    setItem: () => {},
                };
        
        const mediaMatcher = window.matchMedia("(prefers-color-scheme: light)");
        
        let systemTheme = mediaMatcher.matches ? 'light' : 'dark';
        mediaMatcher.addEventListener("change", (event) => {
            systemTheme = mediaMatcher.matches ? "light" : "dark";

            if (theme.getTheme() === "auto") {
                applyTheme(systemTheme);
            }
        });
        
        function applyTheme(theme) {
            document.documentElement.dataset.theme = theme;
            
            const event = new CustomEvent('theme-changed', {
                details: {
                    appliedTheme: theme,
                    selectedTheme: getTheme(),
                },
            });
            
            document.dispatch(event);
        }

        function setTheme(theme = defaultTheme) {
            store.setItem(storageKey, theme);
            
            applyTheme(theme === 'auto' ? systemTheme : theme);
        }

        function getTheme() {
            return store.getItem(storageKey) || defaultTheme;
        }

        return {
            setTheme,
            getTheme
        };
    })();

    theme.setTheme(theme.getTheme())
</script>
```

### Styles

```html
<style>
  /* Define CSS variables for light theme */
  :root[data-theme="light"] {
    --background-color: #ffffff;
    --text-color: #000000;
    --link-color: #0000ee;
  }

  /* Define CSS variables for dark theme */
  :root[data-theme="dark"] {
    --background-color: #333333;
    --text-color: #ffffff;
    --link-color: #0094ff;
  }
  body {
    background-color: var(--background-color);
    color: var(--text-color);
  }
</style>
```