Skip to main content

Prerequisites

Before installing themes, ensure you have shadcn/ui set up in your Astro project with React integration. If you haven’t already:
1

Add React to Astro

npx astro add react
2

Initialize shadcn/ui

npx shadcn@latest init
3

Install required components

The theme system requires these shadcn/ui components:
npx shadcn@latest add dropdown-menu button

Installation

1

Install the theme system

Install the complete theme system with all 40+ themes:
npx shadcn@latest add https://tweakcn-picker.vercel.app/r/astro/theme-system
This installs:
  • lib/themes-config.ts - Theme configuration and metadata
  • components/theme-script.astro - Inline script for preventing FOUC
  • components/mode-toggle.tsx - React component for theme switching
  • styles/themes/*.css - All 40+ theme CSS files
2

Add theme script to your layout

Add the theme script component in the <head> of your base layout to prevent flash of unstyled content (FOUC):
src/layouts/Layout.astro
---
import ThemeScript from "@/components/theme-script.astro";
import "@/styles/themes/index.css";
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>My Astro Site</title>
    <ThemeScript />
  </head>
  <body>
    <slot />
  </body>
</html>
The ThemeScript component must be placed in the <head> tag to work correctly and prevent FOUC.
3

Add the ModeToggle to your UI

Import and use the ModeToggle component in any .astro or .tsx file:
src/components/Header.astro
---
import { ModeToggle } from "@/components/mode-toggle";
---

<header>
  <nav>
    <!-- Your navigation -->
    <ModeToggle client:load />
  </nav>
</header>
The client:load directive is required for React components to be interactive in Astro.

How it works

The Astro adapter uses an inline script for theme management to avoid hydration issues:
Located at components/theme-script.astro:
<script is:inline>
  const STORAGE_KEY = "tweakcn-theme";
  const DEFAULT_THEME = "default-dark";

  function getThemePreference() {
    if (typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY)) {
      return localStorage.getItem(STORAGE_KEY);
    }
    return DEFAULT_THEME;
  }

  function applyTheme(theme) {
    document.documentElement.setAttribute("data-theme", theme);
  }

  // Apply theme immediately
  const theme = getThemePreference();
  applyTheme(theme);

  // Listen for theme changes from React components
  if (typeof window !== "undefined") {
    window.setTheme = function(newTheme) {
      localStorage.setItem(STORAGE_KEY, newTheme);
      applyTheme(newTheme);
      window.dispatchEvent(new CustomEvent("theme-change", { detail: newTheme }));
    };
  }

  // Watch for storage changes (multi-tab sync)
  window.addEventListener("storage", (e) => {
    if (e.key === STORAGE_KEY && e.newValue) {
      applyTheme(e.newValue);
    }
  });
</script>
The is:inline directive ensures the script runs before any other JavaScript, preventing FOUC.
Located at components/mode-toggle.tsx:
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";

const STORAGE_KEY = "tweakcn-theme";
const DEFAULT_THEME = "default-dark";

export function ModeToggle() {
  const [theme, setThemeState] = React.useState<string>(DEFAULT_THEME);

  React.useEffect(() => {
    // Get initial theme
    const currentTheme =
      document.documentElement.getAttribute("data-theme") ||
      localStorage.getItem(STORAGE_KEY) ||
      DEFAULT_THEME;
    setThemeState(currentTheme);

    // Listen for theme changes
    const handleThemeChange = (e: CustomEvent<string>) => {
      setThemeState(e.detail);
    };

    window.addEventListener("theme-change", handleThemeChange as EventListener);
    return () => {
      window.removeEventListener("theme-change", handleThemeChange as EventListener);
    };
  }, []);

  const setTheme = (newTheme: string) => {
    setThemeState(newTheme);
    if (typeof window !== "undefined" && window.setTheme) {
      window.setTheme(newTheme);
    }
  };

  const isDark = theme.endsWith("-dark");
  const colorTheme = theme.replace("-light", "").replace("-dark", "");

  return (
    <Button
      variant="outline"
      size="icon"
      onClick={() => setTheme(`${colorTheme}-${isDark ? "light" : "dark"}`)}
    >
      {isDark ? <Moon /> : <Sun />}
      <span className="sr-only">Toggle theme</span>
    </Button>
  );
}
The theme script includes multi-tab synchronization using the Storage API:
window.addEventListener("storage", (e) => {
  if (e.key === STORAGE_KEY && e.newValue) {
    applyTheme(e.newValue);
  }
});
When you change the theme in one tab, all other tabs automatically update.

Adding individual themes

To install specific themes instead of all 40+:
npx shadcn@latest add https://tweakcn-picker.vercel.app/r/theme-catppuccin
Then import only the themes you need:
src/styles/themes/index.css
@import "./catppuccin.css";
@import "./supabase.css";
@import "./nature.css";

Customizing themes

All theme CSS files are in src/styles/themes/. Each theme defines CSS variables:
src/styles/themes/supabase.css
[data-theme="supabase-light"] {
  --background: oklch(0.99 0 0);
  --foreground: oklch(0.25 0.02 267.08);
  --primary: oklch(0.83 0.13 160.91);
  --primary-foreground: oklch(0.20 0.03 267.08);
  /* ... more variables */
}

[data-theme="supabase-dark"] {
  --background: oklch(0.13 0.01 264.53);
  --foreground: oklch(0.93 0.01 264.53);
  --primary: oklch(0.44 0.1 156.76);
  --primary-foreground: oklch(0.93 0.01 264.53);
  /* ... more variables */
}
Edit these files to customize colors, borders, shadows, and more.

TypeScript usage

You can programmatically change themes using the global setTheme function:
// In any TypeScript file
if (typeof window !== "undefined" && window.setTheme) {
  window.setTheme("supabase-dark");
}
Add TypeScript declarations for the global function:
src/env.d.ts
/// <reference types="astro/client" />

declare global {
  interface Window {
    setTheme: (theme: string) => void;
  }
}

export {};

Astro configuration

Ensure your astro.config.mjs includes the React integration:
astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';

export default defineConfig({
  integrations: [react()],
});

Client directives

When using React components in Astro, choose the appropriate client directive:
  • client:load - Hydrates immediately on page load (recommended for theme toggle)
  • client:idle - Hydrates when the main thread is free
  • client:visible - Hydrates when the component enters the viewport
<!-- Immediate hydration for theme toggle -->
<ModeToggle client:load />

<!-- Lazy hydration for less critical components -->
<ThemeShowcase client:visible />

Next steps

Browse Themes

Explore all 40+ available themes

Theme Picker

Preview themes in real-time

Next.js Setup

Install themes in Next.js

Vite Setup

Install themes in Vite React

Build docs developers (and LLMs) love