Article · programming
HTML & CSS EP16: CSS Variables และ Design System — สร้างธีมเว็บไซต์ที่ปรับแต่งได้ทั้งระบบ
HTML & CSS
บทเรียน HTML และ CSS ตอนที่ 16 เรียนรู้ CSS Custom Properties หรือ CSS Variables การกำหนดค่าสี ฟอนต์ ระยะห่าง เงา และมุมโค้งไว้เป็นตัวแปรส่วนกลาง การสร้าง Design Tokens การทำ Light/Dark Theme ด้วย JavaScript และ localStorage รวมถึงการสร้างหน้าเว็บไซต์ตัวอย่างที่เปลี่ยนธีมได้จริงและรองรับหน้าจอหลายขนาด
HTML & CSS EP16: CSS Variables และ Design System
เมื่อเว็บไซต์มีหลายหน้าและมีองค์ประกอบจำนวนมาก การกำหนดสี ฟอนต์ ระยะห่าง และรูปแบบปุ่มแยกกันในทุกส่วนจะทำให้ดูแลรักษายาก หากต้องการเปลี่ยนสีหลักของเว็บไซต์ เราอาจต้องค้นหาและแก้ไข CSS หลายตำแหน่ง บทเรียนนี้จะพาไปรู้จัก **CSS Variables** หรือชื่อทางการว่า **CSS Custom Properties** ซึ่งช่วยให้กำหนดค่าที่ใช้ซ้ำได้ แล้วนำไปอ้างอิงจากส่วนต่าง ๆ ของเว็บไซต์ได้อย่างเป็นระบบ
เราจะต่อยอดแนวคิดนี้ไปสู่ **Design System** ขนาดเล็ก โดยจัดเก็บค่าสี ฟอนต์ ระยะห่าง เงา และรูปแบบองค์ประกอบไว้เป็นชุดเดียว พร้อมสร้างปุ่มเปลี่ยน Light Theme และ Dark Theme ที่ใช้งานได้จริง
🎯 เป้าหมายของบทเรียนเมื่อเรียนจบ คุณจะสามารถประกาศตัวแปรด้วย `--ชื่อ-ตัวแปร`, อ่านค่าด้วย `var()`, กำหนดค่าเริ่มต้นด้วย `var(--name, fallback)`, ใช้ตัวแปรร่วมกับ `:root` และ `data-theme`, สร้างชุด Design Tokens และเชื่อม CSS เข้ากับ JavaScript เพื่อสลับธีมโดยไม่ต้องเขียน CSS ซ้ำทั้งเว็บไซต์
1. CSS Variables คืออะไร
CSS Variables คือค่าที่นักพัฒนาตั้งชื่อขึ้นเองและนำกลับมาใช้ซ้ำใน CSS ได้ ชื่อตัวแปรต้องขึ้นต้นด้วยเครื่องหมายขีดสองตัว `--` และเรียกใช้ค่าด้วยฟังก์ชัน `var()` ตัวแปรเหล่านี้ไม่ได้ทำงานเหมือนตัวแปรของภาษา JavaScript ทุกประการ เพราะ CSS Custom Properties เป็นส่วนหนึ่งของระบบ Cascading และ Inheritance ของ CSS
ตัวอย่างเช่น หากเว็บไซต์ใช้สีน้ำเงินเป็นสีหลัก เราสามารถกำหนด `--color-primary: #2563eb;` ไว้เพียงจุดเดียว จากนั้นนำไปใช้กับปุ่ม ลิงก์ ป้ายสถานะ และหัวข้อได้ เมื่อเปลี่ยนค่าสีหลัก ส่วนที่อ้างอิงตัวแปรนี้ก็จะเปลี่ยนตามโดยอัตโนมัติ
:root {
--color-primary: #2563eb;
--color-text: #1e293b;
--color-surface: #ffffff;
--radius-card: 16px;
}
.button {
background: var(--color-primary);
color: white;
border-radius: var(--radius-card);
}
.card {
color: var(--color-text);
background: var(--color-surface);
border-radius: var(--radius-card);
}
ทำไมจึงนิยมประกาศตัวแปรไว้ใน :root
` :root ` เป็น pseudo-class ที่เลือก root element ของเอกสาร HTML ซึ่งโดยทั่วไปคือองค์ประกอบ `html` การประกาศ Custom Properties ไว้ที่นี่ทำให้ตัวแปรสามารถสืบทอดไปยังองค์ประกอบลูกส่วนใหญ่ของเอกสารได้ หากไม่มีการเขียนทับค่าที่ระดับองค์ประกอบอื่น
ตัวอย่างเช่น `:root { --brand: #2563eb; }` ทำให้ทุกองค์ประกอบที่อยู่ในขอบเขตการสืบทอดสามารถเรียก `var(--brand)` ได้ การใช้ `:root` จึงเหมาะกับค่ากลางของเว็บไซต์ เช่น สีแบรนด์ สีพื้นหลัง สีข้อความ และระยะห่างมาตรฐาน
ระวังชื่อที่ไม่มีเครื่องหมาย --การเขียน `primary: blue;` ไม่ได้สร้าง CSS Custom Property ต้องเขียนเป็น `--primary: blue;` แล้วเรียกด้วย `var(--primary)` จึงจะทำงานตามที่ต้องการ
2. เข้าใจ var() และค่า fallback
ฟังก์ชัน `var()` ใช้อ่านค่าจาก Custom Property รูปแบบพื้นฐานคือ `var(--ชื่อ-ตัวแปร)` และสามารถกำหนดค่า fallback ได้ด้วยการใส่ค่าต่อจากเครื่องหมายจุลภาค เช่น `var(--brand, #2563eb)` หากตัวแปรที่ระบุไม่มีค่าที่ใช้งานได้ในบริบทนั้น เบราว์เซอร์จะใช้ค่า fallback แทน
Fallback มีประโยชน์เมื่อเขียนคอมโพเนนต์ที่อาจถูกนำไปใช้ในหน้าอื่น หรือเมื่อยังไม่ได้กำหนด Design Tokens บางตัว อย่างไรก็ตาม fallback ไม่ใช่สิ่งทดแทนการออกแบบตัวแปรที่ครบถ้วน และไม่ได้แก้ปัญหาตัวแปรที่มีค่าอยู่แต่ไม่ถูกต้องตามชนิดของ CSS property เสมอไป
.notice {
color: var(--notice-text, #7c2d12);
background: var(--notice-background, #ffedd5);
padding: var(--space-md, 16px);
}
:root {
--space-md: 1rem;
}
การตั้งชื่อตัวแปรให้อ่านง่าย
ควรตั้งชื่อให้สื่อความหมายและใช้รูปแบบเดียวกันทั่วทั้งโครงการ เช่น `--color-primary`, `--color-text`, `--space-md`, `--font-body` และ `--radius-card` หลีกเลี่ยงชื่อที่ไม่บอกหน้าที่ เช่น `--blue1`, `--x` หรือ `--thing` เพราะเมื่อระบบเติบโตขึ้นจะทำให้แก้ไขได้ยาก
หากสีเดียวกันถูกใช้ในหลายหน้าที่ อาจแยกชื่อเป็นตัวแปรตามบทบาท เช่น `--color-primary` สำหรับสีหลักของแบรนด์ และ `--color-success` สำหรับข้อความแจ้งผลสำเร็จ แทนการผูกทุกอย่างเข้ากับชื่อสีดิบเพียงอย่างเดียว
3. สร้าง Design Tokens สำหรับเว็บไซต์
**Design Tokens** คือชุดค่าพื้นฐานที่ใช้กำหนดภาษาการออกแบบของผลิตภัณฑ์ เช่น สี ฟอนต์ ระยะห่าง มุมโค้ง เงา และความกว้างของเนื้อหา การจัดเก็บค่ากลุ่มนี้ใน CSS Custom Properties ทำให้ทีมพัฒนาและทีมออกแบบสามารถใช้มาตรฐานเดียวกันได้
เริ่มจากแบ่งตัวแปรเป็นหมวดหมู่ เช่น `color`, `space`, `radius`, `shadow` และ `font` แล้วกำหนดค่าที่เหมาะสมกับเว็บไซต์ ตัวอย่างเช่น ระยะห่าง 8, 16 และ 24 พิกเซลสามารถใช้เป็นชุดมาตรฐาน แทนการกำหนดตัวเลขใหม่ในทุกคอมโพเนนต์
| หมวดหมู่ | ตัวอย่างตัวแปร | การใช้งาน |
|---|---|---|
| สี | --color-primary | ปุ่มหลัก ลิงก์ และองค์ประกอบที่ต้องการเน้น |
| พื้นผิว | --color-surface | พื้นหลังการ์ด แผงข้อมูล และกล่องเนื้อหา |
| ตัวอักษร | --font-body | ฟอนต์หลักของเนื้อหาเว็บไซต์ |
| ระยะห่าง | --space-md | Padding และ Margin ที่ใช้ซ้ำ |
| มุมโค้ง | --radius-card | รูปทรงของการ์ดและกล่องข้อมูล |
| เงา | --shadow-card | เพิ่มระดับความลึกให้ส่วนประกอบ |
:root {
/* Colors */
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-text: #1e293b;
--color-muted: #64748b;
--color-surface: #ffffff;
--color-page: #f1f5f9;
--color-border: #e2e8f0;
/* Typography */
--font-body: system-ui, sans-serif;
--font-size-base: 16px;
--line-height-body: 1.6;
/* Spacing */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
/* Shape and depth */
--radius-card: 18px;
--radius-button: 10px;
--shadow-card: 0 10px 30px rgb(15 23 42 / 8%);
}
แนวคิดสำคัญ: ใช้ค่าตามบทบาทเมื่อกำหนด `--color-text` และ `--color-surface` ตามหน้าที่ แทนการตั้งชื่อแบบ `--black` และ `--white` การเปลี่ยนจาก Light Theme เป็น Dark Theme จะง่ายขึ้น เพราะเราเปลี่ยนค่าของบทบาทเดิมได้โดยไม่ต้องแก้ทุกคอมโพเนนต์
4. สร้าง Light Theme และ Dark Theme ด้วย data-theme
การสร้างธีมที่ดูแลรักษาง่ายควรใช้ตัวแปรเดียวกันในทุกคอมโพเนนต์ แล้วเปลี่ยนเฉพาะค่าของตัวแปรเมื่อเปลี่ยนธีม ตัวอย่างนี้ใช้ `data-theme="dark"` บนองค์ประกอบ `html` เพื่อระบุว่าเว็บไซต์กำลังอยู่ใน Dark Theme
ใน Light Theme เรากำหนดพื้นหลังหน้าเป็นสีอ่อน พื้นผิวเป็นสีขาว และข้อความเป็นสีเข้ม ส่วน Dark Theme จะเปลี่ยนพื้นหลังและพื้นผิวเป็นสีเข้ม พร้อมเลือกสีข้อความที่อ่านได้ชัดเจน วิธีนี้ช่วยลดการเขียน CSS ซ้ำและทำให้ธีมมีรูปแบบสม่ำเสมอ
:root {
color-scheme: light;
--color-page: #f1f5f9;
--color-surface: #ffffff;
--color-text: #1e293b;
--color-muted: #64748b;
--color-border: #e2e8f0;
}
:root[data-theme="dark"] {
color-scheme: dark;
--color-page: #0f172a;
--color-surface: #1e293b;
--color-text: #f1f5f9;
--color-muted: #cbd5e1;
--color-border: #334155;
}
body {
background: var(--color-page);
color: var(--color-text);
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
}
เหตุใดจึงใช้ data-theme แทนการเปลี่ยนคลาสทุกกล่อง
เมื่อเปลี่ยนค่า Custom Properties ที่ระดับ root องค์ประกอบลูกที่อ้างอิงตัวแปรจะรับค่าตามธีมใหม่โดยอัตโนมัติ จึงไม่จำเป็นต้องเพิ่มคลาส `dark` ให้กับการ์ด ปุ่ม และกล่องทุกชิ้น วิธีนี้ช่วยลดความซ้ำซ้อนและทำให้เพิ่มคอมโพเนนต์ใหม่ได้ง่ายขึ้น
ข้อสำคัญคือทุกองค์ประกอบต้องใช้ Design Tokens อย่างสม่ำเสมอ หากยังมี CSS ที่กำหนด `background: white` หรือ `color: black` แบบตายตัว ส่วนเหล่านั้นอาจไม่เปลี่ยนตามธีม จึงควรตรวจสอบและแทนค่าที่เหมาะสมด้วยตัวแปร
5. เชื่อม JavaScript เพื่อสลับธีมและจดจำการตั้งค่า
CSS สามารถกำหนดหน้าตาของแต่ละธีมได้ ส่วน JavaScript ทำหน้าที่เปลี่ยนค่า `data-theme` เมื่อผู้ใช้กดปุ่ม หากต้องการให้เว็บไซต์จดจำธีมระหว่างการเปิดหน้าใหม่ สามารถเก็บค่าที่ผู้ใช้เลือกไว้ใน `localStorage` ซึ่งจะคงอยู่ในเบราว์เซอร์เดิมจนกว่าผู้ใช้หรือเว็บไซต์จะลบข้อมูลนั้น
ตัวอย่างนี้ตรวจสอบค่าที่เคยบันทึกไว้ก่อน หากไม่มีค่าที่บันทึก จะใช้การตั้งค่าระบบผ่าน `prefers-color-scheme` เป็นค่าเริ่มต้น จากนั้นเมื่อผู้ใช้กดปุ่ม ระบบจะสลับธีมและบันทึกค่าที่เลือกไว้
const root = document.documentElement;
const themeButton = document.querySelector('#theme-toggle');
function applyTheme(theme) {
root.dataset.theme = theme;
themeButton.setAttribute(
'aria-pressed',
String(theme === 'dark')
);
themeButton.textContent = theme === 'dark'
? '☀️ เปลี่ยนเป็น Light Mode'
: '🌙 เปลี่ยนเป็น Dark Mode';
}
const savedTheme = localStorage.getItem('site-theme');
const systemPrefersDark = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
applyTheme(savedTheme || (systemPrefersDark ? 'dark' : 'light'));
themeButton.addEventListener('click', () => {
const nextTheme = root.dataset.theme === 'dark'
? 'light'
: 'dark';
applyTheme(nextTheme);
localStorage.setItem('site-theme', nextTheme);
});
ข้อควรระวังเกี่ยวกับ localStorageโค้ดตัวอย่างนี้เหมาะกับหน้าเว็บที่รันในเบราว์เซอร์และมี `localStorage` ให้ใช้งาน หากทดสอบในสภาพแวดล้อมที่จำกัดการจัดเก็บข้อมูล ควรมีการจัดการข้อผิดพลาดเพิ่มเติม ในเว็บไซต์ที่ต้องรองรับเบราว์เซอร์หลายรูปแบบ อาจใช้ `try...catch` ครอบการอ่านและเขียนข้อมูลได้
6. สร้างคอมโพเนนต์ที่เปลี่ยนธีมตาม Design Tokens
ตัวแปรจะมีประโยชน์มากที่สุดเมื่อองค์ประกอบทั้งหมดใช้ค่าจากชุดเดียวกัน ตัวอย่างเช่น การ์ดข้อมูลใช้ `--color-surface` สำหรับพื้นหลัง `--color-text` สำหรับหัวข้อ `--color-muted` สำหรับคำอธิบาย และ `--shadow-card` สำหรับเงา ส่วนปุ่มใช้ `--color-primary` เป็นสีพื้นและ `--radius-button` เป็นมุมโค้ง
การกำหนด CSS ของแต่ละคอมโพเนนต์ให้ยึดตามบทบาทของตัวแปร ทำให้ไม่จำเป็นต้องเขียน `.dark .card`, `.dark .button` และ `.dark .notice` แยกกันทุกครั้งที่เพิ่มธีมใหม่
.card {
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
padding: var(--space-lg);
}
.card__description {
color: var(--color-muted);
line-height: var(--line-height-body);
}
.button {
color: white;
background: var(--color-primary);
border: 0;
border-radius: var(--radius-button);
padding: 10px 16px;
font: inherit;
cursor: pointer;
}
.button:hover {
background: var(--color-primary-hover);
}
7. ทำให้ธีมรองรับการเข้าถึงและผู้ใช้ทุกกลุ่ม
ระบบธีมที่ดีไม่ได้พิจารณาเพียงความสวยงาม แต่ต้องคำนึงถึงความสามารถในการอ่านและการใช้งานด้วย ควรตรวจสอบความต่างระหว่างสีข้อความกับสีพื้นหลัง โดยเฉพาะข้อความขนาดเล็ก หลีกเลี่ยงการใช้สีเพียงอย่างเดียวเพื่อสื่อสถานะ และกำหนด focus indicator ที่มองเห็นได้ชัดเมื่อผู้ใช้ใช้งานด้วยแป้นพิมพ์
นอกจากนี้ควรใช้ปุ่มจริง เช่น `<button>` สำหรับการเปลี่ยนธีม เพราะรองรับการใช้งานด้วยแป้นพิมพ์และเทคโนโลยีช่วยเหลือได้ดีกว่าการใช้ `<div>` ที่ทำตัวเหมือนปุ่มโดยไม่มีการกำหนดพฤติกรรมเพิ่มเติม
button:focus-visible,
a:focus-visible {
outline: 3px solid var(--color-primary);
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
8. ตัวอย่างโปรเจกต์: Dashboard ที่สลับ Light/Dark Theme ได้
ต่อไปนี้คือหน้า Dashboard ตัวอย่างที่รวมแนวคิดจากบทเรียนไว้ในไฟล์เดียว สามารถคัดลอกไปบันทึกเป็น `index.html` แล้วเปิดด้วยเว็บเบราว์เซอร์ได้ทันที ภายในมี Design Tokens, การ์ดสถิติ, รายการงาน, ปุ่มเปลี่ยนธีม และการจดจำการตั้งค่าผ่าน `localStorage`
ลองกดปุ่มเปลี่ยนธีม แล้วรีเฟรชหน้าเว็บเพื่อทดสอบว่าค่าที่เลือกไว้ยังคงอยู่ จากนั้นลองปรับค่า `--color-primary` ในส่วน `:root` เพื่อเปลี่ยนสีหลักของ Dashboard ทั้งระบบ
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>Design Tokens Dashboard | EP16</title>
<style>
:root {
color-scheme: light;
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-page: #f1f5f9;
--color-surface: #ffffff;
--color-text: #1e293b;
--color-muted: #64748b;
--color-border: #e2e8f0;
--color-success: #15803d;
--color-success-bg: #dcfce7;
--font-body: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--radius-card: 18px;
--radius-button: 10px;
--shadow-card: 0 10px 30px rgb(15 23 42 / 7%);
}
:root[data-theme="dark"] {
color-scheme: dark;
--color-page: #0f172a;
--color-surface: #1e293b;
--color-text: #f1f5f9;
--color-muted: #cbd5e1;
--color-border: #334155;
--color-success: #86efac;
--color-success-bg: #14532d;
--shadow-card: 0 10px 30px rgb(0 0 0 / 18%);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--color-page);
color: var(--color-text);
font-family: var(--font-body);
line-height: 1.6;
transition: background-color 180ms ease, color 180ms ease;
}
.container {
width: min(1100px, calc(100% - 32px));
margin-inline: auto;
padding-block: var(--space-xl);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
display: grid;
place-items: center;
width: 46px;
height: 46px;
border-radius: 14px;
color: white;
background: var(--color-primary);
font-size: 23px;
font-weight: 800;
}
.brand h1 { margin: 0; font-size: clamp(20px, 4vw, 28px); line-height: 1.2; }
.brand p { margin: 4px 0 0; color: var(--color-muted); font-size: 13px; }
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 10px 15px;
border: 1px solid var(--color-border);
border-radius: var(--radius-button);
color: var(--color-text);
background: var(--color-surface);
font: inherit;
font-size: 14px;
font-weight: 650;
cursor: pointer;
transition: transform 160ms ease, background-color 160ms ease, border-color 160ms ease;
}
.button:hover { transform: translateY(-2px); }
.button:active { transform: translateY(0); }
.button-primary { background: var(--color-primary); color: #fff; border-color: transparent; }
.button-primary:hover { background: var(--color-primary-hover); }
button:focus-visible, a:focus-visible {
outline: 3px solid var(--color-primary);
outline-offset: 3px;
}
.welcome { margin-bottom: var(--space-lg); }
.welcome h2 { margin: 0 0 6px; font-size: clamp(22px, 4vw, 30px); }
.welcome p { margin: 0; color: var(--color-muted); }
.stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.card {
min-width: 0;
padding: var(--space-lg);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
background: var(--color-surface);
box-shadow: var(--shadow-card);
transition: background-color 180ms ease, border-color 180ms ease, transform 180ms ease;
}
.card:hover { transform: translateY(-3px); }
.stat-label { color: var(--color-muted); font-size: 14px; }
.stat-value { margin: 8px 0 4px; font-size: clamp(25px, 4vw, 34px); font-weight: 800; letter-spacing: -1px; }
.stat-note { color: var(--color-success); font-size: 13px; font-weight: 650; }
.content-grid {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.7fr);
gap: var(--space-lg);
}
.section-title { margin: 0 0 16px; font-size: 18px; }
.task-list { display: grid; gap: 12px; }
.task {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px;
border: 1px solid var(--color-border);
border-radius: 12px;
}
.task-icon {
display: grid;
place-items: center;
flex: 0 0 38px;
height: 38px;
border-radius: 11px;
background: var(--color-page);
font-size: 18px;
}
.task h3 { margin: 0; font-size: 15px; }
.task p { margin: 4px 0 0; color: var(--color-muted); font-size: 13px; }
.status {
display: inline-block;
margin-top: 8px;
padding: 3px 8px;
border-radius: 999px;
color: var(--color-success);
background: var(--color-success-bg);
font-size: 11px;
font-weight: 750;
}
.token-list { display: grid; gap: 14px; }
.token-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 13px; }
.token-name { color: var(--color-muted); }
.swatch { display: inline-flex; align-items: center; gap: 8px; font-weight: 650; }
.swatch-color { width: 20px; height: 20px; border-radius: 6px; background: var(--color-primary); border: 1px solid var(--color-border); }
.swatch-surface { background: var(--color-surface); }
.swatch-border { background: var(--color-border); }
.divider { height: 1px; background: var(--color-border); margin-block: 16px; }
.footer { padding-top: var(--space-xl); color: var(--color-muted); text-align: center; font-size: 12px; }
@media (max-width: 760px) {
.stats { grid-template-columns: 1fr; }
.content-grid { grid-template-columns: 1fr; }
.container { padding-block: 22px; }
.card { padding: 18px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
</head>
<body>
<main class="container">
<header class="topbar">
<div class="brand">
<div class="brand-mark" aria-hidden="true">D</div>
<div>
<h1>Design System</h1>
<p>CSS Variables Dashboard · EP16</p>
</div>
</div>
<button class="button" id="theme-toggle" type="button" aria-pressed="false">
🌙 เปลี่ยนเป็น Dark Mode
</button>
</header>
<section class="welcome" aria-labelledby="welcome-title">
<h2 id="welcome-title">ภาพรวมโปรเจกต์</h2>
<p>ตัวอย่างการใช้ Design Tokens ควบคุมรูปแบบเว็บไซต์จากจุดเดียว</p>
</section>
<section class="stats" aria-label="สถิติภาพรวม">
<article class="card">
<div class="stat-label">โปรเจกต์ทั้งหมด</div>
<div class="stat-value">24</div>
<div class="stat-note">↑ 12% จากเดือนก่อน</div>
</article>
<article class="card">
<div class="stat-label">งานที่เสร็จแล้ว</div>
<div class="stat-value">18</div>
<div class="stat-note">75% ของงานทั้งหมด</div>
</article>
<article class="card">
<div class="stat-label">สมาชิกในทีม</div>
<div class="stat-value">08</div>
<div class="stat-note">พร้อมทำงานร่วมกัน</div>
</article>
</section>
<section class="content-grid" aria-label="รายละเอียดโปรเจกต์">
<article class="card">
<h2 class="section-title">งานล่าสุด</h2>
<div class="task-list">
<div class="task">
<div class="task-icon" aria-hidden="true">🎨</div>
<div>
<h3>กำหนด Design Tokens</h3>
<p>จัดระเบียบสี ฟอนต์ ระยะห่าง และมุมโค้ง</p>
<span class="status">เสร็จแล้ว</span>
</div>
</div>
<div class="task">
<div class="task-icon" aria-hidden="true">🧩</div>
<div>
<h3>สร้างคอมโพเนนต์การ์ด</h3>
<p>ใช้ตัวแปรร่วมกันเพื่อให้รูปแบบสม่ำเสมอ</p>
<span class="status">เสร็จแล้ว</span>
</div>
</div>
<div class="task">
<div class="task-icon" aria-hidden="true">🌙</div>
<div>
<h3>ระบบเปลี่ยนธีม</h3>
<p>สลับ Light และ Dark พร้อมจดจำการตั้งค่า</p>
<span class="status">เสร็จแล้ว</span>
</div>
</div>
</div>
</article>
<aside class="card" aria-labelledby="tokens-title">
<h2 class="section-title" id="tokens-title">Design Tokens</h2>
<div class="token-list">
<div class="token-row">
<span class="token-name">Primary Color</span>
<span class="swatch"><span class="swatch-color" aria-hidden="true"></span>#2563eb</span>
</div>
<div class="divider"></div>
<div class="token-row">
<span class="token-name">Surface</span>
<span class="swatch"><span class="swatch-color swatch-surface" aria-hidden="true"></span>Surface</span>
</div>
<div class="divider"></div>
<div class="token-row">
<span class="token-name">Border</span>
<span class="swatch"><span class="swatch-color swatch-border" aria-hidden="true"></span>Border</span>
</div>
<div class="divider"></div>
<div class="token-row">
<span class="token-name">Card Radius</span>
<strong>18px</strong>
</div>
<div class="token-row">
<span class="token-name">Base Spacing</span>
<strong>16px</strong>
</div>
</div>
<p style="color: var(--color-muted); font-size: 13px; margin: 20px 0 0;">
ลองเปลี่ยนธีมเพื่อดูว่าคอมโพเนนต์ทั้งหมดใช้ตัวแปรชุดเดียวกัน
</p>
</aside>
</section>
<footer class="footer">
HTML & CSS Learning Series · ตอนที่ 16: CSS Variables และ Design System
</footer>
</main>
<script>
const root = document.documentElement;
const themeButton = document.querySelector('#theme-toggle');
const storageKey = 'levellance-ep16-theme';
function readSavedTheme() {
try {
return localStorage.getItem(storageKey);
} catch (error) {
return null;
}
}
function saveTheme(theme) {
try {
localStorage.setItem(storageKey, theme);
} catch (error) {
// ธีมยังใช้งานได้ แม้เบราว์เซอร์ไม่อนุญาตให้บันทึกข้อมูล
}
}
function applyTheme(theme) {
const safeTheme = theme === 'dark' ? 'dark' : 'light';
root.dataset.theme = safeTheme;
themeButton.setAttribute('aria-pressed', String(safeTheme === 'dark'));
themeButton.textContent = safeTheme === 'dark'
? '☀️ เปลี่ยนเป็น Light Mode'
: '🌙 เปลี่ยนเป็น Dark Mode';
}
const savedTheme = readSavedTheme();
const prefersDark = window.matchMedia
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
applyTheme(savedTheme || (prefersDark ? 'dark' : 'light'));
themeButton.addEventListener('click', () => {
const nextTheme = root.dataset.theme === 'dark' ? 'light' : 'dark';
applyTheme(nextTheme);
saveTheme(nextTheme);
});
</script>
</body>
</html>
9. แบบฝึกหัด: ทดลองปรับ Design Tokens
หลังจากทดลองหน้า Dashboard แล้ว ให้แก้ไขโค้ดด้วยตนเองเพื่อให้เข้าใจว่าตัวแปรส่งผลต่อส่วนต่าง ๆ อย่างไร แนะนำให้ทดลองทีละข้อและสังเกตผลลัพธ์ก่อนเปลี่ยนไปทำข้อถัดไป
แบบฝึกหัดที่ 1: เปลี่ยนสีหลักของเว็บไซต์
เปลี่ยนค่า `--color-primary` จาก `#2563eb` เป็น `#7c3aed` แล้วสังเกตสีของโลโก้และปุ่มเปลี่ยนธีม จากนั้นลองเปลี่ยน `--color-primary-hover` ให้เป็นสีที่เข้มขึ้นเพื่อให้เห็นความแตกต่างเมื่อเลื่อนเมาส์เหนือปุ่ม
แบบฝึกหัดที่ 2: ปรับระยะห่างและมุมโค้ง
เปลี่ยน `--space-lg` จาก `24px` เป็น `32px` แล้วตรวจดูช่องว่างภายในกล่องการ์ด จากนั้นเปลี่ยน `--radius-card` จาก `18px` เป็น `8px` เพื่อเปรียบเทียบรูปทรงของการ์ดก่อนและหลัง
แบบฝึกหัดที่ 3: เพิ่มสีสถานะใหม่
เพิ่มตัวแปร `--color-warning` สำหรับสถานะเตือนใน `:root` และกำหนดค่าที่เหมาะสมใน Dark Theme จากนั้นสร้างคลาส `.status-warning` ที่ใช้ตัวแปรสีข้อความและพื้นหลังดังกล่าว โดยตรวจสอบให้ข้อความยังอ่านได้ชัดเจน
แบบฝึกหัดที่ 4: จดจำธีมด้วย localStorage
ทดลองเปลี่ยนชื่อ `storageKey` และรีเฟรชหน้าเว็บ ตรวจสอบว่าธีมถูกจดจำไว้หรือไม่ จากนั้นลบค่าที่เก็บไว้ผ่าน Developer Tools เพื่อดูว่าระบบกลับไปใช้การตั้งค่าธีมเริ่มต้นอย่างไร
10. ปัญหาที่พบบ่อยและแนวทางแก้ไข
| อาการ | สาเหตุที่เป็นไปได้ | แนวทางตรวจสอบ |
|---|---|---|
| สีไม่เปลี่ยนเมื่อสลับธีม | องค์ประกอบกำหนดสีแบบตายตัว | เปลี่ยนค่าเป็น `var(--color-text)` หรือ `var(--color-surface)` ตามบทบาท |
| ตัวแปรไม่ทำงาน | ลืมใส่ `--` หรือเรียกชื่อไม่ตรง | ตรวจชื่อในจุดประกาศและจุดเรียกใช้ให้ตรงกัน |
| ค่าตัวแปรใช้กับ property ไม่ได้ | ค่าที่เก็บไม่ตรงกับรูปแบบที่ property ต้องการ | ตรวจค่าของ Custom Property และ property ที่นำไปใช้ |
| ธีมไม่คงอยู่หลังรีเฟรช | ไม่มีการบันทึกหรืออ่าน localStorage | ตรวจ `storageKey` และข้อจำกัดของเบราว์เซอร์ |
| ตัวแปรจากส่วนหนึ่งไม่ถูกใช้อีกส่วน | ขอบเขตการสืบทอดไม่ครอบคลุมองค์ประกอบเป้าหมาย | ตรวจตำแหน่งที่ประกาศ Custom Property และดูว่ามีการเขียนทับหรือไม่ |
| ธีมมืดอ่านยาก | ค่าสีข้อความและพื้นหลังมีความต่างไม่เพียงพอ | ตรวจ contrast และทดสอบทั้งข้อความปกติและข้อความขนาดใหญ่ |
11. สรุปแนวคิดสำคัญ
CSS Custom Properties ช่วยให้เว็บไซต์ใช้ค่าการออกแบบร่วมกันได้ โดยประกาศชื่อตัวแปรด้วย `--name` และเรียกผ่าน `var(--name)` การจัดตัวแปรเป็น Design Tokens ทำให้แก้ไขสี ฟอนต์ ระยะห่าง และรูปแบบคอมโพเนนต์จากจุดศูนย์กลางได้ง่ายขึ้น
การเปลี่ยนธีมด้วย `data-theme` เป็นแนวทางที่ยืดหยุ่น เพราะเปลี่ยนค่าตัวแปรในระดับ root แล้วให้องค์ประกอบต่าง ๆ ปรับตาม ส่วน JavaScript และ `localStorage` ช่วยให้ผู้ใช้สลับธีมและเก็บการตั้งค่าไว้ได้ อย่าลืมตรวจสอบการเข้าถึง ความต่างของสี การใช้งานด้วยแป้นพิมพ์ และการเคารพการตั้งค่า `prefers-reduced-motion`
✅ ภารกิจหลังเรียนสร้างหน้าเว็บหนึ่งหน้าที่มี Design Tokens อย่างน้อย 8 ตัวแปร ใช้ตัวแปรเหล่านั้นกับการ์ด ปุ่ม และข้อความ พร้อมทำ Light/Dark Theme ให้ใช้งานได้จริง และทดสอบว่าการตั้งค่ายังคงอยู่หลังรีเฟรชหน้าเว็บ
ระบบออกแบบที่ดีไม่ได้ทำให้ทุกหน้าตาเหมือนกัน แต่ทำให้ทุกองค์ประกอบพูดภาษาเดียวกันและเปลี่ยนแปลงไปด้วยกันได้อย่างมีระเบียบ
Reach within 500px of the page end and stay for at least 5 minutes to earn EXP once per day.
⏳ Reading timer is running...