TOOLS GAMES DEVLOG DOCS ABOUT CONTACT
HOME > DOCS > Web / Tools > FOUC(화면 깜빡임) 없는 다크 모드 및 다국어(KO/EN) 프리페인트(Pre-Paint) 아키텍처FOUC-Free Dark Mode and Multilingual (KO/EN) Pre-Paint Architecture
Web / Tools Intermediate HTML5 / CSS3 / ES6

FOUC(화면 깜빡임) 없는 다크 모드 및 다국어(KO/EN) 프리페인트(Pre-Paint) 아키텍처FOUC-Free Dark Mode and Multilingual (KO/EN) Pre-Paint Architecture

브라우저 첫 페인트(First Paint) 이전에 실행되는 <head> 인라인 스크립트와 CSS 속성 선택자를 결합하여, 화면 깜빡임과 레이아웃 시프트 없이 테마와 언어를 전환하는 기법입니다.Combining a synchronous pre-paint <head> inline script with CSS attribute selectors to switch themes and languages without Flash of Unstyled Content (FOUC) or layout shifts.

#DarkMode #i18n #FOUC #CSS #Frontend
핵심 개념다크 모드나 다국어 설정을 DOMContentLoaded 이후 외부 자바스크립트에서 적용하면, 페이지 이동 시 밝은 배경이 순간적으로 번쩍이는 FOUC(Flash of Unstyled Content) 현상이 발생합니다. 이를 방지하려면 <head> 최상단의 동기 인라인 스크립트에서 localStorage와 OS 설정을 즉시 판별해 <html> 루트 요소에 data-theme과 data-lang 속성을 첫 페인트 전에 주입해야 합니다.
Key ConceptApplying dark mode or language settings via external JavaScript after DOMContentLoaded causes a visible white flash—known as FOUC (Flash of Unstyled Content)—during page navigation. To prevent this, a synchronous inline script at the very top of <head> must inspect localStorage and OS preferences, setting data-theme and data-lang attributes on the root <html> element before the browser's first paint.

1. Pre-Paint 인라인 부트스트랩 및 CSS 변수 오버라이드 구현1. Implementing Pre-Paint Inline Bootstrap and CSS Variable Overriding

브라우저가 <body>를 그리기 전에 document.documentElement의 속성을 확정하고, CSS에서는 html[data-theme="dark"] 속성 선택자와 CSS 커스텀 프로퍼티(--bg, --surface, --ink)를 사용해 모든 하위 컴포넌트의 색상을 일괄 전환합니다.

Lock in attributes on document.documentElement before the browser parses <body>, and use the html[data-theme="dark"] attribute selector alongside CSS custom properties (--bg, --surface, --ink) to switch component colors across the page.

prepaint_theme_i18n.html
<!-- 1. <head> 최상단에 배치: 첫 페인트(First Paint) 이전에 동기 실행 -->
<script>
(function() {
    var docEl = document.documentElement;
    // 언어 상태 복원 (ko | en)
    try {
        var savedLang = localStorage.getItem('lysc-lang');
        if (savedLang === 'en' || savedLang === 'ko') {
            docEl.setAttribute('data-lang', savedLang);
            docEl.lang = savedLang;
        }
    } catch (e) {}

    // 테마 상태 복원 (localStorage 우선, 없으면 OS prefers-color-scheme 참조)
    try {
        var savedTheme = localStorage.getItem('lysc-theme');
        if (savedTheme !== 'dark' && savedTheme !== 'light') {
            savedTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
        }
        docEl.setAttribute('data-theme', savedTheme);
    } catch (e) {
        docEl.setAttribute('data-theme', 'light');
    }
})();
</script>

<!-- 2. CSS 속성 선택자 기반 테마 및 전환 트랜지션 제어 -->
<style>
html[data-theme="dark"] {
    --ink: #f4f1e8;
    --ink-dim: #b6b0a2;
    --bg: #15130f;
    --surface: #201c16;
    --line: #f4f1e8;
    color-scheme: dark;
}
html[data-theme="dark"] body {
    background-color: var(--bg);
    color: var(--ink);
}
/* 수동 토글 시에만 250ms 동안 부드러운 색상 전환 허용 */
html.theme-switching, html.theme-switching * {
    transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease !important;
}
/* 언어별 전용 블록 즉시 토글 */
html[data-lang="en"] [data-lang-only="ko"] { display: none !important; }
html:not([data-lang="en"]) [data-lang-only="en"] { display: none !important; }
</style>
prepaint_theme_i18n.html
<!-- 1. Place at the top of <head>: executes synchronously before First Paint -->
<script>
(function() {
    var docEl = document.documentElement;
    // Restore language state (ko | en)
    try {
        var savedLang = localStorage.getItem('lysc-lang');
        if (savedLang === 'en' || savedLang === 'ko') {
            docEl.setAttribute('data-lang', savedLang);
            docEl.lang = savedLang;
        }
    } catch (e) {}

    // Restore theme state (localStorage first, fallback to OS prefers-color-scheme)
    try {
        var savedTheme = localStorage.getItem('lysc-theme');
        if (savedTheme !== 'dark' && savedTheme !== 'light') {
            savedTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
        }
        docEl.setAttribute('data-theme', savedTheme);
    } catch (e) {
        docEl.setAttribute('data-theme', 'light');
    }
})();
</script>

