Plan: light and dark mode for the Nuxt 3 app (plain CSS, SSR)
I haven't looked at your code, so file names below are placeholders. I did not run anything.
1. Token approach
- Audit. Grep components for
#[0-9a-fA-F]{3,8},rgb(,hsl(and named colors, including inlinestyleand SVGfill/strokeattributes. Group the values by role. A handful of distinct grays usually collapses into 3–4 surface and text roles. - Define role tokens, never value-named ones, in
assets/css/theme.css. Register it innuxt.config.tsundercss: ['~/assets/css/theme.css']. Role tokens:- Surfaces:
--bg,--surface,--surface-raised,--surface-input. - Text:
--text,--text-secondary,--text-disabled,--text-inverse,--link. - Borders and focus:
--border,--border-strong,--focus-ring. - Brand:
--primary,--primary-hover,--on-primary. - Status:
--success,--warning,--danger,--info, each with an--on-*token. --shadow.
- Surfaces:
- Pick values. Seed from the skill's starter palette, then substitute your brand colors. Set each dark value deliberately, with no
filter: invertand no mechanical lightness flip. Re-tune the status colors per theme. - Migrate components. Replace every hex with
var(--token). No.dark .card {}overrides. Switch SVG icons tocurrentColor.
2. Root and head setup
CSS, using data-theme on <html> as the single switch:
:root { color-scheme: light; /* light tokens */ }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { color-scheme: dark; /* dark tokens */ }
}
:root[data-theme="dark"] { color-scheme: dark; /* dark tokens */ }
The dark tokens appear twice. To keep the copies from drifting, I'd generate one from the other with a small build step (PostCSS or a script). If that is too heavy, I'd put a comment on both blocks and add a check that diffs them.
Head setup in nuxt.config.ts:
app: {
head: {
meta: [
{ name: 'color-scheme', content: 'light dark' },
{ name: 'theme-color', content: '#ffffff', media: '(prefers-color-scheme: light)' },
{ name: 'theme-color', content: '#0f1115', media: '(prefers-color-scheme: dark)' },
],
script: [{
innerHTML: `(function(){try{var s=localStorage.getItem('theme');if(s==='light'||s==='dark')document.documentElement.setAttribute('data-theme',s)}catch(e){}})()`,
tagPosition: 'head',
}],
},
},
Nuxt/unhead renders a script with innerHTML as an inline, synchronous <script> in the server HTML. I'd check the rendered HTML to confirm it has no defer, async or type="module". Also check that the project's CSP, if it has one, allows inline scripts, with a nonce or hash if needed. Without a stored choice the script does nothing, so the media query handles System.
3. System / Light / Dark toggle
A useTheme() composable plus a ThemeToggle.vue component:
- State:
choiceis'system' | 'light' | 'dark'. - Select Light or Dark: set
data-themeand writelocalStorage.theme. - Select System: remove the attribute and remove the key. Never store the word "system".
- Storage access: wrap every read and write in
try/catch. - System changes: while the choice is System, listen to
matchMedia('(prefers-color-scheme: dark)')changes. This matters mainly fortheme-colorand for any JS that reads the theme. CSS follows the media query on its own. - Markup: a three-option radio group (
<fieldset>withrole="radiogroup") or three buttons witharia-pressed. It shows the current choice and has an accessible name.
4. Avoiding the flash and the hydration mismatch
Flash.
- The inline head script sets
data-themebefore first paint. <meta name="color-scheme">pluscolor-schemein CSS prevents a white canvas.- No theme transitions on first paint. Add a
theme-readyclass after mount if you want them.
Hydration mismatch. The server can't know the saved choice, so the server HTML must not depend on it.
- Initialize
choiceto'system'identically on server and client, and read storage only inonMounted. - Don't use
useStateoruseCookieto render a different icon or label per theme. - Make the toggle's visual state not depend on JS state at first render. Two options: render the control's checked state only after mount, or drive it from CSS with
:root[data-theme="dark"] .opt-dark { … }. - Don't bind
data-themethrough Vue on<html>. The head script owns it, and a Vue-managed attribute can conflict with it on hydration. - Never branch on
import.meta.clientorwindowduring render.
5. Other items to handle
- Logos and images with baked-in backgrounds: use
<picture>with amediaquery, or a variant switched bydata-theme. Note that<picture>follows the system setting, not the manual choice, so for a manual override use two<img>elements toggled by CSS. - Shadows, selection color, scrollbars, code blocks, charts, and third-party embeds. Embeds may remain known gaps.
- Print styles and email templates should stay light, set explicitly.
6. Verification
Contrast, checked per theme and per state (hover, focus, disabled, error, placeholder, link):
--texton--bg,--surfaceand--surface-raised.--text-secondaryon--bgand--surface.--linkon--bgand--surface.--on-primaryon--primaryand--primary-hover.- Each
--on-<status>on its status color. --border-strongand--focus-ringagainst their surfaces (3:1).- Placeholder on
--surface-input.
I'd compute these ratios with a script from the token values, which needs no browser. Thresholds are 4.5:1 for text and 3:1 for large text and UI parts.
Browser matrix, at phone and desktop widths:
- First load with no stored choice (system light and system dark), stored light, and stored dark. Throttle CPU and cache to catch any flash.
- Hard reload and client-side navigation.
- Toggle persists across reloads. System follows the OS.
- Storage blocked (private window or blocked site data): the page still renders correctly.
- Console shows no hydration warning.
- Keyboard focus rings visible in both themes. No text, icon or border disappears.
curlthe SSR HTML to confirm the inline script is in<head>and the toggle markup has no theme-dependent content.- Grep for leftover hard-coded colors.
What I could not check
I can't run the app, so none of the following is verified:
- Any visual result in either theme.
- The actual flash behavior.
- Hydration warnings.
- Real contrast of your brand colors. Starter values are placeholders, and the ratios have to be re-checked once your colors are in.
- Behavior at phone and desktop widths.
- Toggle persistence and OS-follow behavior.
- Blocked-storage behavior.
- Which unhead version and CSP settings your app has, which affects the inline script.
- Whether your images, charts or embeds have theme problems, because I haven't seen them.
Real run in an isolated folder with only this skill installed. Only the Skill and Read tools were enabled, so no command was run and no file was written. The scenario is fictional and the model had no access to the code. The model opened the skill's token starter file.
하는 일
웹 UI에 라이트·다크 모드를 추가하거나 고치는 단계별 방법입니다. 먼저 프로젝트의 프레임워크와 기존 테마 체계를 살펴본 뒤, 16진수 색상을 직접 쓰는 대신 역할별 의미 토큰(배경, 텍스트, 테두리, 브랜드와 상태 색상 및 그 위에 올리는 글자색)을 정의합니다. 루트 요소의 data-theme 속성 하나로 테마를 바꾸고, 기본값은 시스템 설정을 따르며, color-scheme을 항상 지정해 기본 컨트롤과 스크롤바도 맞춥니다. head에 두는 작은 동기 스크립트로 잘못된 테마가 번쩍이는 것을 막고, 저장된 선택은 try/catch로 감싸 읽으며, 서버 렌더링 페이지에서 하이드레이션 불일치를 피하는 방법도 설명합니다. 전환 버튼은 시스템, 라이트, 다크의 세 상태입니다.
점검과 수정
각 테마와 각 상태에서 대비를 점검하는 방법(일반 텍스트 4.5:1, 큰 글자와 의미 있는 UI 요소 3:1)과 흔한 원인을 나열합니다. 하드코딩된 색상, 흰 배경 이미지, currentColor를 쓰지 않는 아이콘, 그림자, 차트, 서드파티 임베드, 코드 블록, 인쇄 스타일입니다. 참고 파일에는 시작용 토큰 세트가 있으며, 그 자리표시 색상은 자체적으로 제시한 대비 조합으로 검증되었습니다.
이런 때 좋습니다
기존 사이트에 다크 모드를 추가하거나, 어색해 보이는 다크 테마를 고칠 때.
낮은 위험:스크립트가 없는 지침 패키지로, 네트워크 접속이나 파일 쓰기가 없습니다. CSS와 짧은 인라인 스크립트를 제시하므로 직접 프로젝트에 넣기 전에 검토하세요. 시작용 색상은 자리표시입니다. 스킬이 제시한 대비 조합으로 검증했지만, 브랜드 색상으로 바꾸면 반드시 다시 확인해야 합니다. 실행 중인 페이지는 볼 수 없으며 하지 못한 점검은 밝힙니다. 최신 CSS와 meta 기능은 브라우저마다 지원이 다르므로 대상 브라우저에서 확인하세요. AIBars가 만든 오리지널(MIT). 가상의 Nuxt 앱으로 한 번 시험 실행했습니다.