roon_dev
A nice looking HTML and CSS only Theme Selector that requires no JS with TailwindCSS and Astro.

A nice looking HTML and CSS only Theme Selector that requires no JS with TailwindCSS and Astro.

July 30, 2025
6 min read
index

When I first heard about Chrome’s Customizable Select , I knew immediately you could create really cool stuff without relying on component libraries like shancn that do the difficult work of essentially reimplementing selects.

The really “cool” thing I wanted to do was create a theme selector that needs nothing, but CSS and HTML. Unfortunately for my Firefox bros, Customizable Select is not yet available so for now you’re stuck with the boring old select. Here’s its Bugzilla ticket for those interested.

You can test the by using it in the header above!

What Customizable Select looks like on Chrome.

For my Astro pals, if #1083 hasn’t been merged yet at the time of reading, then unfortunately in the meantime you’ll have to compile the Astro compiler with the changes from #1070 and using pnpm link or equivalent to use the patched compiler instead. The compiling process itself is lightweight, but will require you to have (or develop!) basic familiarity with Go.

Setting And Persisting Color Scheme Preferences With CSS And A “Touch” Of JavaScript by Henry Bley-Vroman in Smashing Magazine laid the groundwork for my implementation.

Below is the basic implementation used by this blog using TailwindCSS and Astro. Most important to note is the use of appearance: base-select; which is the actual CSS that enables customization. Also to note for Astro users, transition persistence should be set on the Select element to maintain its state across pages, but this does unfortunately require JS .

src/components/ThemeSelectToggle.astro
---
import { Icon } from "astro-icon/components"
---
<style>
@reference '@/styles/global.css'; // change to reference whereever your Tailwind global class is defined
#theme-color-select {
@apply text-foreground/60 cursor-pointer text-sm;
@supports (appearance: base-select) {
&,
&::picker(select) {
@apply text-foreground/60;
appearance: base-select;
}
5 collapsed lines
@apply focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-full text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50;
@apply hover:bg-accent/25 hover:text-accent-foreground;
@apply size-8 transition-colors;
@apply text-right;
&::picker-icon {
display: none;
}
&::picker(select) {
@apply min-w-28 overflow-x-hidden overflow-y-auto rounded-md border p-0 shadow-md;
@apply border-border backdrop-blur-xs;
@apply bg-card/25;
right: anchor(right);
}
6 collapsed lines
&:open::picker(select) {
@apply animate-in fade-in-0 zoom-in-95;
}
&:close::picker(select) {
@apply animate-out fade-out-0 zoom-out-95;
}
option {
3 collapsed lines
@apply relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0;
@apply hover:bg-primary/10 hover:text-primary;
@apply justify-end-safe;
svg {
display: none;
}
&::checkmark {
display: none;
}
}
selectedcontent {
svg {
@apply size-4 shrink-0;
}
span {
@apply sr-only;
}
}
}
}
</style>
<select
id="theme-color-select"
transition:persist="theme-color-select"
>
<button>
<selectedcontent></selectedcontent>
</button>
<option value="system">
<Icon name="lucide:sun-moon" />
<span>System</span>
</option>
<option value="light">
<Icon name="lucide:sun" />
<span>Light Mode</span>
</option>
<option value="dark">
<Icon name="lucide:moon" />
<span>Night Mode</span>
</option>
</select>

Notice when we get down to the basics of the select, everything becomes styling rather than managing complex states in JavaScript or deeply nested divs to manage placement. For example you can have the svg display in the select dropdown by changing the display in the css. The new customizable select enables making very elegant drop downs easily.

Tip (enhance with js)

Just because this doesn’t require scripting doesn’t mean it can’t be enhanced with some.

For example, using the Astro recommended nanostore and its persistence plugin, you can create a store that saves the user’s selection across visits.

src/lib/theme-store.ts
import { persistentAtom } from '@nanostores/persistent'
const THEME_VALUES = ['dark', 'light', 'system'] as const
export type THEME_TYPE = (typeof THEME_VALUES)[number]
const STORAGE_KEY = 'preferred-theme'
export const isValidTheme = (val: string | null): val is THEME_TYPE =>
!!val && THEME_VALUES.includes(val as THEME_TYPE)
export const $theme = persistentAtom<THEME_TYPE>(STORAGE_KEY, 'system')

