Theming StickerNest with CSS tokens and design variables...
This skill covers the theming system using CSS custom properties (tokens), enabling consistent styling across the app and widgets with full customization support.
"Everything is themeable. The canvas, components, and widgets all inherit from a unified token system."
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Theme Provider ā
ā (sets :root variables) ā
āāāāāāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā
āāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāā
ā¼ ā¼ ā¼
āāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāā
ā App Shell ā ā Components ā ā Widgets ā
ā (inherits) ā ā (inherits) ā ā (inherits) ā
āāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāā
:root {
/* Base colors */
--color-bg-primary: #0a0a0f;
--color-bg-secondary: #12121a;
--color-bg-tertiary: #1a1a24;
/* Surface colors (panels, cards) */
--color-surface: #16161f;
--color-surface-hover: #1e1e28;
--color-surface-active: #252530;
/* Text colors */
--color-text-primary: #ffffff;
--color-text-secondary: #a1a1aa;
--color-text-muted: #71717a;
--color-text-disabled: #52525b;
/* Accent colors */
--color-accent: #6366f1;
--color-accent-hover: #818cf8;
--color-accent-muted: rgba(99, 102, 241, 0.2);
/* Semantic colors */
--color-success: #22c55e;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-info: #3b82f6;
/* Border colors */
--color-border: #27272a;
--color-border-hover: #3f3f46;
--color-border-focus: var(--color-accent);
}
:root {
/* Font families */
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
/* Font sizes */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
/* Font weights */
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
/* Line heights */
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.75;
}
:root {
--space-0: 0;
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
}
:root {
/* Border radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.6);
/* Transitions */
--transition-fast: 100ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
/* Z-index layers */
--z-base: 0;
--z-widget: 10;
--z-dropdown: 100;
--z-modal: 200;
--z-tooltip: 300;
--z-toast: 400;
}
:root {
/* Widget container */
--widget-bg: var(--color-surface);
--widget-border: var(--color-border);
--widget-radius: var(--radius-lg);
--widget-shadow: var(--shadow-md);
/* Widget text */
--widget-text: var(--color-text-primary);
--widget-text-muted: var(--color-text-muted);
/* Widget interactive */
--widget-accent: var(--color-accent);
--widget-item-bg: var(--color-bg-tertiary);
--widget-item-hover: var(--color-surface-hover);
/* Widget header */
--widget-header-bg: var(--color-bg-secondary);
--widget-header-height: 32px;
}
:root {
/* Social feed */
--social-feed-bg: var(--widget-bg);
--social-feed-item-bg: var(--widget-item-bg);
--social-feed-item-hover: var(--widget-item-hover);
/* Avatars */
--social-avatar-size: 40px;
--social-avatar-size-sm: 32px;
--social-avatar-size-lg: 56px;
--social-avatar-border: 2px solid var(--color-accent);
/* Status indicators */
--social-online-color: #22c55e;
--social-offline-color: #6b7280;
--social-away-color: #f59e0b;
--social-busy-color: #ef4444;
/* Chat */
--social-chat-bubble-self: var(--color-accent);
--social-chat-bubble-other: var(--color-surface-hover);
--social-chat-text-self: #ffffff;
--social-chat-text-other: var(--color-text-primary);
/* Notifications */
--social-notification-unread-bg: rgba(99, 102, 241, 0.1);
--social-notification-badge: var(--color-error);
/* Activity */
--social-verb-color: var(--color-accent);
--social-timestamp-color: var(--color-text-muted);
}
// src/themes/default.ts
export const defaultTheme = {
id: 'default',
name: 'Default',
tokens: {
'color-bg-primary': '#0a0a0f',
'color-bg-secondary': '#12121a',
'color-surface': '#16161f',
'color-text-primary': '#ffffff',
'color-accent': '#6366f1',
// ... all tokens
},
};
export const cyberpunkTheme = {
id: 'cyberpunk',
name: 'Cyberpunk',
tokens: {
'color-bg-primary': '#0d0d0d',
'color-bg-secondary': '#1a0a1a',
'color-surface': '#1f0f1f',
'color-text-primary': '#00ff88',
'color-accent': '#ff00ff',
'color-accent-hover': '#ff66ff',
'social-online-color': '#00ff88',
// ... cyberpunk-specific overrides
},
};
export const cozyTheme = {
id: 'cozy',
name: 'Cozy',
tokens: {
'color-bg-primary': '#faf5f0',
'color-bg-secondary': '#f5ede4',
'color-surface': '#ffffff',
'color-text-primary': '#2d2a26',
'color-accent': '#c9a87c',
// ... warm, light tokens
},
};
// src/state/useThemeStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { defaultTheme, cyberpunkTheme, cozyTheme } from '@/themes';
interface ThemeState {
currentThemeId: string;
customTokens: Record<string, string>;
}
interface ThemeActions {
setTheme: (themeId: string) => void;
setCustomToken: (key: string, value: string) => void;
resetCustomTokens: () => void;
getEffectiveTokens: () => Record<string, string>;
}
const themes = {
default: defaultTheme,
cyberpunk: cyberpunkTheme,
cozy: cozyTheme,
};
export const useThemeStore = create<ThemeState & ThemeActions>()(
persist(
(set, get) => ({
currentThemeId: 'default',
customTokens: {},
setTheme: (themeId) => {
set({ currentThemeId: themeId });
get().applyTheme();
},
setCustomToken: (key, value) => {
set((state) => ({
customTokens: { ...state.customTokens, [key]: value },
}));
get().applyTheme();
},
resetCustomTokens: () => {
set({ customTokens: {} });
get().applyTheme();
},
getEffectiveTokens: () => {
const theme = themes[get().currentThemeId] || defaultTheme;
return { ...theme.tokens, ...get().customTokens };
},
applyTheme: () => {
const tokens = get().getEffectiveTokens();
const root = document.documentElement;
Object.entries(tokens).forEach(([key, value]) => {
root.style.setProperty(`--${key}`, value);
});
},
}),
{
name: 'theme-store',
partialize: (state) => ({
currentThemeId: state.currentThemeId,
customTokens: state.customTokens,
}),
}
)
);
// src/components/ThemeProvider.tsx
import { useEffect } from 'react';
import { useThemeStore } from '@/state/useThemeStore';
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const applyTheme = useThemeStore((state) => state.applyTheme);
useEffect(() => {
// Apply theme on mount
applyTheme();
}, [applyTheme]);
return <>{children}</>;
}
// src/runtime/WidgetHost.ts
class WidgetHost {
private sendThemeTokens() {
const tokens = useThemeStore.getState().getEffectiveTokens();
// Filter to widget-relevant tokens
const widgetTokens = Object.fromEntries(
Object.entries(tokens).filter(([key]) =>
key.startsWith('widget-') ||
key.startsWith('social-') ||
key.startsWith('color-')
)
);
this.iframe.contentWindow?.postMessage({
type: 'widget:theme',
payload: widgetTokens,
}, '*');
}
// Subscribe to theme changes
private setupThemeSync() {
useThemeStore.subscribe(() => {
this.sendThemeTokens();
});
}
}
<!-- In widget HTML -->
<style>
:root {
/* Fallback values if host doesn't send tokens */
--widget-bg: #1a1a2e;
--widget-text: #ffffff;
--widget-accent: #6366f1;
}
body {
background: var(--widget-bg);
color: var(--widget-text);
}
.button {
background: var(--widget-accent);
}
</style>
<script>
// Apply tokens from host
window.addEventListener('message', (event) => {
if (event.data?.type === 'widget:theme') {
const tokens = event.data.payload;
const root = document.documentElement;
Object.entries(tokens).forEach(([key, value]) => {
root.style.setProperty(`--${key}`, value);
});
}
});
</script>
// Use CSS variables directly
const Button = styled.button`
background: var(--color-accent);
color: var(--color-text-primary);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
transition: background var(--transition-fast);
&:hover {
background: var(--color-accent-hover);
}
`;
// Or with inline styles
function Button({ children }) {
return (
<button
style={{
background: 'var(--color-accent)',
color: 'var(--color-text-primary)',
padding: 'var(--space-2) var(--space-4)',
borderRadius: 'var(--radius-md)',
}}
>
{children}
</button>
);
}
// src/hooks/useTheme.ts
export function useTheme() {
const { currentThemeId, setTheme, setCustomToken, getEffectiveTokens } = useThemeStore();
const getToken = (key: string): string => {
return getComputedStyle(document.documentElement)
.getPropertyValue(`--${key}`)
.trim();
};
const isDark = () => {
const bg = getToken('color-bg-primary');
// Simple darkness check
return bg.startsWith('#0') || bg.startsWith('#1') || bg.startsWith('#2');
};
return {
themeId: currentThemeId,
setTheme,
setToken: setCustomToken,
getToken,
isDark: isDark(),
tokens: getEffectiveTokens(),
};
}
// Each canvas can have its own theme overrides
interface Canvas {
id: string;
name: string;
themeOverrides?: Record<string, string>;
}
// Apply when loading canvas
function loadCanvas(canvas: Canvas) {
if (canvas.themeOverrides) {
Object.entries(canvas.themeOverrides).forEach(([key, value]) => {
document.documentElement.style.setProperty(`--${key}`, value);
});
}
}
// src/components/ThemeEditor.tsx
function ThemeEditor() {
const { tokens, setToken, themeId, setTheme } = useTheme();
return (
<div className="theme-editor">
<select value={themeId} onChange={(e) => setTheme(e.target.value)}>
<option value="default">Default</option>
<option value="cyberpunk">Cyberpunk</option>
<option value="cozy">Cozy</option>
</select>
<div className="token-editors">
<ColorPicker
label="Accent Color"
value={tokens['color-accent']}
onChange={(v) => setToken('color-accent', v)}
/>
<ColorPicker
label="Background"
value={tokens['color-bg-primary']}
onChange={(v) => setToken('color-bg-primary', v)}
/>
{/* More token editors */}
</div>
</div>
);
}
| File | Purpose |
|---|---|
src/themes/ |
Theme definitions |
src/state/useThemeStore.ts |
Theme state management |
src/components/ThemeProvider.tsx |
Theme application |
src/hooks/useTheme.ts |
Theme access hook |
src/styles/tokens.css |
Token definitions |
--color-accent not --purple