<!-- 2. CSS attribute selector theme rules and transition control -->
<style>
html[data-theme="dark"] {
    --ink: #f4f1e8;
    --ink-dim: #b6b0a2;
    --bg: #15130f;
    --surface: #201c16;
    --line: #f4f1e8;
    color-scheme: dark;
}
html[data-theme="dark"] body {
    background-color: var(--bg);
    color: var(--ink);
}
/* Allow smooth 200ms color transition only during manual user toggle */
html.theme-switching, html.theme-switching * {
    transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease !important;
}
/* Immediately hide inactive language blocks */
html[data-lang="en"] [data-lang-only="ko"] { display: none !important; }
html:not([data-lang="en"]) [data-lang-only="en"] { display: none !important; }
</style>

2. 테마 및 다국어 상태 제어 속성 명세2. Theme and Multilingual State Attribute Specification

좌우로 밀어서 표 전체 내용을 확인하세요
속성 / 키 이름적용 대상동작 시점 및 설계 의도
html[data-theme="dark"]<html> 루트 요소Pre-Paint 시점에 확정되어 배경·표면·테두리 CSS 변수를 다크 팔레트로 즉시 치환
html.theme-switching<html> 루트 요소초기 로드 시에는 트랜지션을 끄고, 사용자가 토글 버튼을 누른 250ms 동안만 애니메이션 활성화
data-i18n="key"일반 텍스트 DOM 노드i18n.js 사전 객체에서 현재 언어(ko/en)에 맞는 문자열을 찾아 textContent 교체
data-lang-only="ko|en"언어별 독립 블록 요소CSS 선택자 단계에서 비활성 언어 블록을 즉시 숨겨 레이아웃 밀림(CLS) 방지
Swipe horizontally to view the full table
Attribute / KeyTarget ElementExecution Timing and Design Intent
html[data-theme="dark"]<html> root elementLocked before first paint to swap background, surface, and border CSS variables to the dark palette
html.theme-switching<html> root elementKeeps CSS transitions disabled on page load and enables them for 250ms only when the user clicks the toggle
data-i18n="key"Text DOM nodesReplaces textContent using the dictionary entry matching the active language (ko/en) in i18n.js
data-lang-only="ko|en"Language-specific blocksHides inactive language containers at the CSS selector level to prevent Cumulative Layout Shift (CLS)

3. 실무 적용 및 주의사항3. Practical Considerations and Caveats

  • localStorage 접근 예외 방어: 사파리 사생활 보호 모드나 서드파티 쿠키/스토리지 차단 환경에서는 localStorage.getItem 호출 시 SecurityError 예외가 발생할 수 있으므로 반드시 try-catch 블록으로 감싸야 합니다.
  • 브랜드 강조색 위의 글자 대비 유지: 다크 모드에서 본문 글자색을 밝게(--ink) 뒤집더라도, 노란색(#ffde59)이나 하늘색(#5ce1e6) 같은 네오 브루탈리즘 강조 배지 내부의 글자색은 검정(#000000)으로 고정해야 WCAG 명도 대비 기준을 충족합니다.
  • 초기 렌더링 시 CSS Transition 비활성화: body에 상시 transition: background-color 0.3s를 걸어두면 페이지 로드 직후 밝은 색에서 어두운 색으로 서서히 변하는 잔상이 남으므로, 버튼 클릭 시에만 임시 클래스(theme-switching)를 부여해야 합니다.
  • Guard localStorage Calls: Safari Private Browsing or strict storage-blocking policies can throw a SecurityError when accessing localStorage.getItem, so always wrap storage access in try-catch blocks.
  • Preserve Contrast on Brand Accent Surfaces: Even when body text flips to light (--ink) in dark mode, text inside bright neo-brutalist accent badges (#ffde59, #5ce1e6) must remain black (#000000) to meet WCAG contrast ratios.
  • Disable CSS Transitions During Initial Load: Applying a permanent transition: background-color 0.3s on body causes a visible fade from light to dark on page load; attach a temporary theme-switching class only on button click.

첫 페인트 이전의 동기 속성 주입과 CSS 선택자 우선순위 설계를 결합하면, 정적 다중 페이지(MPA) 환경에서도 페이지 간 이동 시 깜빡임 없는 다크 모드와 다국어 사용자 경험을 구현할 수 있습니다.

Combining synchronous pre-paint attribute injection with high-specificity CSS selectors enables flicker-free dark mode and bilingual switching across multi-page static sites.