Then in the theme selector astro component you can add the following script tag:

src/components/ThemeSelectToggle.astro
<script>
import { $theme, isValidTheme } from '@/lib/theme-store'
let themeSelector = document.querySelector<HTMLSelectElement>(
'#theme-color-select',
)
$theme.subscribe((val) => {
if (isValidTheme(val) && themeSelector) {
themeSelector.value = val
}
})
themeSelector?.addEventListener('change', (event) => {
const target = event.target as HTMLSelectElement
if (isValidTheme(target.value)) {
$theme.set(target.value)
}
})
</script>

Now if you’re using TailwindCSS you need a dark variant to replace the one provided by Tailwind by default:

// change #theme-color-select to the ID if you've changed it
@custom-variant dark {
:root:has(#theme-color-select option[value='dark']:checked) & {
@slot;
}
@media (prefers-color-scheme: dark) {
:root:has(#theme-color-select option[value='system']:checked) & {
@slot;
}
}
}
Explanation (how it works)

The variant works by using the :has to check whether the root (which is always the <html> tag in the browser) contains some element with the given id and either the option dark is checked. .

The ampersand following is the Nesting Selector . Because of how TailwindCSS places the variant within the class rather than the class in the variant, the nesting selector is vital in making this work:

.dark\:bg-purple-950\/5 {
:root:has(#theme-color-select option[value='dark']:checked) & {
background-color: color-mix(in srgb, oklch(29.1% 0.149 302.717) 5%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-purple-950) 5%, transparent);
}
}
@media (prefers-color-scheme: dark) {
:root:has(#theme-color-select option[value='system']:checked) & {
background-color: color-mix(in srgb, oklch(29.1% 0.149 302.717) 5%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-purple-950) 5%, transparent);
}
}
}
}

The nesting selector means that the following are functionally equivalent!

:root:has(#theme-color-select option[value='dark']:checked) .dark\:bg-purple-950\/5 {
...
}
.dark\:bg-purple-950\/5 {
:root:has(#theme-color-select option[value='dark']:checked) & {
...
}
}

Unfortunately, last I checked there isn’t a way to have all the related classes wrapped under the variant:

:root:has(#theme-color-select option[value='dark']:checked) {
.dark\:bg-purple-950\/5 {
...
}
.dark\:bg-pink-950\/5 {
...
}
}

But hey that’s why compression exists.

Tip (multiple themes)

You could potentially define multiple themes by adding more values to the select and defining more custom variants in Tailwind:

@custom-variant forest {
:root:has(#theme-color-select option[value='forest']:checked) & {
@slot;
}
}

The possibilities are endless!

If you are using a component library like shadcn with CSS Variables for theming, the above variant doesn’t just werk :(. But worry not there is a solution. For shadcn, keep all your lightmode colors where they are in :root. Presuming that you are using .dark as prescribed by the shadcn docs, copy the values into root like the following:

:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
// ... the rest of the variables
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
&:has(#theme-color-select option[value='dark']:checked) {
// paste your dark mode variables here
}
@media (prefers-color-scheme: dark) {
&:has(#theme-color-select option[value='system']:checked) {
// paste your dark mode variables here
}
}

However if you’re like me, you’ll be annoyed at the fact that you have to double check that you are keeping dark mode in sync between the two modes. However, just how we defined a custom variant for dark, we can create a custom variant just for this:

@custom-variant ROOT_DARK {
&:has(#theme-color-select option[value='dark']:checked) {
@slot;
}
@media (prefers-color-scheme: dark) {
&:has(#theme-color-select option[value='system']:checked) {
@slot;
}
}
}
:root {
// ...light mode variables
@variant ROOT_DARK {
// paste your dark mode variables here
}
}

Now you have a single source of truth for your dark mode variables.

Now you have a theme selector that works looks fancy using native HTML elements and can be minimally enhanced with JS.