├── .astro └── types.d.ts ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── code_of_conduct.md ├── contributing.md └── workflows │ ├── ci.yml │ └── release-please.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── Themes.astro.template ├── Themes.ts ├── build.js ├── example ├── .astro │ └── types.d.ts ├── public │ └── slow.js └── src │ ├── env.d.ts │ └── pages │ ├── index.astro │ ├── options.astro │ └── slow.astro ├── index.ts ├── package-lock.json ├── package.json ├── playwright.config.ts ├── tests ├── basic.test.ts ├── fouc.test.ts └── options.test.ts └── tsconfig.json /.astro/types.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "\U0001F41B Bug Report: " 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "\U0001F4A1Feature Request: " 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/code_of_conduct.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | hello@alexgrover.me. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /.github/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First of all, welcome to `astro-themes` and thanks for your interest in contributing! Please note that we have a [code of conduct](https://github.com/alex-grover/astro-themes/blob/main/.github/code_of_conduct.md) that all participants are expected to follow. 4 | 5 | ## Suggesting features or reporting bugs 6 | 7 | Please [submit an issue](https://github.com/alex-grover/astro-themes/issues/new) before making a pull request! This package aims to be easy to use, not to support every possible use case, so a discussion ahead of time will lead to the highest chance of your change being accepted. 8 | 9 | I'll do my best to respond in a timely manner, but if I don't get back to you in a few days please feel free to ping me in the issue or PR! 10 | 11 | ## Making contributions 12 | 13 | Once the change has been discussed: 14 | 15 | - Fork the repo 16 | - Code it up! 17 | - Open a PR 18 | 19 | ### Expectations for all changes 20 | 21 | - Tests 22 | - Reasonable browser support (~95% on caniuse.com or 5 years) 23 | 24 | Commit messages and formatting are enforced by the commit hooks. Please use `npm run cz` to commit. 25 | 26 | Thanks again and happy coding! 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | cache: npm 20 | - name: Install dependencies 21 | run: | 22 | npm ci 23 | npm link 24 | npm link astro-themes 25 | - name: Check code 26 | run: npm run check:code 27 | - name: Check formatting 28 | run: npm run check:format 29 | - name: Check styles 30 | run: npm run check:styles 31 | - name: Check commit messages 32 | run: npx commitlint --from HEAD~${{ github.event.pull_request.commits }} --to HEAD --verbose 33 | 34 | test: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: actions/setup-node@v4 39 | with: 40 | node-version: 20 41 | cache: npm 42 | - name: Install dependencies 43 | run: | 44 | npm ci 45 | npm link 46 | npm link astro-themes 47 | - name: Install Playwright Browsers 48 | run: npm run test:install 49 | - name: Run Playwright tests 50 | run: npm test 51 | - uses: actions/upload-artifact@v4 52 | with: 53 | name: playwright-report 54 | path: playwright-report/ 55 | retention-days: 30 56 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | name: release-please 7 | jobs: 8 | release-please: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: google-github-actions/release-please-action@v3 12 | id: release 13 | with: 14 | release-type: node 15 | package-name: astro-themes 16 | bump-minor-pre-major: true 17 | bump-patch-for-minor-pre-major: true 18 | - uses: actions/checkout@v3 19 | if: ${{ steps.release.outputs.release_created }} 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: 20 23 | registry-url: 'https://registry.npmjs.org' 24 | if: ${{ steps.release.outputs.release_created }} 25 | - run: npm ci 26 | if: ${{ steps.release.outputs.release_created }} 27 | - run: npm run build 28 | if: ${{ steps.release.outputs.release_created }} 29 | - run: npm publish 30 | env: 31 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 32 | if: ${{ steps.release.outputs.release_created }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Keep .prettierignore in sync with this file! 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Dependency directories 11 | node_modules/ 12 | 13 | # TypeScript cache 14 | *.tsbuildinfo 15 | 16 | # Environment variables 17 | .env 18 | .env.test 19 | 20 | # Astro output 21 | src/env.d.ts 22 | dist/ 23 | 24 | # esbuild build output 25 | build/ 26 | 27 | # Stylelint cache 28 | .cache 29 | 30 | # IDE files 31 | .idea/ 32 | 33 | # Test output files 34 | tests/screenshots/ 35 | tests/videos/ 36 | /test-results/ 37 | /playwright-report/ 38 | /playwright/.cache/ 39 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run check:code 5 | npx lint-staged 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # This is the same as the .gitignore but with the autogenerated changelog added 2 | CHANGELOG.md 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Dependency directories 12 | node_modules/ 13 | 14 | # TypeScript cache 15 | *.tsbuildinfo 16 | 17 | # Environment variables 18 | .env 19 | .env.test 20 | 21 | # Astro output 22 | src/env.d.ts 23 | dist/ 24 | 25 | # esbuild build output 26 | build/ 27 | 28 | # Stylelint cache 29 | .cache 30 | 31 | # IDE files 32 | .idea/ 33 | 34 | # Test output files 35 | tests/screenshots/ 36 | tests/videos/ 37 | /test-results/ 38 | /playwright-report/ 39 | /playwright/.cache/ 40 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.2.6](https://github.com/alex-grover/astro-themes/compare/v0.2.5...v0.2.6) (2025-04-23) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * **themes.astro:** change script to not be a module ([67702f2](https://github.com/alex-grover/astro-themes/commit/67702f2acfb3180a2588e54d08e31a6f3d211a8c)) 9 | 10 | ## [0.2.6](https://github.com/alex-grover/astro-themes/compare/v0.2.5...v0.2.6) (2025-04-23) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * **themes.astro:** change script to not be a module ([67702f2](https://github.com/alex-grover/astro-themes/commit/67702f2acfb3180a2588e54d08e31a6f3d211a8c)) 16 | 17 | ## [0.2.5](https://github.com/alex-grover/astro-themes/compare/v0.2.4...v0.2.5) (2024-02-02) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * rewrite theme after view transition ([d04ee26](https://github.com/alex-grover/astro-themes/commit/d04ee26cc9d98e74ffa4f56d06a9846970784161)), closes [#23](https://github.com/alex-grover/astro-themes/issues/23) 23 | 24 | ## [0.2.4](https://github.com/alex-grover/astro-themes/compare/v0.2.3...v0.2.4) (2023-06-06) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * **deps:** release to update dependencies ([cc30d3b](https://github.com/alex-grover/astro-themes/commit/cc30d3bb881fdd11f3a6e922b82e939d1cde982f)) 30 | 31 | ## [0.2.3](https://github.com/alex-grover/astro-themes/compare/v0.2.2...v0.2.3) (2022-08-26) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * **build:** run build before publishing ([6dcba83](https://github.com/alex-grover/astro-themes/commit/6dcba83715afe33202d8eee72f412c8d17055e24)) 37 | 38 | ## [0.2.2](https://github.com/alex-grover/astro-themes/compare/v0.2.1...v0.2.2) (2022-08-26) 39 | 40 | 41 | ### Features 42 | 43 | * minify snippet ([0a9ab05](https://github.com/alex-grover/astro-themes/commit/0a9ab05ee928fd8b2ec3f69c7987e746f6ace222)) 44 | 45 | ## [0.2.1](https://github.com/alex-grover/astro-themes/compare/v0.2.0...v0.2.1) (2022-08-26) 46 | 47 | 48 | ### Bug Fixes 49 | 50 | * dispatch events on document, not window ([c721d72](https://github.com/alex-grover/astro-themes/commit/c721d724b31495f3499a55c8dc1a49453288855f)) 51 | 52 | ## [0.2.0](https://github.com/alex-grover/astro-themes/compare/v0.1.0...v0.2.0) (2022-08-26) 53 | 54 | 55 | ### ⚠ BREAKING CHANGES 56 | 57 | * update get/set methods 58 | 59 | ### Code Refactoring 60 | 61 | * use web builtins to get and set theme ([db399fe](https://github.com/alex-grover/astro-themes/commit/db399fe7a9c92455f6a36bae8298358fb4ba2292)) 62 | 63 | ## [0.1.0](https://github.com/alex-grover/astro-themes/compare/v0.0.1...v0.1.0) (2022-08-25) 64 | 65 | 66 | ### ⚠ BREAKING CHANGES 67 | 68 | * Update theme get/set methods 69 | 70 | ### Features 71 | 72 | * add listener for browser color scheme preference changes ([494a84a](https://github.com/alex-grover/astro-themes/commit/494a84a58415cab738f39a53668df15494f31c8c)) 73 | 74 | 75 | ### Code Refactoring 76 | 77 | * update API to only expose one object on window ([dd1e8fd](https://github.com/alex-grover/astro-themes/commit/dd1e8fd56d8716eeecd6647a2b7fc01c035ff52e)) 78 | 79 | ## 0.0.1 (2022-08-24) 80 | 81 | 82 | ### Features 83 | 84 | * implement theme component ([251c76b](https://github.com/alex-grover/astro-themes/commit/251c76bbbc7e250f7dfd266fc0d779f68a9064d6)) 85 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alex Grover 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 astro-themes [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/alex-grover/astro-themes/ci.yml?branch=main)](https://github.com/alex-grover/astro-themes/actions/workflows/ci.yml?query=branch%3Amain) [![npm](https://img.shields.io/npm/v/astro-themes)](https://www.npmjs.com/package/astro-themes) 2 | 3 | Easy dark mode for Astro websites. Add themes in 2 lines of code! Zero dependencies, supports SSR, and works with any 4 | framework or UI. 5 | 6 | ## Install 7 | 8 | ```sh 9 | npm install --save-dev astro-themes 10 | ``` 11 | 12 | ## Use 13 | 14 | Add the `` component in your app. Works best in a centralized layout component, so it's shared everywhere. 15 | 16 | ![code sample adding Themes component in document head](https://user-images.githubusercontent.com/3088615/187366957-fd3b3ef6-c3a8-4524-a898-49be189e1a73.png) 17 | 18 | ### Usage 19 | 20 | `astro-themes` gives you the ability to get and set the theme, with builtin browser standards. No polluting the global 21 | scope or any other hacks! These methods will work without a framework, or from within your React/Svelte/Vue/etc 22 | components. 23 | 24 | Set the theme: 25 | 26 | ```ts 27 | document.dispatchEvent(new CustomEvent('set-theme', { detail: 'dark' })) // or pass `null` to clear the saved setting 28 | ``` 29 | 30 | Get the current theme: 31 | 32 | ```ts 33 | document.documentElement.attributes.getNamedItem('data-theme')?.value // 'light' | 'dark' 34 | ``` 35 | 36 | Set a default theme (only applied if the browser doesn't specify a preference for dark mode): 37 | 38 | ```astro 39 | 40 | ``` 41 | 42 | #### Compatibility with Tailwind 43 | 44 | By default, Tailwind expects `class="dark"` on the `html` element rather than `data-theme`. To ensure that `dark:` classes work correctly, you can configure your dark mode selector as described [here](https://tailwindcss.com/docs/dark-mode#customizing-the-selector). 45 | 46 | ## What does it do? 47 | 48 | - Provides ability to get and set the theme from any framework, or none at all 49 | - Set theme by dispatching a custom browser event: `document.dispatchEvent(new CustomEvent('set-theme', { detail: 'dark' }))` 50 | - Get current theme: `document.documentElement.attributes.getNamedItem('data-theme')?.value` 51 | - Sets `data-theme` attribute on the `html` element, so you can implement dark mode in CSS 52 | - Sets `color-scheme` CSS attribute on the `html` element, so the browser renders built in elements appropriately 53 | - Reads user theme preference on page load 54 | - From `localStorage`, if they've set it in the past 55 | - From `prefers-color-scheme: dark`, if supported 56 | - Falls back to the `defaultTheme` option, or `light` if not specified 57 | - Synchronizes setting across tabs 58 | - Responds to changes in OS preference (for example, the OS automatically switching to dark mode in the evening) 59 | 60 | ## Run example locally 61 | 62 | ```sh 63 | gh repo clone alex-grover/astro-themes 64 | cd astro-themes 65 | npm install 66 | npm link 67 | npm link astro-themes 68 | npm run example 69 | ``` 70 | 71 | ## Feedback 72 | 73 | Ideas, bug reports, or questions are welcomed! Please don't hesitate to [open an issue](https://github.com/alex-grover/astro-themes/issues/new). 74 | 75 | This component was inspired by the excellent [`next-themes`](https://github.com/pacocoursey/next-themes). 76 | -------------------------------------------------------------------------------- /Themes.astro.template: -------------------------------------------------------------------------------- 1 | --- 2 | {types} 3 | 4 | const { defaultTheme = 'light' } = Astro.props as Props 5 | --- 6 | 7 | 10 | -------------------------------------------------------------------------------- /Themes.ts: -------------------------------------------------------------------------------- 1 | export type Theme = 'light' | 'dark' 2 | 3 | declare const defaultTheme: Theme 4 | 5 | declare global { 6 | interface DocumentEventMap { 7 | 'set-theme': CustomEvent 8 | } 9 | } 10 | 11 | export interface Props { 12 | defaultTheme?: Theme 13 | } 14 | 15 | const STORAGE_KEY = 'theme' 16 | 17 | const prefersDark = window.matchMedia('(prefers-color-scheme: dark)') 18 | 19 | function getThemePreference(): Theme { 20 | return prefersDark.matches ? 'dark' : defaultTheme 21 | } 22 | 23 | function resolveTheme(setting?: Theme | null): Theme { 24 | const storageValue = 25 | setting !== undefined 26 | ? setting 27 | : (localStorage.getItem(STORAGE_KEY) as Theme) 28 | 29 | return storageValue ?? getThemePreference() 30 | } 31 | 32 | function writeTheme(theme: Theme): void { 33 | document.documentElement.setAttribute('data-theme', theme) 34 | document.documentElement.style.colorScheme = theme 35 | } 36 | 37 | function handleStorageChange(event: StorageEvent): void { 38 | if (event.key !== STORAGE_KEY) return 39 | writeTheme(resolveTheme(event.newValue as Theme)) 40 | } 41 | 42 | function rewriteTheme(): void { 43 | writeTheme(resolveTheme()) 44 | } 45 | 46 | function handleThemeChange(event: CustomEvent): void { 47 | if (event.detail) { 48 | localStorage.setItem(STORAGE_KEY, event.detail) 49 | writeTheme(event.detail) 50 | } else { 51 | localStorage.removeItem(STORAGE_KEY) 52 | writeTheme(resolveTheme(event.detail)) 53 | } 54 | } 55 | 56 | document.addEventListener('set-theme', handleThemeChange) 57 | window.addEventListener('storage', handleStorageChange) 58 | prefersDark.addEventListener('change', rewriteTheme) 59 | document.addEventListener('astro:after-swap', rewriteTheme) 60 | writeTheme(resolveTheme()) 61 | -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises' 2 | 3 | import { transform } from 'esbuild' 4 | import ts from 'typescript' 5 | 6 | import tsconfig from './tsconfig.json' with { type: 'json' } 7 | 8 | console.log('Building...') 9 | 10 | console.log('Starting transpile and minify...') 11 | 12 | const input = await fs.readFile('Themes.ts') 13 | 14 | const snippet = await transform(input, { 15 | target: 'es6', 16 | format: 'esm', 17 | loader: 'ts', 18 | minify: true, 19 | }) 20 | 21 | console.log('Starting type generation...') 22 | 23 | let types 24 | const host = ts.createCompilerHost(tsconfig.compilerOptions) 25 | host.writeFile = (fileName, contents) => (types = contents) 26 | ts.createProgram(['Themes.ts'], tsconfig.compilerOptions, host).emit() 27 | 28 | console.log('Writing output file...') 29 | 30 | const template = await fs.readFile('Themes.astro.template') 31 | 32 | const output = template 33 | .toString() 34 | .replace('{types}', types.trim()) 35 | .replace('{snippet}', snippet.code.trim()) 36 | 37 | await fs.mkdir('build', { recursive: true }) 38 | await fs.writeFile('build/Themes.astro', output) 39 | 40 | console.log('Build complete!') 41 | -------------------------------------------------------------------------------- /example/.astro/types.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/public/slow.js: -------------------------------------------------------------------------------- 1 | console.log('hi') 2 | -------------------------------------------------------------------------------- /example/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Themes from 'astro-themes' 3 | --- 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Astro Themes Example 12 | 13 | 27 | 28 | 29 |

Astro Themes Example

30 | 31 |
32 | 37 | 42 | 47 |
48 | 49 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /example/src/pages/options.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Themes from 'astro-themes' 3 | --- 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Astro Themes Example 12 | 13 | 27 | 28 | 29 |

Astro Themes Example w/ Options

30 |

Default theme set to dark

31 | 32 |
33 | 38 | 43 | 48 |
49 | 50 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /example/src/pages/slow.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Themes from 'astro-themes' 3 | --- 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Astro Themes Example 12 | 13 | 27 | 28 | 29 |

Astro Themes Example

30 | 31 |

32 | I am text that appears in the body. Once I'm here and visible, the page 33 | has been rendered. 34 |

35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './build/Themes.astro' 2 | export * from './build/Themes.astro' 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro-themes", 3 | "version": "0.2.6", 4 | "description": "Easy dark mode for Astro websites", 5 | "type": "module", 6 | "exports": { 7 | ".": "./index.ts" 8 | }, 9 | "files": [ 10 | "index.ts", 11 | "build/Themes.astro" 12 | ], 13 | "scripts": { 14 | "clean": "rm -rf build", 15 | "build": "node build.js", 16 | "build:watch": "chokidar \"*.ts\" Themes.astro.template -c \"npm run build\"", 17 | "example": "npm-run-all --parallel build:watch example:dev", 18 | "example:dev": "astro dev --root example", 19 | "example:build": "astro build --root example", 20 | "example:start": "astro preview --root example", 21 | "test": "playwright test", 22 | "test:install": "playwright install --with-deps", 23 | "test:server": "npm-run-all --sequential build example:build example:start", 24 | "check": "npm-run-all --parallel check:*", 25 | "check:code": "astro check", 26 | "check:format": "prettier --check --cache .", 27 | "check:styles": "stylelint --formatter verbose --cache --cache-location .cache --ignore-path .gitignore \"**/*.astro\"", 28 | "fix": "npm-run-all --sequential fix:styles fix:format", 29 | "fix:format": "prettier --write --cache .", 30 | "fix:styles": "stylelint --formatter verbose --cache --cache-location .cache --ignore-path .gitignore \"**/*.astro\"", 31 | "cz": "cz", 32 | "prepare": "is-ci || husky install" 33 | }, 34 | "repository": { 35 | "type": "git", 36 | "url": "git+https://github.com/alex-grover/astro-themes.git" 37 | }, 38 | "keywords": [ 39 | "astro", 40 | "astro-component", 41 | "css", 42 | "dark-mode", 43 | "theme", 44 | "ui" 45 | ], 46 | "author": "Alex Grover (https://alexgrover.me)", 47 | "license": "MIT", 48 | "bugs": { 49 | "url": "https://github.com/alex-grover/astro-themes/issues" 50 | }, 51 | "homepage": "https://github.com/alex-grover/astro-themes#readme", 52 | "peerDependencies": { 53 | "astro": "*" 54 | }, 55 | "devDependencies": { 56 | "@astrojs/check": "^0.9.4", 57 | "@commitlint/cli": "^17.6.1", 58 | "@commitlint/config-conventional": "^17.0.3", 59 | "@playwright/test": "^1.50.1", 60 | "astro": "^4.16.6", 61 | "chokidar-cli": "^3.0.0", 62 | "cz-conventional-changelog": "^3.3.0", 63 | "esbuild": "^0.25.0", 64 | "husky": "^7.0.0", 65 | "is-ci": "^3.0.1", 66 | "lint-staged": "^13.0.3", 67 | "npm-run-all": "^4.1.5", 68 | "postcss-html": "^1.5.0", 69 | "prettier": "^3.5.3", 70 | "prettier-plugin-astro": "^0.5.3", 71 | "stylelint": "^15.6.0", 72 | "stylelint-config-astro": "^1.0.4", 73 | "stylelint-config-prettier": "^9.0.3", 74 | "stylelint-config-recess-order": "^4.0.0", 75 | "stylelint-config-standard": "^33.0.0", 76 | "typescript": "<5.5.0" 77 | }, 78 | "config": { 79 | "commitizen": { 80 | "path": "cz-conventional-changelog" 81 | } 82 | }, 83 | "commitlint": { 84 | "extends": [ 85 | "@commitlint/config-conventional" 86 | ] 87 | }, 88 | "prettier": { 89 | "semi": false, 90 | "singleQuote": true 91 | }, 92 | "stylelint": { 93 | "extends": [ 94 | "stylelint-config-standard", 95 | "stylelint-config-recess-order", 96 | "stylelint-config-prettier", 97 | "stylelint-config-astro" 98 | ] 99 | }, 100 | "lint-staged": { 101 | "*.astro": [ 102 | "stylelint --fix", 103 | "prettier --write" 104 | ], 105 | "!*.astro": "prettier --write --ignore-unknown" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test' 2 | import { devices } from '@playwright/test' 3 | 4 | const config: PlaywrightTestConfig = { 5 | testDir: './tests', 6 | timeout: 30 * 1000, 7 | expect: { 8 | timeout: 5000, 9 | }, 10 | fullyParallel: true, 11 | forbidOnly: !!process.env.CI, 12 | retries: process.env.CI ? 2 : 0, 13 | workers: process.env.CI ? 1 : undefined, 14 | reporter: process.env.CI ? 'html' : 'list', 15 | use: { 16 | actionTimeout: 0, 17 | baseURL: 'http://localhost:4321', 18 | trace: 'on-first-retry', 19 | }, 20 | projects: [ 21 | { 22 | name: 'chromium', 23 | use: { 24 | ...devices['Desktop Chrome'], 25 | }, 26 | }, 27 | ...(process.env.CI 28 | ? [ 29 | { 30 | name: 'firefox', 31 | use: { 32 | ...devices['Desktop Firefox'], 33 | }, 34 | }, 35 | { 36 | name: 'webkit', 37 | use: { 38 | ...devices['Desktop Safari'], 39 | }, 40 | }, 41 | ] 42 | : []), 43 | ], 44 | webServer: { 45 | command: 'npm run test:server', 46 | port: 4321, 47 | }, 48 | } 49 | 50 | export default config 51 | -------------------------------------------------------------------------------- /tests/basic.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test' 2 | 3 | test.describe('', () => { 4 | test('sets attribute and style on the html element', async ({ page }) => { 5 | await page.goto('/') 6 | 7 | await page.evaluate(() => 8 | document.dispatchEvent(new CustomEvent('set-theme', { detail: 'dark' })), 9 | ) 10 | 11 | const html = page.locator('html') 12 | await expect(html).toHaveAttribute('data-theme', 'dark') 13 | await expect(html).toHaveCSS('color-scheme', 'dark') 14 | 15 | await page.evaluate(() => 16 | document.dispatchEvent(new CustomEvent('set-theme', { detail: 'light' })), 17 | ) 18 | 19 | await expect(html).toHaveAttribute('data-theme', 'light') 20 | await expect(html).toHaveCSS('color-scheme', 'light') 21 | }) 22 | 23 | test('allows users to get theme', async ({ page }) => { 24 | await page.goto('/') 25 | 26 | const html = page.locator('html') 27 | await expect(html).toHaveAttribute('data-theme', 'light') 28 | 29 | await page.evaluate(() => 30 | document.dispatchEvent(new CustomEvent('set-theme', { detail: 'dark' })), 31 | ) 32 | 33 | const theme = await page.evaluate( 34 | () => 35 | document.documentElement.attributes.getNamedItem('data-theme')?.value, 36 | ) 37 | 38 | await expect(theme).toBe('dark') 39 | }) 40 | 41 | test('persists on refresh', async ({ page }) => { 42 | await page.goto('/') 43 | 44 | await page.evaluate(() => 45 | document.dispatchEvent(new CustomEvent('set-theme', { detail: 'dark' })), 46 | ) 47 | 48 | const html = page.locator('html') 49 | await expect(html).toHaveAttribute('data-theme', 'dark') 50 | await expect(html).toHaveCSS('color-scheme', 'dark') 51 | 52 | await page.reload() 53 | 54 | await expect(html).toHaveAttribute('data-theme', 'dark') 55 | await expect(html).toHaveCSS('color-scheme', 'dark') 56 | }) 57 | 58 | test('respects browser preferences', async ({ page }) => { 59 | await page.emulateMedia({ colorScheme: 'dark' }) 60 | 61 | await page.goto('/') 62 | 63 | const html = page.locator('html') 64 | await expect(html).toHaveAttribute('data-theme', 'dark') 65 | await expect(html).toHaveCSS('color-scheme', 'dark') 66 | }) 67 | 68 | test('responds to changes from localStorage', async ({ page }) => { 69 | await page.goto('/') 70 | 71 | const html = page.locator('html') 72 | await expect(html).toHaveAttribute('data-theme', 'light') 73 | await expect(html).toHaveCSS('color-scheme', 'light') 74 | 75 | // StorageEvents don't fire if you set localStorage from the same page, so 76 | // send an event manually 77 | await page.evaluate(() => 78 | window.dispatchEvent( 79 | new StorageEvent('storage', { 80 | key: 'theme', 81 | oldValue: null, 82 | newValue: 'dark', 83 | }), 84 | ), 85 | ) 86 | 87 | await expect(html).toHaveAttribute('data-theme', 'dark') 88 | await expect(html).toHaveCSS('color-scheme', 'dark') 89 | }) 90 | 91 | test('responds to changes in browser preference', async ({ page }) => { 92 | await page.emulateMedia({ colorScheme: 'dark' }) 93 | 94 | await page.goto('/') 95 | 96 | const html = page.locator('html') 97 | await expect(html).toHaveAttribute('data-theme', 'dark') 98 | await expect(html).toHaveCSS('color-scheme', 'dark') 99 | 100 | await page.emulateMedia({ colorScheme: 'light' }) 101 | 102 | await expect(html).toHaveAttribute('data-theme', 'light') 103 | await expect(html).toHaveCSS('color-scheme', 'light') 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /tests/fouc.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test' 2 | 3 | test.describe('Flash of Unstyled Content (FOUC)', () => { 4 | test('should be dark theme IMMEDIATELY as text is rendered', async ({ 5 | page, 6 | }) => { 7 | await page.emulateMedia({ colorScheme: 'dark' }) 8 | 9 | // throttle getting slow.js so that it doesn't immediately load 10 | await page.route('/slow.js', async (route) => { 11 | await new Promise((resolve) => setTimeout(resolve, 5_000)) 12 | await route.continue() 13 | }) 14 | 15 | // also don't wait for the page to load! 16 | await page.goto('/slow', { 17 | // "commit" = consider operation to be finished when network response is 18 | // received and the document started loading. 19 | waitUntil: 'commit', 20 | }) 21 | 22 | // wait for the text to be in the body 23 | await expect( 24 | page.getByText('I am text that appears in the body.'), 25 | ).toBeVisible() 26 | 27 | // now we should be able to immediately get the html element ... 28 | const html = page.locator('html') 29 | 30 | // ... and use a NON-retrying assertion to immediately assert. Once we 31 | // have the html element we do NOT want to wait for data-theme to be 32 | // available. It should be on the html element by the time we get here 33 | // because the body text is already there! 34 | expect(await html.getAttribute('data-theme')).toBe('dark') 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /tests/options.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test' 2 | 3 | test.describe(' options', () => { 4 | test("allows setting default theme if browser doesn't specify preference", async ({ 5 | page, 6 | }) => { 7 | await page.goto('/options') 8 | 9 | const html = page.locator('html') 10 | 11 | await expect(html).toHaveAttribute('data-theme', 'dark') 12 | await expect(html).toHaveCSS('color-scheme', 'dark') 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2016", 4 | "declaration": true, 5 | "emitDeclarationOnly": true, 6 | "isolatedModules": true, 7 | "strict": true 8 | }, 9 | "include": ["Themes.ts"] 10 | } 11 | --------------------------------------------------------------------------------