Skip to main content
Home pageGetting StartedTheming

Theming

Latty's theme engine generates a full color palette from a single base color, giving you consistent light and dark modes without hand-crafting every shade.

Changing the brand color​

Use configure() from @latty-ds/tokens/configure to apply a custom theme. Call it once at your app's entry point, before any components render:

import { configure } from '@latty-ds/tokens/configure';

configure({
colors: { primary: '#6366f1', secondary: '#f59e0b' },
font: { family: 'Inter, sans-serif' },
border: { radius: '0.375rem', width: 'medium' }
});

configure() generates the full token palette from your base colors and injects them as a <style id="lt-tokens"> element into <head>. Calling it again replaces the previous values. All options are optional — omit any key to keep the default.

Readable text on your colors​

You never have to pick label colors. Buttons, badges, alerts, snackbars, and every other filled surface take their text (and their hover and pressed shades) from your palette by contrast: the theme engine tries white, then the color's own lightest tint, then its own deepest shade, then near-black, then pure black, and uses the first that reaches WCAG AA (4.5:1) on the fill, in both the light and dark themes. Pure black or white always reaches 4.5:1 on any fill, so this holds for whatever base color you configure.

In practice a saturated brand color gets white text, while a bright one (yellow, green, orange) gets a dark shade of itself, and its hover and pressed fills move the other way (lighter) so the text keeps its contrast. The result is available as --lt-text-on-<variant> and --lt-interactive-<variant>-bg (with -hover and -active) if you build your own filled surface.

.promo-banner {
background: var(--lt-interactive-primary-bg);
color: var(--lt-text-on-primary); /* readable on any configured primary */
}

Border width​

border.width sets the outline weight of control chrome across the system — outlined buttons, inputs, chips, badges, cards, and popover panels. It accepts three named presets — 'thin' (1px, default), 'medium' (2px), 'thick' (4px) — or any CSS length (e.g. '1.5px'). Separators (dividers, table rules), focus outlines, and checkbox/radio borders keep their fixed widths by design. Chips and badges cap at 'medium' even when the theme requests 'thick' — at their small pill heights, a 4px border reads as compressed rather than bold.

configure({ border: { width: 'thick' } }); // bold, brutalist outlines
configure({ border: { width: '1.5px' } }); // raw CSS lengths pass through

Border contrast​

border.contrast shifts which neutral palette step --lt-border-default/ --lt-border-strong/--lt-border-subtle resolve to, for a bolder, higher-contrast structural border look without touching theme-switching machinery. Accepts 'default' (the default) or 'high' — 'high' moves each token toward a darker step in light mode and a lighter step in dark mode (both directions increase contrast against their respective background):

configure({ border: { contrast: 'high' } });

Loading a font from a CDN​

font.family also accepts a Google Fonts CSS2 stylesheet URL instead of a plain CSS value — configure() @imports it and derives the family name from the URL's family= query param, so there's no separate <link> tag to add:

configure({
font: {
family: 'https://fonts.googleapis.com/css2?family=Hanken+Grotesk:ital,wght@0,100..900;1,100..900&display=swap'
}
});

This works with createStyleSheet() too — the @import is written as the first line of the returned string, so it stays valid CSS once dropped into a <style> tag. Auto-detection only covers fonts.googleapis.com URLs; for other font CDNs, load the stylesheet yourself and pass the resulting family name as a plain font.family value instead.

A bare URL string always falls back to the generic sans-serif family if the font itself fails to load. For a font that isn't sans-serif (a serif display font, a monospace font, etc.), pass a { url, fallback } object instead so the safety-net fallback matches:

configure({
font: {
family: {
url: 'https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700&display=swap',
fallback: 'serif'
}
}
});

fallback accepts any standard CSS generic family: 'sans-serif' (default), 'serif' , 'monospace', 'cursive', 'fantasy', 'system-ui'.

Loading multiple fonts​

font.family also accepts an array of entries — each a plain value, a URL string, or a { url, fallback } object — resolved independently and mapped to its own token in order, rather than being combined into one CSS fallback stack: --lt-typography-fontFamilyPrimary, --lt-typography-fontFamilySecondary, --lt-typography-fontFamilyTertiary, and so on. This lets you use a heading font and a body font (or any other combination) independently in your own CSS:

configure({
font: {
family: [
'https://fonts.googleapis.com/css2?family=Hanken+Grotesk:ital,wght@0,100..900&display=swap',
'Georgia, serif'
]
}
});
h1, h2, h3 {
font-family: var(--lt-typography-fontFamilySecondary);
}

Two-typeface identity (dedicated heading font)​

For the common case of "one font for headings, one for body," font.heading is more direct than routing a family array entry yourself — lt-text's heading/display variants (h1–h6, display-*) read --lt-typography-fontFamilyHeading automatically, falling back to fontFamilyPrimary if unset:

configure({
font: {
family: 'Inter, sans-serif',
heading: { url: 'https://fonts.googleapis.com/css2?family=Playfair+Display&display=swap', fallback: 'serif' }
}
});

No manual CSS selectors needed — every heading automatically picks up the heading font, body text stays on family.

SSR​

For server-side rendering use createStyleSheet(), which returns the CSS string instead of touching the DOM:

import { createStyleSheet } from '@latty-ds/tokens/configure';

const css = createStyleSheet({ colors: { primary: '#6366f1' } });
// inject `css` into a <style> tag in your server-rendered HTML

CSS overrides​

Override individual tokens directly in your stylesheet for one-off adjustments or scoped changes:

:root {
--lt-border-radius: 2px; /* sharper corners globally */
--lt-border-width: 2px; /* heavier control outlines */
--lt-typography-fontFamilyPrimary: 'Inter', sans-serif; /* swap font */
}

/_ Scoped to a section of the page _/
.sidebar {
--lt-color-neutral-50: #f8f8ff;
--lt-color-neutral-100: #f0f0f8;
}

/_ Spacing tokens — only overridable this way, not via configure() _/
:root {
--lt-spacing-4: 1.25rem; /_ default: 1rem _/
}

Component-level overrides​

Every component exposes CSS custom properties — named --lt-[component]-[property] — that let you adapt it to a specific context. Set them on the element or any ancestor:

/* All icon buttons in the header get a light colour */
.site-header {
--lt-icon-button-color: white;
--lt-icon-button-hover-bg: rgba(255, 255, 255, 0.15);
}

/_ A single progress bar with a custom fill colour _/
.upload-progress {
--lt-progress-color: #8b5cf6;
--lt-progress-track-color: #ede9fe;
}

Because CSS custom properties inherit through shadow DOM, this works with a plain class name — no ::part() selector needed. The full list of props for each component is in its API table.

Theme switching​

Pass theme: 'system' to configure() to generate light, dark, and prefers-color-scheme layers in one call:

configure({
theme: 'system',
colors: { primary: '#6366f1' }
});

With that in place, toggling themes at runtime requires no further calls to configure() — just set a data-theme attribute on <html>:

// Follow the OS preference (remove any previous override)
document.documentElement.removeAttribute('data-theme');

// Force light
document.documentElement.setAttribute('data-theme', 'light');

// Force dark
document.documentElement.setAttribute('data-theme', 'dark');

Persisting the user's choice​

function setTheme(theme: 'light' | 'dark' | 'system') {
if (theme === 'system') {
document.documentElement.removeAttribute('data-theme');
localStorage.removeItem('lt-theme');
} else {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('lt-theme', theme);
}
}

Restore the saved theme before first paint by placing a synchronous script at the top of <head>:

<script>
const saved = localStorage.getItem('lt-theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
</script>

Framework notes​

Vanilla JS / Vite / webpack​

Call configure() in your entry file. No other setup is required.

React (CSR)​

Call configure() at the top of main.tsx, before ReactDOM.createRoot:

main.tsx
import { configure } from '@latty-ds/tokens/configure';
configure({ colors: { primary: '#6366f1' }, theme: 'system' });

import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(<App />);

React SSR (Next.js App Router, Remix)​

configure() touches document and must not run on the server. Use createStyleSheet() in your root layout to inject tokens at render time:

app/layout.tsx
import { createStyleSheet } from '@latty-ds/tokens/configure';

export default function RootLayout({ children }) {
const tokenCss = createStyleSheet({ colors: { primary: '#6366f1' }, theme: 'system' });
return (
<html lang="en">
<head>
<style id="lt-tokens" dangerouslySetInnerHTML={{ __html: tokenCss }} />
</head>
<body>{children}</body>
</html>
);
}

Tokens are inlined server-side so there is no flash. Theme toggling with data-theme still works client-side as described above.

Next steps​

Tokens overview — browse all available tokens

Colors — see the full generated palettes