Skip to content

ajstrongdev/start-themes

Repository files navigation

(TanStack) Start Themes

A headless theme provider for TanStack Start applications.

  • Headless - bring your own themes, classes, and styles. No built-in themes.
  • SSR-safe - themes resolve on the server via cookies. No flash of unstyled content.
  • Typed - full type-safety for theme IDs and custom theme metadata.
  • Tiny - zero runtime dependencies. Just react and @tanstack/react-router as peer deps.

Install

npm install @ajstrongdev/start-themes
# or
pnpm add @ajstrongdev/start-themes
# or
yarn add @ajstrongdev/start-themes
# or
bun install @ajstrongdev/start-themes

Peer dependencies: react >=19, @tanstack/react-router >=1

Quick start

1. Define your themes

Create a theme config file in your app. Add whatever properties you need — defineThemes preserves them with full type safety.

// src/lib/theme.ts
import { createServerFn } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import { defineThemes } from "@ajstrongdev/start-themes";

export const themeConfig = defineThemes({
  themes: [
    { id: "light", isDark: false, label: "Light Mode" },
    { id: "dark", isDark: true, label: "Dark Mode" },
    { id: "nord", isDark: true, label: "Nord" },
  ],
  defaultTheme: "dark",
});

export type ThemeId = (typeof themeConfig)["themeIds"][number];

2. Create server functions

Server functions must live in your app code so TanStack Start can process them.

// src/lib/theme.ts

export const getTheme = createServerFn().handler(() =>
  themeConfig.resolveTheme(getCookie(themeConfig.cookieKey)),
);

export const setTheme = createServerFn()
  .inputValidator(themeConfig.validateTheme)
  .handler(({ data }) =>
    setCookie(themeConfig.cookieKey, data, {
      maxAge: themeConfig.cookieMaxAge,
    }),
  );

3. Wire up the root route

// src/routes/__root.tsx
import { ThemeProvider } from "@ajstrongdev/start-themes";
import { themeConfig, getTheme, setTheme } from "@/lib/theme";

export const Route = createRootRouteWithContext()({
  loader: () => getTheme(),
  shellComponent: RootDocument,
  component: RootLayout,
});

function RootLayout() {
  const theme = Route.useLoaderData();
  return (
    <ThemeProvider theme={theme} onThemeChange={(id) => setTheme({ data: id })}>
      <Outlet />
    </ThemeProvider>
  );
}

function RootDocument({ children }: { children: React.ReactNode }) {
  const theme = Route.useLoaderData();
  return (
    <html className={themeConfig.getClasses(theme)}>
      <head>
        <HeadContent />
      </head>
      <body>{children}</body>
    </html>
  );
}

4. Use in components

import { useTheme } from "@ajstrongdev/start-themes";

function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
      Current: {theme}
    </button>
  );
}

API

defineThemes(options)

Creates a typed theme configuration object.

Options

Option Type Default Description
themes ThemeDefinition[] required Array of theme definitions. Each must have an id string. Add any custom properties.
defaultTheme string required Theme ID to use when no cookie is set or the value is invalid.
resolveClasses (theme) => string Auto Custom function to generate CSS classes. Default: "<id>" for light, "<id> dark" for dark themes.
cookieKey string "_theme" Cookie name for persistence.
cookieMaxAge number 31536000 Cookie max-age in seconds (default 1 year).

Returns ThemeConfig<T>

Property Type Description
themes T The original themes array with full types preserved.
defaultTheme string The default theme ID.
themeIds readonly string[] Array of all valid theme IDs.
cookieKey string The cookie key.
cookieMaxAge number The cookie max-age.
resolveTheme(cookieValue) string Resolve a cookie value to a valid theme ID. Falls back to defaultTheme.
getClasses(themeId) string Generate CSS class string for the <html> element.
validateTheme(value) string Validate input is a known theme ID. Throws on invalid input. Works as a server function input validator.

<ThemeProvider>

React context provider for the current theme.

Prop Type Description
theme string Current theme ID (from loader data).
onThemeChange (theme: string) => Promise<unknown> | unknown Called when the theme changes. The TanStack Router is automatically invalidated afterward.
children ReactNode

useTheme()

Hook to access the current theme context. Must be called within a <ThemeProvider>.

Returns { theme: string; setTheme: (theme: string) => void }.

Custom class resolution

By default, getClasses returns the theme ID as a CSS class, plus "dark" for themes with isDark: true:

"light"    → "light"
"dark"     → "dark dark"
"dracula"  → "dracula dark"

Override this with resolveClasses:

const themeConfig = defineThemes({
  themes: [
    { id: "light", isDark: false },
    { id: "midnight", isDark: true },
  ],
  defaultTheme: "midnight",
  resolveClasses: (theme) => {
    // Only output "dark" class, skip theme ID
    return theme.isDark ? "dark" : "";
  },
});

Custom theme properties

Add any properties to your theme definitions. They're fully typed:

const themeConfig = defineThemes({
  themes: [
    {
      id: "ocean",
      isDark: true,
      label: "Ocean Blue",
      swatch: "#1e3a5f",
      fontFamily: "Inter",
    },
  ],
  defaultTheme: "ocean",
});

// Fully typed access
themeConfig.themes[0].label; // "Ocean Blue"
themeConfig.themes[0].swatch; // "#1e3a5f"
themeConfig.themes[0].fontFamily; // "Inter"

Tailwind CSS integration

This package pairs well with Tailwind's dark: variant. In your CSS:

@custom-variant dark (&:is(.dark *));

Then define your theme CSS variables using class selectors that match your theme IDs:

:root {
  --background: white;
  --foreground: black;
}

.dark {
  --background: #1a1a2e;
  --foreground: #eaeaea;
}

.nord {
  --background: #2e3440;
  --foreground: #d8dee9;
}

About

Headless theme engine for Tanstack Start!

Resources

License

Stars

2 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors