TuvixRSS features a sophisticated, extensible theming system built with TypeScript, React Context, CSS Variables, and Tailwind CSS v4. The system supports multiple pre-built themes with full customization of colors, fonts, border radius, and visual effects.
- Architecture
- Core Concepts
- Available Themes
- Using Themes
- Adding New Themes
- Advanced Customization
- API Reference
-
Theme Provider (
packages/app/src/components/provider/theme-provider.tsx)- Manages theme state and persistence
- Applies CSS variables dynamically
- Handles system theme detection
-
Theme Registry (
packages/app/src/lib/themes/themes.ts)- Central registry of all available themes
- Utility functions for theme access
-
Theme Types (
packages/app/src/lib/themes/types.ts)- TypeScript interfaces and types
- Ensures type safety across the system
-
Global Styles (
packages/app/src/index.css)- CSS variable definitions
- Tailwind CSS v4 configuration
- Theme-specific overrides
packages/app/src/lib/themes/
├── types.ts # Type definitions
├── themes.ts # Theme registry and utilities
├── light.ts # Light theme
├── dark.ts # Dark theme
├── nord.ts # Nord theme
├── material.ts # Material Design theme
├── material.css # Material theme CSS overrides
├── minimal.ts # Minimal theme
├── minimal.css # Minimal theme CSS overrides
├── hackernews.ts # Hacker News theme
├── hackernews.css # Hacker News theme CSS overrides
├── win95.ts # Windows 95 theme
├── win95.css # Windows 95 theme CSS overrides
└── README.md # Theme development guide
Every theme implements the ThemeConfig interface:
interface ThemeConfig {
id: string; // Unique identifier
name: string; // Display name
description?: string; // Optional description
colors: ColorPalette; // Color definitions
fonts: FontConfig; // Font stacks
radius: BorderRadiusConfig; // Border radius values
grain: GrainConfig; // Grain overlay effect
}The color system uses OKLCH (OK Lightness Chroma Hue) color space for perceptually uniform colors:
interface ColorPalette {
// Base colors
background: string;
foreground: string;
// Card colors
card: string;
cardForeground: string;
// Semantic colors
primary: string;
primaryForeground: string;
secondary: string;
secondaryForeground: string;
muted: string;
mutedForeground: string;
accent: string;
accentForeground: string;
destructive: string;
// UI elements
border: string;
input: string;
ring: string;
// Chart colors
chart1-5: string;
// Sidebar colors
sidebar: string;
sidebarForeground: string;
// ... more sidebar colors
// Logo colors
logoPrimary: string;
logoSecondary: string;
}Format: oklch(lightness chroma hue)
- Lightness: 0-1 (0=black, 1=white)
- Chroma: 0-0.4+ (0=grayscale, higher=saturated)
- Hue: 0-360 (color wheel degrees)
Example: oklch(0.5 0.2 250) = medium blue
Benefits:
- Perceptually uniform color transitions
- Better color manipulation
- Wide gamut support
- Human-friendly parameters
All theme values are applied as CSS variables on the :root element:
:root {
--background: oklch(0.98 0 0);
--foreground: oklch(0.15 0 0);
--primary: oklch(0.4 0.2 250);
--radius: 0.625rem;
--grain-opacity: 0.06;
/* ... more variables */
}- Style: Clean, bright, professional
- Colors: High contrast grayscale with blue accents
- Radius: 0.625rem (10px)
- Grain: 0.06 opacity
- Use Case: Default system theme, daytime viewing
- Style: Low-light optimized
- Colors: Dark backgrounds with light text
- Radius: 0.625rem
- Grain: 0.06 opacity
- Use Case: Night viewing, reduced eye strain
- Style: Arctic, north-bluish developer theme
- Colors: Based on Nord color palette
- Palette: Polar Night, Snow Storm, Frost, Aurora
- Radius: 0.625rem
- Grain: 0.06 opacity
- Use Case: Developer preference, cold aesthetic
- Style: Material Design 3 inspired
- Colors: Tonal color system
- Features: Pill-shaped buttons, no borders
- Radius: 1rem (cards), 9999px (buttons)
- Grain: 0.06 opacity
- Use Case: Modern, elevated UI feel
- Style: Ultra-minimal, print-friendly
- Colors: Pure grayscale, high contrast
- Features: No borders, no radius, no grain
- Radius: 0
- Grain: 0 (disabled)
- Use Case: Distraction-free reading, printing
- Style: Nostalgic, utilitarian
- Colors: Classic HN orange (#ff6600) and beige
- Features: Custom header styling
- Radius: 0.25rem
- Grain: 0.02 opacity (subtle)
- Use Case: Retro aesthetic, HN enthusiasts
- Style: Classic Windows 95 with 3D beveled UI
- Colors: Classic Win95 gray (#c0c0c0) background with blue (#000080) accents
- Features: 3D outset/inset borders, square corners, MS Sans Serif font
- Radius: 0 (square corners)
- Grain: 0 (disabled)
- Use Case: Nostalgic Windows 95 aesthetic, retro computing enthusiasts
- 3D Effects: Buttons use outset borders (raised), inputs use inset borders (sunken), cards use outset borders (window chrome)
- Behavior: Automatically follows OS dark/light mode preference
- Implementation: Uses
prefers-color-schememedia query - Resolves To: Light or Dark theme based on system
export function MyComponent() {
return (
<div className="bg-background text-foreground border border-border rounded-lg">
<h1 className="text-primary font-bold">Title</h1>
<p className="text-muted-foreground">Description</p>
</div>
);
}Common class patterns:
bg-background,text-foregroundbg-card,text-card-foregroundbg-primary,text-primary-foregroundbg-muted,text-muted-foregroundborder-border,ring-ring
export function CustomComponent() {
return (
<div
style={{
backgroundColor: "var(--background)",
color: "var(--foreground)",
borderRadius: "var(--radius)",
}}
>
Custom styled content
</div>
);
}import { useTheme } from "@/components/provider/theme-provider";
import { getTheme } from "@/lib/themes/themes";
export function ThemeAwareComponent() {
const { theme } = useTheme();
const themeConfig = getTheme(theme);
return (
<div
style={{
backgroundColor: themeConfig.colors.background,
borderRadius: themeConfig.radius.value,
}}
>
Theme: {themeConfig.name}
</div>
);
}import { useTheme } from "@/components/provider/theme-provider";
export function ThemeSwitcher() {
const { theme, setTheme } = useTheme();
return (
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="nord">Nord</option>
<option value="material">Material</option>
<option value="minimal">Minimal</option>
<option value="hackernews">Hacker News</option>
<option value="win95">Windows 95</option>
<option value="system">System</option>
</select>
);
}Themes are automatically persisted to:
- localStorage (key:
vite-ui-theme) - Immediate persistence - User settings API - Synced to backend for cross-device support
Create a new file: packages/app/src/lib/themes/my-theme.ts
import type { ThemeConfig } from "./types";
export const myTheme: ThemeConfig = {
id: "mytheme",
name: "My Theme",
description: "A brief description of your theme",
colors: {
// Base colors
background: "oklch(0.95 0.01 220)",
foreground: "oklch(0.2 0.02 220)",
// Card colors
card: "oklch(0.98 0.01 220)",
cardForeground: "oklch(0.2 0.02 220)",
// Popover colors
popover: "oklch(0.98 0.01 220)",
popoverForeground: "oklch(0.2 0.02 220)",
// Primary colors
primary: "oklch(0.5 0.2 250)",
primaryForeground: "oklch(0.98 0.01 220)",
// Secondary colors
secondary: "oklch(0.92 0.02 220)",
secondaryForeground: "oklch(0.2 0.02 220)",
// Muted colors
muted: "oklch(0.92 0.02 220)",
mutedForeground: "oklch(0.45 0.02 220)",
// Accent colors
accent: "oklch(0.92 0.02 220)",
accentForeground: "oklch(0.2 0.02 220)",
// Destructive
destructive: "oklch(0.5 0.2 25)",
// UI elements
border: "oklch(0.88 0.02 220)",
input: "oklch(0.88 0.02 220)",
ring: "oklch(0.5 0.2 250)",
// Chart colors
chart1: "oklch(0.6 0.2 10)",
chart2: "oklch(0.6 0.2 150)",
chart3: "oklch(0.6 0.2 250)",
chart4: "oklch(0.6 0.2 50)",
chart5: "oklch(0.6 0.2 310)",
// Sidebar colors
sidebar: "oklch(0.98 0.01 220)",
sidebarForeground: "oklch(0.2 0.02 220)",
sidebarPrimary: "oklch(0.5 0.2 250)",
sidebarPrimaryForeground: "oklch(0.98 0.01 220)",
sidebarAccent: "oklch(0.92 0.02 220)",
sidebarAccentForeground: "oklch(0.2 0.02 220)",
sidebarBorder: "oklch(0.88 0.02 220)",
sidebarRing: "oklch(0.5 0.2 250)",
// Logo colors
logoPrimary: "oklch(0.5 0.2 250)",
logoSecondary: "oklch(0.4 0.15 280)",
},
fonts: {
sans: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
mono: '"SF Mono", Monaco, "Cascadia Code", monospace',
},
radius: {
value: "0.5rem",
button: "0.5rem", // Optional: custom button radius
},
grain: {
opacity: 0.06, // 0-1, or 0 to disable
},
};Edit packages/app/src/lib/themes/types.ts:
export type ThemeId =
| "light"
| "dark"
| "nord"
| "material"
| "minimal"
| "hackernews"
| "mytheme" // Add your theme ID
| "system";Edit packages/app/src/lib/themes/themes.ts:
import { myTheme } from "./my-theme";
export const themes: Record<ThemeId, ThemeConfig> = {
light: lightTheme,
dark: darkTheme,
nord: nordTheme,
material: materialTheme,
minimal: minimalTheme,
hackernews: hackernewsTheme,
mytheme: myTheme, // Add your theme
system: lightTheme,
};In the same file (themes.ts), add metadata:
export const themeMetadata: Record<ThemeId, ThemeMetadata> = {
// ... existing themes
mytheme: {
id: "mytheme",
name: "My Theme",
description: "A brief description of your theme",
previewColors: {
primary: myTheme.colors.primary,
background: myTheme.colors.background,
accent: myTheme.colors.accent,
},
},
// ... system theme
};Edit packages/app/src/components/provider/theme-provider.tsx:
Find the theme class removal section (around line 163) and add your theme:
document.documentElement.classList.remove(
"light",
"dark",
"nord",
"material",
"minimal",
"hackernews",
"win95",
"mytheme" // Add your theme
);If your theme needs custom CSS that can't be achieved with CSS variables alone, create a CSS file:
Create: packages/app/src/lib/themes/my-theme.css
/* My Theme - custom styling */
.mytheme [data-slot="button"] {
/* Your custom CSS rules */
}Import in: packages/app/src/index.css
/* Import theme-specific CSS overrides */
@import "./lib/themes/my-theme.css";- Run the development server:
pnpm dev - Navigate to Settings → Theme
- Select your new theme from the list
- Verify all colors, borders, and effects work correctly
- Start with Base Colors: Define background and foreground first
- Maintain Contrast: Ensure text is readable (WCAG AA minimum: 4.5:1)
- Use Color Tools:
- OKLCH Color Picker
- Coolors for palette inspiration
- Test Accessibility: Use browser DevTools contrast checker
- Keep Semantic Consistency: Primary should feel primary across themes
- All text is readable
- Buttons have clear hover states
- Borders are visible where expected
- Charts use distinct colors
- Dark/light variants work appropriately
- Sidebar colors are consistent
- Form inputs are clearly defined
- Focus states (ring) are visible
For themes that need custom CSS overrides (beyond what CSS variables can provide), create a separate CSS file co-located with your theme TypeScript file:
Create: packages/app/src/lib/themes/my-theme.css
/* My Theme - custom styling */
.mytheme [data-slot="card"] {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.mytheme [data-slot="button"] {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.mytheme header[data-slot="breadcrumb"] {
background: linear-gradient(to right, var(--primary), var(--accent));
}Import in: packages/app/src/index.css
/* Import theme-specific CSS overrides */
@import "./lib/themes/my-theme.css";The CSS file will be automatically imported and bundled. All CSS rules are scoped by the theme class (e.g., .mytheme), so they only apply when that theme is active.
Add data-slot attributes to components you want to target:
export function Card({ children }: CardProps) {
return (
<div data-slot="card" className="bg-card rounded-xl border">
{children}
</div>
);
}To use custom web fonts:
- Add font files to
packages/app/public/fonts/ - Define
@font-faceinindex.css - Update font stack in your theme config:
fonts: {
sans: '"My Custom Font", system-ui, sans-serif',
mono: '"Custom Mono", "SF Mono", monospace',
}The grain overlay adds texture to the UI:
- Disable: Set
grain.opacity: 0 - Subtle: Use
0.02-0.04 - Medium: Use
0.05-0.08 - Strong: Use
0.09-0.12
Replace grain texture by updating packages/app/public/grain.gif
Customize border radius per component type:
radius: {
value: "0.75rem", // Default radius
button: "9999px", // Pill-shaped buttons
}Access in CSS:
--radius: Base radius--button-radius: Button-specific radius--radius-sm,--radius-md,--radius-lg,--radius-xl: Calculated variants
const { theme, setTheme } = useTheme();Returns:
theme: Current theme ID (string)setTheme: Function to change theme
Get theme configuration by ID. Resolves "system" to light/dark.
import { getTheme } from "@/lib/themes/themes";
const config = getTheme("nord");
console.log(config.colors.primary); // "oklch(0.68 0.1 220)"Get array of selectable theme IDs (excludes "system").
import { getAvailableThemeIds } from "@/lib/themes/themes";
const ids = getAvailableThemeIds();
// ["light", "dark", "nord", "material", "minimal", "hackernews"]Type guard for theme ID validation.
import { isValidThemeId } from "@/lib/themes/themes";
if (isValidThemeId(userInput)) {
setTheme(userInput);
}Get display metadata for a theme.
import { getThemeMetadata } from "@/lib/themes/themes";
const metadata = getThemeMetadata("nord");
console.log(metadata.name); // "Nord"
console.log(metadata.description); // "Arctic, north-bluish color palette"Get metadata for all themes (for UI display).
import { getAllThemeMetadata } from "@/lib/themes/themes";
const allThemes = getAllThemeMetadata();
// Array of theme metadata objectsvar(--background)
var(--foreground)
var(--card)
var(--card-foreground)
var(--primary)
var(--primary-foreground)
var(--secondary)
var(--secondary-foreground)
var(--muted)
var(--muted-foreground)
var(--accent)
var(--accent-foreground)
var(--destructive)
var(--border)
var(--input)
var(--ring)
var(--chart-1) through var(--chart-5)
var(--sidebar-*)
var(--logo-primary)
var(--logo-secondary)var(--radius)
var(--radius-sm)
var(--radius-md)
var(--radius-lg)
var(--radius-xl)
var(--button-radius)var(--font-sans)
var(--font-mono)var(--grain-opacity)
var(--grain-blend-mode)- Use OKLCH: Perceptually uniform, predictable color manipulation
- Use CSS Variables: Never hardcode colors
- Use Semantic Colors:
primary,muted, etc. instead of specific colors - Test All Themes: Verify your component works with all themes
- Maintain Contrast: WCAG AA minimum (4.5:1 for text)
- Use
cn()Utility: For class merging - Use
data-slot: For theme-specific targeting
- Don't Hardcode Colors: Use CSS variables or Tailwind classes
- Don't Use Hex/RGB: OKLCH provides better color space
- Don't Skip Accessibility: Test contrast ratios
- Don't Forget Foreground: Every background needs a foreground color
- Don't Ignore System Theme: Test with OS dark/light mode
- Don't Duplicate Variables: Reuse existing semantic colors
- Check theme ID is registered in
themes.ts - Verify theme class is added to classList in theme-provider.tsx
- Clear localStorage and retry
- Check browser console for errors
- Verify OKLCH syntax:
oklch(L C H)with spaces - Check lightness is 0-1 (not 0-100)
- Verify chroma is appropriate (usually 0-0.3)
- Test in different browsers (OKLCH support varies)
- Check localStorage permissions
- Verify theme-provider is mounted at app root
- Check for JavaScript errors preventing save
- Use browser DevTools contrast checker
- Adjust lightness values
- Test with actual content, not Lorem Ipsum
- Consider colorblind users (use tools like Coblis)
- OKLCH Color Picker
- Nord Theme Documentation
- Material Design 3 Colors
- WCAG Contrast Guidelines
- Tailwind CSS v4 Documentation
See packages/app/src/lib/themes/nord.ts for a well-documented, complete theme implementation.
See packages/app/src/lib/themes/minimal.ts for a theme with special features (no borders, no grain).
See Material theme (material.ts) with its CSS overrides in material.css for flat design customization.
See Windows 95 theme (win95.ts) with its CSS overrides in win95.css for 3D beveled UI effects using border-style: inset/outset.
For additional help, refer to the README in packages/app/src/lib/themes/README.md or open an issue on GitHub.