Skip to content

Instantly share code, notes, and snippets.

@AlexandroMtzG
Created January 12, 2026 02:58
Show Gist options
  • Select an option

  • Save AlexandroMtzG/146734aafefc22183a3d7efeb74cfaae to your computer and use it in GitHub Desktop.

Select an option

Save AlexandroMtzG/146734aafefc22183a3d7efeb74cfaae to your computer and use it in GitHub Desktop.

Custom Themes Architecture

This document explains the Custom Themes feature in SaasRock (All Editions). Tenants can customize their application's look and feel with theme presets, color pickers, CSS import/export, and AI-powered theme generation.


Overview

┌─────────────────────────────────────────────────────────────────────────────────┐
│                              THEME SYSTEM                                       │
│                                                                                 │
│  ┌─────────────────────────────────────────────────────────────────────────┐    │
│  │                     THEME SETTINGS PAGE                                 │    │
│  │                   /app/:tenant/settings/theme                           │    │
│  │                                                                         │    │
│  │  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────────────┐  │    │
│  │  │  Theme Presets  │  │  Color Pickers  │  │  Sidebar Options        │  │    │
│  │  │                 │  │                 │  │                         │  │    │
│  │  │  - Default      │  │  Primary        │  │  - Style (default/inset)│  │    │
│  │  │  - Zinc         │  │  Secondary      │  │  - Force Dark Sidebar   │  │    │
│  │  │  - Blue         │  │  Accent         │  │                         │  │    │
│  │  │  - Tangerine    │  │  Background     │  │  [Import/Export CSS]    │  │    │
│  │  │  - Sunset       │  │  + 15 more...   │  │                         │  │    │
│  │  │  - Catppuccin   │  │                 │  │                         │  │    │
│  │  │  - Custom       │  │  (Light + Dark) │  │                         │  │    │
│  │  └─────────────────┘  └─────────────────┘  └─────────────────────────┘  │    │
│  │                                                                         │    │
│  │  [Save Theme]        [Reset]        [Preview in Real-time]              │    │
│  └─────────────────────────────────────────────────────────────────────────┘    │
│                                                                                 │
└─────────────────────────────────────────────────────────────────────────────────┘
                                      │
                               updateTenantTheme()
                                      ▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│                              DATABASE (Prisma)                                  │
│                                                                                 │
│  Tenant {                                                                       │
│    theme: String       // "default" | "zinc" | "blue" | "custom" | ...          │
│    themeConfig: String // JSON: { colors, darkColors, sidebar, radius, font }   │
│  }                                                                              │
│                                                                                 │
└─────────────────────────────────────────────────────────────────────────────────┘
                                      │
                           CSS Variables Injection
                                      ▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│                         SHADCN SIDEBAR LAYOUT                                   │
│                                                                                 │
│  :root {                                                                        │
│    --primary: oklch(0.7 0.15 150);                                              │
│    --primary-foreground: oklch(0.98 0.01 150);                                  │
│    --secondary: oklch(0.95 0.02 150);                                           │
│    --background: oklch(1 0 0);                                                  │
│    /* ... 19 CSS variables total */                                             │
│  }                                                                              │
│                                                                                 │
└─────────────────────────────────────────────────────────────────────────────────┘

Module Structure

app/utils/theme/
├── ThemeConfig.ts         # TypeScript types for theme configuration
├── defaultThemes.ts       # Built-in theme presets
├── cssConfigParser.ts     # Parse CSS string to ThemeConfig
└── cssConfigExporter.ts   # Export ThemeConfig to CSS string

app/stores/
└── themePreviewStore.ts   # Zustand store for real-time preview

app/components/
├── ui/colors/
│   └── ColorPicker.tsx    # HSL/OKLCH color picker component
└── core/tenants/forms/
    └── UpdateTenantFormTheme.tsx  # Main theme settings form

app/routes/app.$tenant/settings/
├── theme.tsx              # Theme settings page
└── settings.tsx           # Settings layout (theme tab)

app/components/layouts/sidebars/shadcn/
└── ShadcnSidebarLayout.tsx  # Injects theme CSS variables

Database Schema

Theme configuration is stored on the Tenant model:

model Tenant {
  id          String   @id @default(cuid())
  // ... other fields

  theme       String   @default("default")  // Preset name or "custom"
  themeConfig String   @default("{}")       // JSON: ThemeConfig
}

ThemeConfig Type

type ThemeConfig = {
  colors: ThemeColors;      // Light mode colors
  darkColors: ThemeColors;  // Dark mode colors
  sidebar: {
    style: "default" | "inset";
    forceDark: boolean;
  };
  radius: string;           // e.g., "0.5rem"
  fontFamily?: string;      // e.g., "Inter, sans-serif"
};

type ThemeColors = {
  primary: string;
  primaryForeground: string;
  secondary: string;
  secondaryForeground: string;
  accent: string;
  accentForeground: string;
  background: string;
  foreground: string;
  card: string;
  cardForeground: string;
  muted: string;
  mutedForeground: string;
  border: string;
  input: string;
  ring: string;
  destructive: string;
  destructiveForeground: string;
  popover: string;
  popoverForeground: string;
};

19 CSS Variables

The theme system uses shadcn/ui CSS variables:

Variable Purpose Example Value
--primary Primary brand color oklch(0.7 0.15 150)
--primary-foreground Text on primary oklch(0.98 0.01 150)
--secondary Secondary color oklch(0.95 0.02 150)
--secondary-foreground Text on secondary oklch(0.2 0.02 150)
--accent Accent highlights oklch(0.95 0.03 150)
--accent-foreground Text on accent oklch(0.2 0.02 150)
--background Page background oklch(1 0 0)
--foreground Main text color oklch(0.15 0.02 150)
--card Card background oklch(1 0 0)
--card-foreground Card text oklch(0.15 0.02 150)
--muted Muted background oklch(0.96 0.01 150)
--muted-foreground Muted text oklch(0.5 0.02 150)
--border Border color oklch(0.9 0.01 150)
--input Input border oklch(0.9 0.01 150)
--ring Focus ring oklch(0.7 0.15 150)
--destructive Error/danger color oklch(0.55 0.2 25)
--destructive-foreground Text on destructive oklch(0.98 0.01 25)
--popover Popover background oklch(1 0 0)
--popover-foreground Popover text oklch(0.15 0.02 150)

Note: Colors use OKLCH format for perceptually uniform color manipulation.


Theme Presets

Built-in presets defined in defaultThemes.ts:

Preset Primary Hue Description
default (Emerald) Green Fresh, nature-inspired default
zinc Gray Neutral, professional
blue Blue Classic, trustworthy
tangerine Orange Warm, energetic
sunset-horizon Sunset gradient Warm sunset tones
catppuccin Purple/Blue Popular dev color scheme

Additional presets available:

  • yellow - Warm gold tones
  • violet - Creative purple
  • violet-bloom - Purple gradient
  • claude - Anthropic styling
  • claymorphism - Soft 3D effect
  • notebook - Paper-like appearance
  • ocean-breeze - Calm blue-green
  • pastel-dreams - Soft pink/purple
  • twitter - X/Twitter style
  • vercel - Minimal black/white
  • supabase - Green accent
  • t3-chat - Pink/magenta
  • amethyst-haze - Purple haze

Theme Settings Page

Route: /app/:tenant/settings/theme

The theme settings page provides:

  1. Preset Selector - Dropdown to choose a built-in theme
  2. Color Pickers - Individual pickers for all 19 colors
  3. Light/Dark Toggle - Edit light mode or dark mode colors
  4. Sidebar Options:
    • Style: "default" or "inset"
    • Force Dark Sidebar checkbox
  5. Import/Export - CSS import/export dialog with Monaco editor
  6. Save/Reset - Persist changes or reset to preset defaults

Real-Time Preview

The themePreviewStore (Zustand) enables live preview:

import { useThemePreviewStore } from "~/stores/themePreviewStore";

// In component
const { previewConfig, setPreviewConfig } = useThemePreviewStore();

// Update preview
setPreviewConfig({
  colors: { primary: newColor, ...rest },
});

Preview applies CSS variables without saving to database until user clicks Save.


