Guest Adventurer 🥉 Lv.1
🪙 50
🔷 10
← กลับคลังเนื้อหา
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-mdPadding และ 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 ให้ใช้งานได้จริง และทดสอบว่าการตั้งค่ายังคงอยู่หลังรีเฟรชหน้าเว็บ
ระบบออกแบบที่ดีไม่ได้ทำให้ทุกหน้าตาเหมือนกัน แต่ทำให้ทุกองค์ประกอบพูดภาษาเดียวกันและเปลี่ยนแปลงไปด้วยกันได้อย่างมีระเบียบ
อ่านจนถึงระยะ 500px ก่อนท้ายหน้าและอยู่ในหน้านี้อย่างน้อย 5 นาที เพื่อรับ EXP วันละครั้ง

⏳ กำลังจับเวลาอ่าน...

🌐
เลือกภาษา / Select Language
21 World Languages Supported