type Mode = "light" | "dark";
interface Swatch {
background: string;
text: string;
accent: string;
}
const PRESETS = {
dark: {
label: "Default Dark",
mode: "dark",
swatch: { background: "#141414", text: "#fafafa", accent: "#6a63f0" }
},
light: {
label: "Default Light",
mode: "light",
swatch: { background: "#ffffff", text: "#0a0a0a", accent: "#5750f1" }
},
"classic-light": {
label: "Classic Light",
mode: "light",
swatch: { background: "#ffffff", text: "#111827", accent: "#5750f1" }
},
"classic-dark": {
label: "Classic Dark",
mode: "dark",
swatch: { background: "#1f2937", text: "#f3f4f6", accent: "#6a63f0" }
},
ash: {
label: "Ash",
mode: "dark",
swatch: { background: "#313338", text: "#eceef0", accent: "#5865f2" }
},
snow: {
label: "Snow",
mode: "light",
swatch: { background: "#ffffff", text: "#282a30", accent: "#5e6ad2" }
}
} as const satisfies Record<string, { label: string; mode: Mode; swatch: Swatch }>;
export type Preset = keyof typeof PRESETS;
export type Theme = "auto" | Preset;
export const PRESET_NAMES = Object.keys(PRESETS) as Preset[];
export const THEMES: Theme[] = ["auto", ...PRESET_NAMES];
const COOKIE = "theme";
const YEAR = 60 * 60 * 24 * 365;
export const themeLabel = (theme: Theme): string =>
theme === "auto" ? "System" : PRESETS[theme].label;
export const themeSwatch = (theme: Theme, systemDark: boolean): Swatch =>
PRESETS[theme === "auto" ? (systemDark ? "dark" : "light") : theme].swatch;
export const parseTheme = (raw: string | null | undefined): Theme =>
raw && Object.hasOwn(PRESETS, raw) ? (raw as Preset) : "auto";
// the server reads the raw header so the hook works on any request shape
export const themeFromCookies = (header: string | null): Theme => {
for (const part of header?.split(";") ?? []) {
const [name, value] = part.trim().split("=");
if (name === COOKIE) return parseTheme(value);
}
return "auto";
};
const themeMode = (theme: Theme): Mode | undefined =>
theme === "auto" ? undefined : PRESETS[theme].mode;
const themePalette = (theme: Theme): Preset | undefined =>
theme === "auto" || theme === themeMode(theme) ? undefined : theme;
// auto has no data-theme, so the dark variant uses prefers-color-scheme
export const themeAttribute = (theme: Theme): string => {
const mode = themeMode(theme);
const palette = themePalette(theme);
return (mode ? ` data-theme="${mode}"` : "") + (palette ? ` data-palette="${palette}"` : "");
};
export const readTheme = (): Theme => themeFromCookies(document.cookie);
export const applyTheme = (theme: Theme): void => {
const root = document.documentElement;
const mode = themeMode(theme);
const palette = themePalette(theme);
if (mode) root.dataset.theme = mode;
else root.removeAttribute("data-theme");
if (palette) root.dataset.palette = palette;
else root.removeAttribute("data-palette");
document.cookie =
theme === "auto"
? `${COOKIE}=; path=/; max-age=0; samesite=lax`
: `${COOKIE}=${theme}; path=/; max-age=${YEAR}; samesite=lax`;
};