CSS Import/Export

Export Theme to CSS

import { exportThemeToCSS } from "~/utils/theme/cssConfigExporter";

const css = exportThemeToCSS(themeConfig);
// Returns CSS string with :root { --primary: ...; }

Import CSS to Theme

import { parseCSSTothemeConfig } from "~/utils/theme/cssConfigParser";

const config = parseCSSTothemeConfig(cssString);
// Returns ThemeConfig object

This enables sharing themes via CSS snippets or importing from other sources.


AI Theme Generation

The chatbot can generate and apply themes via natural language.

Chatbot Tool

Located in app/modules/chatbot/lib/tools-tenant/tools-tenant-theme.server.ts:

// Example prompts:
// "Apply the emerald theme"
// "Create a cyberpunk theme with neon pink and cyan"
// "Make the primary color blue but keep everything else"

How It Works

  1. User requests theme change in chat
  2. AI generates ThemeConfig using OpenAI structured output
  3. Tool validates user has admin permission
  4. Theme is saved to database
  5. onFinish callback applies preview to UI

Permission Check

Only tenant admins can modify themes via chat:

const isAdmin = await getIsSuperUserOrAdminByIds({
  userId: context.user.id,
  tenantId: context.tenant.id,
});
if (!isAdmin) {
  return { success: false, error: "Only admins can update the theme." };
}

CSS Variables Injection

The ShadcnSidebarLayout component injects theme variables:

// In ShadcnSidebarLayout.tsx
const cssVars = useMemo(() => {
  if (!tenant?.themeConfig) return {};
  const config = JSON.parse(tenant.themeConfig) as ThemeConfig;
  return generateCSSVariables(config);
}, [tenant?.themeConfig]);

// Applied to root element
<div style={cssVars}>
  {children}
</div>

Dark mode variables are applied based on user preference via .dark class.


Database Operations

Get Tenant Theme

import { getTenantWithProfile } from "~/utils/db/tenants.db.server";

const tenant = await getTenantWithProfile(tenantId);
const theme = tenant.theme;           // "blue"
const config = JSON.parse(tenant.themeConfig || "{}");

Update Theme

import { updateTenantTheme } from "~/utils/db/tenants.db.server";

await updateTenantTheme(tenantId, {
  theme: "custom",
  themeConfig: JSON.stringify(newConfig),
});

Quick Reference

Adding a New Theme Preset

  1. Edit app/utils/theme/defaultThemes.ts
  2. Add colors and darkColors for the new theme
  3. Add to the preset selector dropdown
export const defaultThemes: Record<string, ThemeConfig> = {
  // Existing themes...
  "my-theme": {
    colors: {
      primary: "oklch(0.6 0.2 280)",
      // ... all 19 colors
    },
    darkColors: {
      primary: "oklch(0.7 0.2 280)",
      // ... all 19 dark colors
    },
    sidebar: { style: "default", forceDark: false },
    radius: "0.5rem",
  },
};

Using Theme in Components

Theme colors are automatically available via Tailwind:

// These use your theme's CSS variables
<div className="bg-primary text-primary-foreground">
  Primary button
</div>
<div className="bg-secondary text-secondary-foreground">
  Secondary element
</div>
<div className="text-muted-foreground">
  Muted text
</div>

Getting Current Theme Programmatically

// In a loader
export async function loader({ params }: Route.LoaderArgs) {
  const tenant = await getTenantWithProfile(params.tenant);
  return {
    theme: tenant.theme,
    themeConfig: JSON.parse(tenant.themeConfig || "{}"),
  };
}

FAQ

Q: What color format should I use? A: OKLCH is recommended for perceptually uniform color adjustments. HSL and hex are also supported.

Q: Can I have different themes per user? A: Currently themes are per-tenant. User-specific themes would require storing preferences on the User model.

Q: How do I add custom CSS beyond colors? A: Use the fontFamily and radius properties in ThemeConfig, or extend the CSS injection in ShadcnSidebarLayout.

Q: Does the theme apply to public pages? A: The theme applies to authenticated tenant pages. Public pages use the default theme unless explicitly configured.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment