Skip to content

Theming and Styling

import { ButtonBase, CardBase, resolveTokens } from "@lastshotlabs/snapshot/ui";
const snapshotCss = resolveTokens({
flavor: "neutral",
overrides: {
colors: {
primary: "#3b82f6",
accent: "#8b5cf6",
},
radius: "md",
font: { sans: "Inter" },
},
});
// Inject <style>{snapshotCss}</style> once near the app root.
<ButtonBase label="Themed button" variant="default" />
// Customize individual components with slots
<CardBase
title="Custom Card"
slots={{
root: { className: "shadow-lg", style: { borderRadius: "1rem" } },
title: { style: { fontSize: "1.5rem", fontWeight: "bold" } },
}}
>
Content
</CardBase>

Tokens are resolved from code with resolveTokens() and compiled to CSS custom properties on :root and .dark.

resolveTokens({
overrides: {
colors: {
primary: "#3b82f6", // --sn-color-primary
accent: "#8b5cf6", // --sn-color-accent
background: "#ffffff", // --sn-color-background
surface: "#f8fafc", // --sn-color-surface
muted: "#94a3b8", // --sn-color-muted
border: "#e2e8f0", // --sn-color-border
success: "#10b981", // --sn-color-success
warning: "#f59e0b", // --sn-color-warning
error: "#ef4444", // --sn-color-error
},
},
});

Each color automatically generates a foreground pair (--sn-color-primary-foreground) computed for contrast.

Spacing tokens are used by gap, padding, and spacing props throughout all components:

TokenCSS VariableDefault
2xs--sn-spacing-2xs0.25rem
xs--sn-spacing-xs0.5rem
sm--sn-spacing-sm0.75rem
md--sn-spacing-md1rem
lg--sn-spacing-lg1.5rem
xl--sn-spacing-xl2rem
2xl--sn-spacing-2xl3rem
resolveTokens({
overrides: {
radius: "md",
},
});
resolveTokens({
overrides: {
font: {
sans: "Inter", // --sn-font-sans
mono: "JetBrains Mono", // --sn-font-mono
display: { family: "Outfit", weights: [400, 700] }, // --sn-font-display
},
},
});

Google Fonts are auto-loaded when recognized (Inter, Roboto, Open Sans, Poppins, Montserrat, etc.).

Every standalone component accepts a slots prop for targeting sub-elements.

<ButtonBase
label="Save"
variant="default"
slots={{
root: { className: "my-button", style: { minWidth: "120px" } },
label: { style: { fontWeight: "bold" } },
icon: { style: { color: "green" } },
}}
/>

Most components share these patterns:

SlotTarget
rootOutermost wrapper element
labelText label element
iconIcon element
titleTitle text
subtitleSubtitle text
contentMain content area
headerHeader region
footerFooter region

Each component documents its available slot names in its props interface.

When multiple style sources apply, they merge in this order (later wins):

  1. Implementation base styles (component defaults)
  2. Component surface config (from surfaceConfig)
  3. Item surface config (from slots)
  4. Active states (hover, focus, open, etc.)

Both className and style can be set on any slot:

<DataTableBase
columns={columns}
rows={data}
slots={{
root: { className: "my-table" },
header: { style: { backgroundColor: "#f0f0f0", fontWeight: "bold" } },
row: { style: { borderBottom: "1px solid #e0e0e0" } },
}}
/>
const { theme, set, toggle } = snap.useTheme();
<ButtonBase
label={theme === "dark" ? "Light mode" : "Dark mode"}
icon={theme === "dark" ? "sun" : "moon"}
variant="ghost"
onClick={toggle}
/>

useTheme adds/removes the .dark class on <html>. All token CSS variables have dark-mode overrides that activate automatically.

Persisted dark mode with system preference

Section titled “Persisted dark mode with system preference”

Persist the user’s choice to localStorage and respect the system preference on first visit:

import { useEffect, useState } from "react";
function ThemeProvider({ children }: { children: React.ReactNode }) {
const { theme, set } = snap.useTheme();
// On mount: restore from localStorage, or fall back to system preference
useEffect(() => {
const stored = localStorage.getItem("sn-theme");
if (stored === "dark" || stored === "light") {
set(stored);
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
set("dark");
}
}, []);
// Persist changes
const setAndPersist = (mode: "light" | "dark") => {
set(mode);
localStorage.setItem("sn-theme", mode);
};
return <>{children}</>;
}
// A toggle button that persists the choice
function ThemeToggle() {
const { theme, set } = snap.useTheme();
const toggle = () => {
const next = theme === "dark" ? "light" : "dark";
set(next);
localStorage.setItem("sn-theme", next);
};
return (
<ButtonBase
label={theme === "dark" ? "Light mode" : "Dark mode"}
icon={theme === "dark" ? "sun" : "moon"}
variant="ghost"
onClick={toggle}
/>
);
}

To also listen for system preference changes (e.g. the user changes OS settings while your app is open):

useEffect(() => {
const mql = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => {
// Only auto-switch if no manual preference is stored
if (!localStorage.getItem("sn-theme")) {
set(e.matches ? "dark" : "light");
}
};
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, []);

Colors set in overrides.colors automatically derive dark variants using OKLCH color space. You can also set explicit dark overrides:

resolveTokens({
overrides: {
colors: {
background: "#ffffff",
card: "#f8fafc",
},
darkColors: {
background: "#0f172a",
card: "#1e293b",
},
},
});

Every component accepts className and style on the root element:

<CardBase
className="dashboard-card"
style={{ maxWidth: "400px", margin: "0 auto" }}
title="Revenue"
>
<StatCardBase value="$48K" label="Total" />
</CardBase>

Use CSS media queries via className or inline styles:

<GridBase
columns="repeat(auto-fill, minmax(300px, 1fr))"
gap="md"
className="responsive-grid"
>
{items.map((item) => <CardBase key={item.id} title={item.name} />)}
</GridBase>