- 구현 날짜: 2025년 11월 3일
- 리포지토리: https://github.com/khy1121/StudyProgram
- 브랜치:
feat/hy_change→main병합 완료
사용자 경험 개선을 위한 다크/라이트 모드 테마 전환 시스템 구축
- 전역 테마 상태 관리
- 모든 페이지에 일관된 테마 적용
- localStorage를 통한 사용자 선호도 저장
- 부드러운 테마 전환 애니메이션
- 11개 파일 수정 (625줄 추가, 141줄 삭제)
- 4개 신규 파일 생성
- Home, SelectPage, Welcome 페이지 테마 적용
┌─────────────────────────────────────────────┐
│ React Application Root │
│ (main.jsx) │
├─────────────────────────────────────────────┤
│ ThemeProvider (Context) │
│ - 테마 상태 관리 (dark/light) │
│ - localStorage 연동 │
│ - data-theme 속성 설정 │
└──────────────┬──────────────────────────────┘
│
┌──────────┴──────────┬──────────────┐
│ │ │
┌───▼────┐ ┌─────▼─────┐ ┌───▼────┐
│ Home │ │ SelectPage│ │ Welcome│
│ (테마적용)│ │ (테마적용)│ │(테마적용)│
└────────┘ └───────────┘ └────────┘
│ │ │
└──────────┬──────────┴──────────────┘
│
┌──────▼──────┐
│ theme.css │
│ CSS 변수 정의│
└─────────────┘
파일: src/contexts/ThemeContext.jsx
- 전역 테마 상태 관리
- localStorage와 자동 동기화
- document.documentElement에
data-theme속성 설정
import React, { createContext, useContext, useState, useEffect } from 'react';
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
// localStorage에서 테마 설정 가져오기, 기본값은 'dark'
const [theme, setTheme] = useState(() => {
const savedTheme = localStorage.getItem('theme');
return savedTheme || 'dark';
});
// 테마 변경 시 localStorage 저장 및 document에 클래스 적용
useEffect(() => {
localStorage.setItem('theme', theme);
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'dark' ? 'light' : 'dark');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}- useState: 테마 상태 관리 (dark/light)
- useEffect: 테마 변경 감지 및 DOM/localStorage 업데이트
- useContext: 컴포넌트에서 테마 접근
파일: src/styles/theme.css
속성 선택자 [data-theme="dark"] 및 [data-theme="light"]를 사용하여 테마별 CSS 변수 정의
:root[data-theme="dark"] {
/* 배경 */
--bg-primary: #0b1020;
--bg-gradient-1: rgba(37, 99, 235, 0.12);
--bg-gradient-2: rgba(168, 85, 247, 0.12);
/* 카드/컨테이너 */
--card-bg-from: rgba(255, 255, 255, 0.08);
--card-bg-to: rgba(255, 255, 255, 0.04);
--card-border: rgba(255, 255, 255, 0.12);
--card-hover-bg-from: rgba(255, 255, 255, 0.12);
--card-hover-bg-to: rgba(255, 255, 255, 0.06);
/* 텍스트 */
--text-primary: #f9fafb;
--text-secondary: #e5e7eb;
--text-tertiary: #cbd5e1;
--text-muted: #9ca3af;
/* 버튼 */
--btn-bg: rgba(255, 255, 255, 0.06);
--btn-border: rgba(255, 255, 255, 0.18);
--btn-hover-bg: rgba(255, 255, 255, 0.1);
--btn-hover-border: rgba(255, 255, 255, 0.24);
/* 아이콘 배경 */
--icon-blue-bg: rgba(59, 130, 246, 0.2);
--icon-blue-color: #93c5fd;
--icon-green-bg: rgba(34, 197, 94, 0.2);
--icon-green-color: #86efac;
--icon-orange-bg: rgba(251, 146, 60, 0.2);
--icon-orange-color: #fdba74;
/* 슬라이더 */
--slider-track-bg: rgba(255, 255, 255, 0.1);
--slider-thumb-bg: #2563eb;
--slider-thumb-border: #93c5fd;
--time-value-color: #93c5fd;
/* 그림자 */
--shadow-sm: 0 8px 24px rgba(0, 0, 0, 0.2);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.3);
}:root[data-theme="light"] {
/* 배경 */
--bg-primary: #f9fafb;
--bg-gradient-1: transparent;
--bg-gradient-2: transparent;
/* 카드/컨테이너 */
--card-bg-from: #ffffff;
--card-bg-to: #ffffff;
--card-border: #e5e7eb;
--card-hover-bg-from: #f9fafb;
--card-hover-bg-to: #f9fafb;
/* 텍스트 */
--text-primary: #111827;
--text-secondary: #374151;
--text-tertiary: #6b7280;
--text-muted: #9ca3af;
/* 버튼 */
--btn-bg: #ffffff;
--btn-border: #e5e7eb;
--btn-hover-bg: #f9fafb;
--btn-hover-border: #d1d5db;
/* 아이콘 배경 */
--icon-blue-bg: #dbeafe;
--icon-blue-color: #2563eb;
--icon-green-bg: #d1fae5;
--icon-green-color: #059669;
--icon-orange-bg: #fed7aa;
--icon-orange-color: #ea580c;
/* 슬라이더 */
--slider-track-bg: #e5e7eb;
--slider-thumb-bg: #2563eb;
--slider-thumb-border: #ffffff;
--time-value-color: #2563eb;
/* 그림자 */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.15);
}파일: src/main.jsx
import "./styles/reset.css";
import "./styles/style.css";
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import { ThemeProvider } from "./contexts/ThemeContext.jsx";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</React.StrictMode>
);ThemeProvider로 전체 앱을 감싸 모든 컴포넌트에서 테마 접근 가능- React Strict Mode 내부에 배치하여 개발 모드 체크 활성화
파일: src/components/Home/Home.jsx
import { useTheme } from '../../contexts/ThemeContext';
export default function Home() {
const { theme, toggleTheme } = useTheme();
return (
<div className="home-container">
<header className="home-header">
<div className="header-content">
<div className="brand">
<div className="brand-icon">📘</div>
<h1 className="brand-title">학습 플랫폼</h1>
</div>
<div className="header-actions">
{/* 테마 토글 버튼 */}
<button className="header-btn theme-toggle" onClick={toggleTheme}>
{theme === 'dark' ? '☀️ 라이트' : '🌙 다크'}
</button>
{/* 기타 버튼들 */}
</div>
</div>
</header>
{/* 메인 컨텐츠 */}
</div>
);
}.home-container: 페이지 전체 컨테이너.header-btn.theme-toggle: 테마 전환 버튼- 동적 아이콘: 다크 모드(☀️), 라이트 모드(🌙)
파일: src/styles/home.css
@import './theme.css';
.home-container {
min-height: 100vh;
display: flex;
flex-direction: column;
background: radial-gradient(1200px 600px at 10% 10%, var(--bg-gradient-1), rgba(255, 255, 255, 0) 60%),
radial-gradient(1000px 500px at 90% 0%, var(--bg-gradient-2), rgba(255, 255, 255, 0) 60%),
var(--bg-primary);
color: var(--text-secondary);
transition: background 0.3s ease, color 0.3s ease;
}
.stat-card {
background: linear-gradient(180deg, var(--card-bg-from), var(--card-bg-to));
border: 1px solid var(--card-border);
transition: transform 0.2s, box-shadow 0.2s, background 0.2s;
}
.stat-card:hover {
background: linear-gradient(180deg, var(--card-hover-bg-from), var(--card-hover-bg-to));
box-shadow: var(--shadow-md);
}/* 통계 카드 - 3개 가로 배치 */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 40px;
}
/* 액션 카드 - 3개 가로 배치 */
.action-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 48px;
}
/* 반응형: 1024px 이하에서 1열로 */
@media (max-width: 1024px) {
.stats-grid,
.action-grid {
grid-template-columns: 1fr;
}
}파일: src/styles/select.css
@import './theme.css';
/* 과목 선택 카드 */
.subject-card {
background: linear-gradient(180deg, var(--card-bg-from), var(--card-bg-to));
border: 2px solid var(--card-border);
transition: all 0.2s;
}
.subject-title {
font-weight: 600;
color: var(--text-primary);
}
/* 난이도 선택 카드 */
.pill-title {
color: var(--text-primary);
}
.pill-desc {
color: var(--text-tertiary);
}
/* 학습 시간 슬라이더 */
.time-range {
background: var(--slider-track-bg);
}
.time-range::-webkit-slider-thumb {
background: var(--slider-thumb-bg);
border: 2px solid var(--slider-thumb-border);
box-shadow: 0 0 0 3px var(--slider-thumb-shadow);
}
.time-value {
color: var(--time-value-color);
}- 다크 모드: 반투명 흰색 트랙, 연한 파란색 시간 표시
- 라이트 모드: 회색 트랙, 진한 파란색 시간 표시
파일: src/pages/SelectCourse/LandingPage.jsx
import { useNavigate } from "react-router-dom";
import SelectPage from "../../components/SelectCourse/selectPage";
export default function LandingPage() {
const navigate = useNavigate();
const handleStart = (payload) => {
// TODO: /quiz 또는 /exam으로 이동
console.log('학습 시작:', payload);
// navigate("/exam", { state: payload });
};
return <SelectPage onStart={handleStart} />;
}- Home →
/select-course→ LandingPage → SelectPage - SelectPage에서 과목/난이도/모드 선택 UI 제공
| 변수명 | 다크 모드 | 라이트 모드 | 용도 |
|---|---|---|---|
--bg-primary |
#0b1020 |
#f9fafb |
페이지 기본 배경 |
--bg-gradient-1 |
rgba(37, 99, 235, 0.12) |
transparent |
그라데이션 효과 1 |
--bg-gradient-2 |
rgba(168, 85, 247, 0.12) |
transparent |
그라데이션 효과 2 |
| 변수명 | 다크 모드 | 라이트 모드 | 용도 |
|---|---|---|---|
--card-bg-from |
rgba(255, 255, 255, 0.08) |
#ffffff |
카드 배경 시작색 |
--card-bg-to |
rgba(255, 255, 255, 0.04) |
#ffffff |
카드 배경 종료색 |
--card-border |
rgba(255, 255, 255, 0.12) |
#e5e7eb |
카드 테두리 |
| 변수명 | 다크 모드 | 라이트 모드 | 용도 |
|---|---|---|---|
--text-primary |
#f9fafb |
#111827 |
주요 텍스트 |
--text-secondary |
#e5e7eb |
#374151 |
보조 텍스트 |
--text-tertiary |
#cbd5e1 |
#6b7280 |
부가 설명 |
--text-muted |
#9ca3af |
#9ca3af |
흐린 텍스트 |
| 변수명 | 다크 모드 | 라이트 모드 | 용도 |
|---|---|---|---|
--btn-bg |
rgba(255, 255, 255, 0.06) |
#ffffff |
버튼 배경 |
--btn-border |
rgba(255, 255, 255, 0.18) |
#e5e7eb |
버튼 테두리 |
--btn-hover-bg |
rgba(255, 255, 255, 0.1) |
#f9fafb |
버튼 호버 배경 |
| 변수명 | 다크 모드 | 라이트 모드 | 용도 |
|---|---|---|---|
--slider-track-bg |
rgba(255, 255, 255, 0.1) |
#e5e7eb |
슬라이더 트랙 |
--slider-thumb-bg |
#2563eb |
#2563eb |
슬라이더 썸 |
--time-value-color |
#93c5fd |
#2563eb |
시간 값 색상 |
사용자 접속
↓
ThemeProvider 마운트
↓
localStorage에서 'theme' 읽기
↓
값 있음? → 해당 값 사용
값 없음? → 'dark' 기본값
↓
document.documentElement.setAttribute('data-theme', theme)
↓
CSS 변수 활성화
사용자가 테마 버튼 클릭
↓
toggleTheme() 실행
↓
setState('dark' ↔ 'light')
↓
useEffect 트리거
↓
localStorage.setItem('theme', newTheme)
↓
document.documentElement.setAttribute('data-theme', newTheme)
↓
CSS transition 애니메이션 (0.3s ease)
↓
화면 전체 테마 변경 완료
src/
├── contexts/
│ └── ThemeContext.jsx # 테마 컨텍스트 (신규)
├── components/
│ ├── Home/
│ │ └── Home.jsx # 테마 버튼 추가
│ └── SelectCourse/
│ └── selectPage.jsx # 테마 변수 적용
├── pages/
│ └── SelectCourse/
│ └── LandingPage.jsx # 주석 해제 활성화
├── styles/
│ ├── theme.css # CSS 변수 정의 (신규)
│ ├── home.css # 테마 변수 적용
│ ├── select.css # 테마 변수 적용
│ ├── home_origin # 백업 파일
│ └── select_origin # 백업 파일
└── main.jsx # ThemeProvider 적용
import { useTheme } from '../../contexts/ThemeContext';
function MyComponent() {
const { theme, toggleTheme } = useTheme();
return (
<div>
<p>현재 테마: {theme}</p>
<button onClick={toggleTheme}>테마 전환</button>
</div>
);
}@import './theme.css';
.my-component {
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--card-border);
transition: background 0.3s ease, color 0.3s ease;
}
.my-card {
background: linear-gradient(180deg, var(--card-bg-from), var(--card-bg-to));
box-shadow: var(--shadow-md);
}src/styles/theme.css에 변수 추가:
:root[data-theme="dark"] {
--my-custom-color: #your-dark-color;
}
:root[data-theme="light"] {
--my-custom-color: #your-light-color;
}.my-element {
/* 기본: 0.3s ease */
transition: background 0.3s ease, color 0.3s ease;
/* 더 빠르게 */
transition: background 0.15s ease, color 0.15s ease;
/* 더 부드럽게 */
transition: background 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}function MyComponent() {
const { theme } = useTheme();
return (
<div>
{theme === 'dark' ? (
<img src="/dark-logo.png" alt="Logo" />
) : (
<img src="/light-logo.png" alt="Logo" />
)}
</div>
);
}- ✅ Chrome 88+
- ✅ Firefox 85+
- ✅ Safari 14+
- ✅ Edge 88+
- CSS Custom Properties (변수): 모든 모던 브라우저 지원
- CSS Transitions: 완전 지원
- localStorage API: 완전 지원
- React Context API: React 16.3+
- ThemeContext의 값이 변경될 때만 리렌더링
useCallback,useMemo활용 가능
/* GPU 가속 활용 */
.card {
transition: transform 0.2s ease;
will-change: transform;
}
/* 불필요한 transition 제거 */
.text {
/* transition 없음 - 즉시 변경 */
color: var(--text-primary);
}- 초기 로드 시 1회만 읽기
- 변경 시에만 쓰기
const getInitialTheme = () => {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) return savedTheme;
// 시스템 테마 감지
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
};// Auto, Dark, Light 3가지 모드
const [themeMode, setThemeMode] = useState('auto'); // auto, dark, lightconst getThemedImage = (baseName) => {
return theme === 'dark'
? `/images/${baseName}-dark.png`
: `/images/${baseName}-light.png`;
};현재 Home, SelectPage에만 적용됨. Welcome 페이지도 동일하게 적용 필요.
원인: localStorage 권한 또는 Private 모드 해결:
try {
localStorage.setItem('theme', theme);
} catch (e) {
console.warn('localStorage not available');
}원인: CSS 변수 적용 전 렌더링 해결: 인라인 스크립트로 즉시 테마 적용
<script>
const theme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme);
</script>원인: @import './theme.css' 누락
해결: 모든 테마 적용 CSS 파일 상단에 import 추가
- 다크 모드: 어두운 배경 + 밝은 텍스트 (기존 Welcome 페이지 스타일)
- 라이트 모드: 밝은 배경 (#f9fafb) + 어두운 텍스트 (Material Design 가이드라인)
- 브랜치:
feat/hy_change - 커밋:
70315d2(feat: 다크/라이트 모드 테마 전환 기능 추가) - 리포지토리: https://github.com/khy1121/StudyProgram
이 프로젝트는 학습 플랫폼의 일부이며, 모든 변경사항은 StudyProgram 리포지토리에서 관리됩니다.
작성일: 2025년 11월 3일
버전: 1.0.0
작성자: khy1121