skills-ws
Version:
87 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.
97 lines (78 loc) • 3.92 kB
Markdown
## Contents
- 6. Theming (Dark Mode)
- CSS Variables Approach
- No-FOUC theme script (run before paint)
- Theme Toggle Component (SSR-safe, accessible)
## 6. Theming (Dark Mode)
### CSS Variables Approach
Override the same token names under a `.dark` selector. On Tailwind v4 this lives in plain CSS *after* the `@theme` block (it re-points the existing `--color-*` vars); on v3 it's a separate `tokens/dark.css`.
```css
/* Dark theme — overrides the --color-* tokens defined in @theme / :root */
.dark, [data-theme="dark"] {
--color-primary: oklch(0.707 0.165 254.6); /* ≈ #60a5fa */
--color-primary-hover: oklch(0.789 0.135 255.5);
--color-primary-foreground: oklch(0.208 0.042 265.8);
--color-background: oklch(0.208 0.042 265.8); /* ≈ #0f172a */
--color-foreground: oklch(0.984 0.003 247.9);
--color-muted: oklch(0.279 0.041 260);
--color-muted-foreground: oklch(0.704 0.04 256.8);
--color-border: oklch(0.372 0.044 257.3);
}
/* Tell the UA to render native form controls / scrollbars dark too. */
.dark { color-scheme: dark; }
```
### No-FOUC theme script (run before paint)
Reading `localStorage` in `useEffect` flashes the wrong theme on first paint (the class is applied after hydration). Set the class **synchronously** before React mounts via a blocking inline script in `<head>`. In Next.js App Router this goes in the root `layout.tsx`; with a CMS/HTML it's a `<script>` in `<head>`.
```tsx
// app/layout.tsx — runs before first paint, no flash, no hydration mismatch
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem('theme');
var d=t?t==='dark':matchMedia('(prefers-color-scheme:dark)').matches;
document.documentElement.classList.toggle('dark',d);
document.documentElement.style.colorScheme=d?'dark':'light';}catch(e){}})();`,
}}
/>
```
> In real projects prefer `next-themes` (`<ThemeProvider attribute="class">`), which ships this exact script and a `useTheme()` hook. The component below shows the mechanics for a standalone library.
### Theme Toggle Component (SSR-safe, accessible)
Use real icon components (e.g. `lucide-react`), an accessible name, and `aria-pressed` for the toggle state. Guard against hydration mismatch with a `mounted` flag so SSR and the first client render agree, then reveal the resolved state.
```tsx
'use client';
import { useEffect, useState } from 'react';
import { Moon, Sun } from 'lucide-react';
export function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [mounted, setMounted] = useState(false);
useEffect(() => {
// The no-FOUC script already set the <html> class; read it back as truth.
setTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light');
setMounted(true);
}, []);
function toggle() {
const next = theme === 'light' ? 'dark' : 'light';
setTheme(next);
localStorage.setItem('theme', next);
document.documentElement.classList.toggle('dark', next === 'dark');
document.documentElement.style.colorScheme = next;
}
const isDark = theme === 'dark';
return (
<button
type="button"
onClick={toggle}
// Stable, non-flipping label so the accessible name doesn't jump pre-hydration.
aria-label="Toggle dark mode"
aria-pressed={mounted ? isDark : undefined}
className="inline-flex h-10 w-10 items-center justify-center rounded-md hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{/* suppressHydrationWarning: the icon legitimately differs from the SSR default */}
<span suppressHydrationWarning>
{isDark ? <Moon className="h-5 w-5" aria-hidden="true" /> : <Sun className="h-5 w-5" aria-hidden="true" />}
</span>
<span className="sr-only">{isDark ? 'Dark mode on' : 'Light mode on'}</span>
</button>
);
}
```
---