Theming
Light and dark modes are driven by a `data-theme` attribute and `--ds-*` tokens. The host app owns the toggle.
How it works
The token CSS defines colors against :root (dark, the default) and :root[data-theme='light']. Components consume those tokens through CSS Modules. Switching themes is just flipping the data-theme attribute on <html> — there is no JS runtime in the design system itself.
The package does not export a ThemeProvider, useTheme, or any FOUC-prevention script. The host app is responsible for:
- Writing
data-theme="light"ordata-theme="dark"on<html>before first paint. - Toggling that attribute when the user changes themes.
- (Optionally) persisting the preference and reacting to
prefers-color-scheme.
This keeps the package free of framework assumptions — any theming library, or none at all, works.
Recommended setup with next-themes
For Next.js apps, next-themes handles the data-theme attribute, system-preference resolution, and the FOUC-prevention script for you.
// app/layout.tsx
import { ThemeProvider } from 'next-themes';
import '@onersoft/design-system/tokens.css';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="data-theme" defaultTheme="system">
{children}
</ThemeProvider>
</body>
</html>
);
}The key piece is attribute="data-theme" — without it, next-themes writes class="dark" instead, which the design system's tokens don't read.
To toggle themes:
import { useTheme } from 'next-themes';
function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}>
{resolvedTheme} mode
</button>
);
}Manual setup
Without a theming library, ensure <html> has data-theme set before the first paint. A small inline script in <head> does this:
<script>
(function () {
try {
var stored = localStorage.getItem('theme');
var prefersDark = matchMedia('(prefers-color-scheme: dark)').matches;
var theme = stored ?? (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch (_) {}
})();
</script>Without this, the page paints in the default (dark) theme until your React tree hydrates.
Overriding tokens
Tokens live under --ds-* and can be overridden anywhere downstream — globally, per-page, or scoped to a subtree.
:root {
--ds-font-sans: 'Pretendard Variable', system-ui, sans-serif;
--ds-radius-md: 0.5rem;
}For brand colors, override the raw color tokens instead of the --ds-color-* aliases — the aliases are derived from raw tokens via var() or color-mix(), so everything downstream stays consistent.
| Raw token | What it drives |
|---|---|
--color-brand-primary | accent and all hover/pressed/disabled/soft variants, border-accent, ring-color |
--color-brand-primary-muted | accent-muted |
--color-surface-base | every surface tier (surface/elevated/overlay/sunken), surface-hover/pressed, elevated-hover, ring-offset, fg-subtle |
--color-text-base | body text (fg-default), border-default/strong |
--color-text-muted | secondary text (fg-muted, fg-subtle) |
--color-accent-fg | text and icons rendered on top of accent surfaces |
:root {
--color-brand-primary: oklch(0.74 0.08 200);
--color-surface-base: oklch(0.18 0.012 220);
}
:root[data-theme='light'] {
--color-brand-primary: oklch(0.52 0.09 200);
--color-surface-base: oklch(0.97 0.006 220);
}--color-text-base and --color-accent-fg are inverse-luminance pairs (text on surface vs text on accent). They are not derived from each other, so override them together when retheming if you change the surface or accent lightness.
Cascade layer
All design system CSS (tokens and components) lives inside @layer onersoft.ds. Any unlayered CSS in the host app automatically wins, with no specificity battles. To order the design system relative to your own layered CSS, declare the order in your entry stylesheet:
@layer onersoft.ds, app;Rules in your app layer now win over the design system, while still losing to anything truly unlayered.