Skip to main content

Prerequisites

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

Initialize shadcn/ui

npx shadcn@latest init
2

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/remix/theme-system
This installs:
  • lib/themes-config.ts - Theme configuration and metadata
  • lib/sessions.server.ts - Server-side session management
  • components/mode-toggle.tsx - Theme toggle component
  • app/routes/action.set-theme.ts - Theme action route
  • styles/themes/*.css - All 40+ theme CSS files
  • remix-themes dependency for SSR theme support
2

Configure theme session in root loader

Update your app/root.tsx to add the theme session resolver:
app/root.tsx
import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
} from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/node";
import { themeSessionResolver } from "@/lib/sessions.server";
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from "remix-themes";
import "@/styles/themes/index.css";

export async function loader({ request }: LoaderFunctionArgs) {
  const { getTheme } = await themeSessionResolver(request);
  return {
    theme: getTheme(),
  };
}

export default function AppWithProviders() {
  const data = useLoaderData<typeof loader>();
  return (
    <ThemeProvider specifiedTheme={data.theme} themeAction="/action/set-theme">
      <App />
    </ThemeProvider>
  );
}

function App() {
  const data = useLoaderData<typeof loader>();
  const [theme] = useTheme();
  
  return (
    <html lang="en" data-theme={theme ?? ""}>
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
        <Links />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}
3

Add the ModeToggle to your UI

Import and use the ModeToggle component anywhere in your app:
app/components/Header.tsx
import { ModeToggle } from "@/components/mode-toggle";

export function Header() {
  return (
    <header>
      <nav>
        {/* Your navigation */}
        <ModeToggle />
      </nav>
    </header>
  );
}
4

Update session secret (production)

Replace the default session secret in lib/sessions.server.ts before deploying to production:
lib/sessions.server.ts
const sessionStorage = createCookieSessionStorage({
  cookie: {
    name: "tweakcn-theme",
    path: "/",
    httpOnly: true,
    sameSite: "lax",
    secrets: [process.env.SESSION_SECRET || "s3cr3t"], // Use environment variable!
    ...(isProduction
      ? { domain: "your-production-domain.com", secure: true }
      : {}),
  },
});
Add SESSION_SECRET to your .env file:
.env
SESSION_SECRET=your-very-secret-random-string-here

How it works

The Remix adapter uses remix-themes for server-side rendering with cookie-based session storage:
Located at lib/sessions.server.ts:
import { createCookieSessionStorage } from "@remix-run/node";
import { createThemeSessionResolver } from "remix-themes";

const isProduction = process.env.NODE_ENV === "production";

const sessionStorage = createCookieSessionStorage({
  cookie: {
    name: "tweakcn-theme",
    path: "/",
    httpOnly: true,
    sameSite: "lax",
    secrets: ["s3cr3t"], // Replace in production!
    ...(isProduction
      ? { domain: "your-production-domain.com", secure: true }
      : {}),
  },
});

export const themeSessionResolver = createThemeSessionResolver(sessionStorage);
Themes are stored in HTTP-only cookies, making them available on the server for SSR.
Located at app/routes/action.set-theme.ts:
import { createThemeAction } from "remix-themes";
import { themeSessionResolver } from "@/lib/sessions.server";

export const action = createThemeAction(themeSessionResolver);
This route handles theme changes from the client. The remix-themes package automatically creates the action handler.
Located at components/mode-toggle.tsx:
import { Moon, Sun } from "lucide-react";
import { Theme, useTheme } from "remix-themes";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

export function ModeToggle() {
  const [theme, setTheme] = useTheme();

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon">
          <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme(Theme.LIGHT)}>
          Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme(Theme.DARK)}>
          Dark
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}
The PreventFlashOnWrongTheme component prevents FOUC by applying the theme before React hydrates:
<head>
  <PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
</head>
This component injects an inline script that runs immediately, setting the theme before any content renders.

Adding individual themes

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

Customizing themes

All theme CSS files are in app/styles/themes/. Each theme defines CSS variables:
app/styles/themes/github.css
[data-theme="github-light"] {
  --background: oklch(1 0 0);
  --foreground: oklch(0.23 0.01 264.53);
  --primary: oklch(0.52 0.16 145);
  --primary-foreground: oklch(1 0 0);
  /* ... more variables */
}

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

TypeScript usage

Use the useTheme hook from remix-themes:
import { Theme, useTheme } from "remix-themes";

function MyComponent() {
  const [theme, setTheme] = useTheme();
  
  return (
    <div>
      <p>Current theme: {theme}</p>
      <button onClick={() => setTheme(Theme.DARK)}>
        Switch to Dark
      </button>
      <button onClick={() => setTheme(Theme.LIGHT)}>
        Switch to Light
      </button>
    </div>
  );
}

Server-side theme access

Access the current theme in loaders and actions:
import { LoaderFunctionArgs } from "@remix-run/node";
import { themeSessionResolver } from "@/lib/sessions.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const { getTheme } = await themeSessionResolver(request);
  const currentTheme = getTheme();
  
  // Use theme for server-side logic
  return { theme: currentTheme };
}

Production deployment

Before deploying to production:
1

Set session secret

Add a strong session secret to your environment:
SESSION_SECRET=$(openssl rand -base64 32)
2

Configure domain

Update the cookie domain in lib/sessions.server.ts:
...(isProduction
  ? { domain: "yourdomain.com", secure: true }
  : {}),
3

Enable secure cookies

Ensure secure: true is set for production to require HTTPS.

Next steps

Browse Themes

Explore all 40+ available themes

Theme Picker

Preview themes in real-time

Next.js Setup

Install themes in Next.js

Astro Setup

Install themes in Astro

Build docs developers (and LLMs) love