Theming

Runtime appearance personalization — light/dark mode, accent color palettes, corner radius, and persistent user preferences

Theming

The theming system gives end users complete control over the visual presentation of the application. Visitors can customize interface appearance modes, select from curated OKLCH accent color palettes, and fine-tune corner curvature across every component in the interface.

All user preferences are automatically persisted in localStorage, applied instantly without flash of unstyled content (FOUC), and synchronized smoothly across tabs and page navigations.


End-User Experience

The theme engine is designed around visual ergonomics, user comfort, and accessibility:

1. Appearance Modes (Light, Dark, System)

Users can select how the interface adapts to their environment:

  • Light Mode — Crisp, high-contrast daylight surfaces with clean borders.
  • Dark Mode — Deep, eye-friendly neutral dark interface (#0a0a0a) designed for low-light environments.
  • System Mode — Dynamically synchronizes with the user's OS or browser setting (prefers-color-scheme). If the user's operating system switches from day to night mode on a schedule, the app updates automatically.
  • Flicker-Free Transitions — Theme switches instantly snap without cross-fade color smears or animation lag (disableTransitionOnChange).

2. Accent Color Palettes

Users can choose an accent color that transforms primary action buttons, active tabs, focus rings, badges, checkboxes, links, and selected states. All nine presets are formulated in the OKLCH color space with bespoke light and dark values ensuring WCAG AA contrast compliance:

PaletteTone & CharacterLight PrimaryDark Primary
Red (Default)Bold, powerful high-contrast scarletoklch(0.55 0.22 27)oklch(0.68 0.22 27)
EmeraldFresh, vibrant brand greenoklch(0.55 0.18 155)oklch(0.696 0.17 162.48)
ZincMinimalist, monochrome neutraloklch(0.205 0 0)oklch(0.922 0 0)
VioletModern, creative deep purpleoklch(0.55 0.22 285)oklch(0.68 0.22 285)
BlueClassic, trusted enterprise blueoklch(0.55 0.20 250)oklch(0.68 0.20 250)
RoseBold, warm, expressive crimsonoklch(0.55 0.24 15)oklch(0.68 0.24 15)
OrangeEnergetic, punchy warm amberoklch(0.62 0.20 45)oklch(0.72 0.20 45)
TealCrisp, balanced cyan-tealoklch(0.56 0.16 200)oklch(0.72 0.16 200)
YellowWarm, radiant, high-visibility goldoklch(0.65 0.18 85)oklch(0.78 0.18 85)

3. Corner Radius Scale

Users can adjust the structural curvature of interactive elements (cards, buttons, inputs, dialogs, dropdowns, and badges). Six granular values range from razor-sharp to friendly and rounded:

Radius LevelCSS ValueVisual Feel
00remSharp, technical, high-density aesthetic
0.30.3remSubtle, compact rounding for dense dashboards
0.50.5remClean, modern soft corners
0.625 (Default)0.625remCelestia default balanced curvature
0.750.75remSmooth, friendly rounded edges
1.01.0remMaximum curvature, pill-like contours

4. Floating Theme Customizer Widget

A persistent, floating trigger button is docked in the bottom-right corner of the viewport on all pages. When clicked, it opens a quick-tuning popover:

  • Live Palette Swatches: Click any color swatch to preview the accent in real-time.
  • Mode Segmented Control: Switch between Light, Dark, and System with one click.
  • Radius Stepper: Select between the 6 corner radius presets.
  • One-Click Reset: Click "Reset" to return immediately to default factory settings (System mode, Red accent, 0.625rem radius).
  • Toast Feedback: Every adjustment confirms the active selection via an unobtrusive toast notification.

5. Quick Keyboard Shortcut (D)

Users can toggle between Light and Dark mode anywhere in the application simply by pressing the D key:

  • Automatically ignored when typing inside <input>, <textarea>, <select>, or content-editable areas.
  • Automatically ignored when modifier keys (Cmd, Ctrl, Alt) are held.
  • Instant, seamless toggle without reloading or scrolling.

6. Persistence & Zero-Flash (FOUC)

  • Preferences are stored in the browser's localStorage under celestia-theme-palette and celestia-theme-radius.
  • A boot synchronization hook re-applies saved overrides immediately upon mount and whenever the resolved mode flips.
  • Server-rendered hydration mismatch is guarded with useSyncExternalStore, preventing white-screen flashing or layout shifts.

User-Facing Components & Recipes

Whether you want to offer the floating customizer or integrate appearance settings directly into a user profile dashboard, Celestia provides ready-to-use building blocks.

Recipe 1: The Floating Customizer

Mount <ThemeCustomizer /> globally in your root layout (app/layout.tsx). It floats above content alongside the back-to-top button:

tsx
import { ThemeCustomizer } from "@/components/shared/theme-customizer"
export default function RootLayout({ children }: { children: React.ReactNode }) {  return (    <html lang="en" suppressHydrationWarning>      <body>        <ThemeProvider>          {children}          <ThemeCustomizer />        </ThemeProvider>      </body>    </html>  )}

Recipe 2: In-App User Appearance Settings Card

For account dashboards or user settings pages (/dashboard/settings), you can present an interactive Appearance card allowing users to configure their workspace:

tsx
"use client"
import {  SunIcon,  MoonIcon,  DesktopIcon,  ArrowsClockwiseIcon,  CheckIcon,} from "@phosphor-icons/react"import {  Button,  Card,  CardHeader,  CardTitle,  CardDescription,  CardContent,  Tabs,  TabsList,  TabsTrigger,  Separator,} from "@celestia-project/ui"import {  useTheme,  useThemeSettings,  PALETTES,  PALETTE_BG_CLASSES,  RADII,} from "@/lib/theme"
export function AppearanceSettings() {  const { theme, setTheme } = useTheme()  const { settings, ready, setPalette, setRadius, reset } = useThemeSettings()
  if (!ready) return null
  return (    <Card className="w-full max-w-2xl">      <CardHeader>        <div className="flex items-center justify-between">          <div>            <CardTitle>Appearance & Theme</CardTitle>            <CardDescription>              Customize how the interface looks and feels on your device.            </CardDescription>          </div>          <Button            variant="outline"            size="sm"            onClick={() => {              reset()              setTheme("system")            }}            className="gap-1.5"          >            <ArrowsClockwiseIcon className="size-3.5" />            <span>Reset to default</span>          </Button>        </div>      </CardHeader>      <CardContent className="space-y-6">        {/* Appearance Mode */}        <div className="space-y-2">          <label className="text-sm font-medium text-foreground">            Interface Mode          </label>          <Tabs            value={theme ?? "system"}            onValueChange={(val) => val && setTheme(val)}          >            <TabsList className="grid grid-cols-3 max-w-sm">              <TabsTrigger value="light" className="gap-2">                <SunIcon className="size-4" />                <span>Light</span>              </TabsTrigger>              <TabsTrigger value="dark" className="gap-2">                <MoonIcon className="size-4" />                <span>Dark</span>              </TabsTrigger>              <TabsTrigger value="system" className="gap-2">                <DesktopIcon className="size-4" />                <span>System</span>              </TabsTrigger>            </TabsList>          </Tabs>        </div>
        <Separator />
        {/* Accent Color Palette */}        <div className="space-y-2">          <label className="text-sm font-medium text-foreground">            Accent Palette          </label>          <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">            {PALETTES.map((palette) => {              const isSelected = settings.paletteId === palette.id              return (                <button                  key={palette.id}                  onClick={() => setPalette(palette.id)}                  className={`flex items-center gap-2.5 rounded-lg border p-2.5 text-sm transition-all text-left cursor-pointer ${                    isSelected                      ? "border-primary bg-primary/10 font-medium text-foreground shadow-xs ring-1 ring-primary/40"                      : "border-border/60 hover:bg-muted/50 text-muted-foreground hover:text-foreground"                  }`}                >                  <span                    className={`size-4 rounded-full shrink-0 shadow-xs ring-1 ring-black/10 dark:ring-white/20 ${                      PALETTE_BG_CLASSES[palette.id] || "bg-primary"                    }`}                  />                  <span>{palette.name}</span>                  {isSelected && (                    <CheckIcon className="size-3.5 text-primary ms-auto shrink-0" weight="bold" />                  )}                </button>              )            })}          </div>        </div>
        <Separator />
        {/* Corner Radius */}        <div className="space-y-2">          <div className="flex items-center justify-between">            <label className="text-sm font-medium text-foreground">              Corner Radius            </label>            <span className="text-xs font-mono text-muted-foreground">              {settings.radius}            </span>          </div>          <Tabs            value={settings.radius}            onValueChange={(val) => val && setRadius(val)}          >            <TabsList className="grid grid-cols-6 max-w-md">              {RADII.map((r) => (                <TabsTrigger key={r.value} value={r.value}>                  {r.label}                </TabsTrigger>              ))}            </TabsList>          </Tabs>        </div>      </CardContent>    </Card>  )}

Recipe 3: Compact Navbar Mode Toggle

A lightweight toggle button suitable for navigation headers or user profile dropdowns:

tsx
"use client"
import { SunIcon, MoonIcon, DesktopIcon } from "@phosphor-icons/react"import { Button } from "@celestia-project/ui"import { useTheme } from "@/lib/theme"
export function HeaderThemeToggle() {  const { theme, setTheme, resolvedTheme } = useTheme()
  const toggle = () => {    if (theme === "system") {      setTheme(resolvedTheme === "dark" ? "light" : "dark")    } else if (theme === "light") {      setTheme("dark")    } else {      setTheme("system")    }  }
  return (    <Button      variant="ghost"      size="icon"      onClick={toggle}      aria-label="Toggle theme appearance"      title={`Theme: ${theme ?? "system"} (click to switch)`}    >      {theme === "system" ? (        <DesktopIcon className="size-4" />      ) : resolvedTheme === "dark" ? (        <MoonIcon className="size-4" />      ) : (        <SunIcon className="size-4" />      )}    </Button>  )}

How Themes Apply to Components

When an end user updates their theme settings, your application UI responds dynamically without requiring component re-renders:

  1. Primary & Foreground:
    • bg-primary maps to the active accent color in the current mode.
    • text-primary-foreground ensures WCAG AA readable text on top of the accent color.
  2. Focus Rings & Highlighting:
    • ring-ring and focus-visible:ring-ring automatically adopt the user's accent color for keyboard focus outlines.
  3. Corner Curvature:
    • Component border radiuses link directly to the user-selected --radius variable. Using Tailwind classes like rounded-md, rounded-lg, or rounded-[var(--radius)] adapts cards, buttons, and inputs seamlessly.

Configuring Defaults for New Users

When a new user visits for the first time before picking custom preferences, the application applies the default settings configured in apps/web/lib/theme/palettes.ts:

ts
// apps/web/lib/theme/palettes.tsexport const DEFAULT_THEME_SETTINGS: ThemeSettings = {  paletteId: "emerald",    // Default accent palette  radius: "0.625rem",      // Default corner radius}

And in apps/web/lib/theme/provider.tsx, the default mode is set to follow the user's operating system:

tsx
<NextThemesProvider  attribute="class"  defaultTheme="system"  enableSystem  disableTransitionOnChange>

Adding Custom Accent Palettes

You can expand the choices available to end users by adding entries to PALETTES in apps/web/lib/theme/palettes.ts. Each palette requires both light and dark OKLCH values and a corresponding Tailwind swatch class:

ts
// apps/web/lib/theme/palettes.ts{  id: "indigo",  name: "Indigo",  colorHex: "#6366f1",  light: {    primary: "oklch(0.51 0.24 275)",    primaryForeground: "oklch(0.985 0 0)",    ring: "oklch(0.51 0.24 275)",  },  dark: {    primary: "oklch(0.65 0.24 275)",    primaryForeground: "oklch(0.145 0 0)",    ring: "oklch(0.65 0.24 275)",  },}

Once added, the new palette immediately appears in the Floating Customizer and any custom appearance settings forms across your app.


Accessibility & Ergonomics Checklist

  • WCAG AA Contrast: Every accent palette is independently tuned for light and dark modes to meet accessibility contrast requirements.
  • Focus Indicators: Focus rings utilize 2px full-strength accent tokens (focus-visible:ring-2 focus-visible:ring-ring) to ensure clear visual focus for keyboard users.
  • Reduced Motion Friendly: disableTransitionOnChange prevents disorienting color flashes when flipping themes.
  • Hotkey Safety: The D shortcut detects and skips active input fields to prevent interrupting user form entry.