บทเรียน 62 · Frontend · Basic HTML & CSS

State ของ element — hover, focus, active, disabled

~18 นาที · ควรผ่าน บทเรียน 61 (Flexbox) มาก่อน เพราะบทนี้ใช้ selector กับ class เป็นแล้ว

จนถึงบทที่แล้ว CSS ที่เราเขียนคือ "หน้าตาตอนอยู่เฉย ๆ" — element หนึ่งตัวมีหน้าตาแบบเดียวตลอดชีวิต
บทนี้เพิ่มมิติที่สอง: หน้าตาที่เปลี่ยนตามสิ่งที่ผู้ใช้กำลังทำอยู่ เอาเมาส์ชี้ · กด Tab มาถึง · กำลังกดค้าง · หรือถูกปิดใช้งานไว้
ของพวกนี้เขียนด้วย CSS ล้วน ไม่ต้องมี JavaScript แม้แต่บรรทัดเดียว
แต่มันมีกับดักสองอันที่ทำให้เว็บใช้ไม่ได้จริงกับคนบางกลุ่ม และทั้งสองอันเป็นสิ่งที่คนเขียนเว็บใหม่ ๆ ทำผิดกันแทบทุกคน — บทนี้ใช้เวลาครึ่งหนึ่งไปกับสองอันนั้น

🧰 ของที่ใช้ตรวจในบทนี้ (รันวันที่ 27 สิงหาคม 2026)

Chrome 148.0.7778.280 (macOS)
CSS Selectors Level 4        # นิยามของ :hover :focus :focus-visible :active
HTML Living Standard         # นิยามของ :enabled / :disabled

กล่อง "ผลลัพธ์จริง" ทุกกล่องในบทนี้เอาโค้ดชุดเดียวกับที่โชว์ข้างบนไปเรนเดอร์จริง
บทนี้ต้องเอาเมาส์ไปชี้ ไปกด ไปกด Tab จริง ๆ ถึงจะได้ประโยชน์ — อ่านผ่านตาอย่างเดียวจะไม่เห็นอะไรเลย เพราะทุกอย่างในบทนี้ซ่อนอยู่หลัง "การกระทำของผู้ใช้"

1 · State คืออะไร — element ตัวเดิม แต่หน้าตาไม่เหมือนเดิม

ลองนึกถึงปุ่ม "บันทึก" ปุ่มเดียวในหน้าเว็บ ปุ่มนั้นมีหลายช่วงชีวิต:

ห้าอย่างนี้คือ element ตัวเดียวกัน ไม่ใช่ห้าปุ่ม
สิ่งที่ CSS ให้เรามาคือวิธีเขียนกฎแยกไว้คนละบรรทัด แล้วเบราว์เซอร์จะสลับให้เองตามสิ่งที่ผู้ใช้ทำ เราไม่ต้องเขียนโค้ดสั่งสลับ

🛗 เหมือนปุ่มลิฟต์

ปุ่มเรียกลิฟต์ในตึกเป็นปุ่มเดียว แต่มันบอกอะไรกับคุณหลายอย่าง: ตอนปกติมันเป็นปุ่มเทา ๆ · ตอนคุณกดลงไปมันยุบลงและสว่างขึ้น · ตอนลิฟต์กำลังซ่อมมันจะไม่สว่างเลยแม้จะกด
คุณไม่ต้องอ่านคู่มือ คุณรู้จากหน้าตาของมันล้วน ๆ ว่าตอนนี้มันอยู่ในสถานะไหน
CSS state ก็ทำงานแบบเดียวกัน: เปลี่ยนหน้าตา = สื่อสารสถานะโดยไม่ต้องมีตัวหนังสือ
ที่ไม่เหมือนกัน: ปุ่มลิฟต์รู้ตัวเองว่าลิฟต์เสีย เพราะสายไฟต่อถึงกัน แต่ปุ่มใน CSS ไม่รู้อะไรเลยเกี่ยวกับระบบของคุณ — มันจางลงเพราะคุณ (หรือ JavaScript ของคุณ) ไปแปะคำว่า disabled ให้มันเท่านั้น ถ้าลืมแปะ ปุ่มจะสดใสเหมือนเดิมทั้งที่ระบบพังไปแล้ว

ลองของจริงเลย ปุ่มสองปุ่มข้างล่างนี้เขียนด้วย CSS ล้วน ไม่มี JavaScript
เอาเมาส์ไปชี้ · กดค้าง · แล้วลองคลิกในกล่องแล้วกด Tab

<style>
  .btn {
    font: inherit;
    padding: 10px 20px;
    border: 0;
    border-radius: 8px;
    background: #b49a9c;
    color: #ffffff;
    cursor: pointer;
  }
  .btn:hover          { background: #96797c; }
  .btn:active         { background: #6f5559; }
  .btn:focus-visible { outline: 3px solid #1a73e8; outline-offset: 2px; }
  .btn:disabled       { background: #e4e4e4; color: #a0a0a0; cursor: not-allowed; }
</style>

<button class="btn">ปุ่มที่ใช้งานได้</button>
<button class="btn" disabled>ปุ่มที่ถูกปิดไว้</button>

สังเกตว่าเราเขียนกฎ 4 บรรทัดเพิ่มจากของเดิม แล้วได้พฤติกรรมครบทั้งชุด
สิ่งที่ทำให้มันเป็นไปได้คือเครื่องหมาย : ที่ต่อท้าย selector — หัวข้อถัดไปเราจะไล่ทีละตัวว่าแต่ละตัวจับ "ช่วงเวลา" ไหน

🏷️ ติดป้ายศัพท์
pseudo-class (ซูโด-คลาส)
ส่วนที่ต่อท้าย selector ด้วย : เช่น :hover :disabled แปลว่า "เลือก element นี้ เฉพาะตอนที่ เงื่อนไขนี้เป็นจริง" คำว่า pseudo แปลว่า "เสมือน" — มันทำตัวเหมือน class ที่เบราว์เซอร์แอบแปะให้เองแล้วดึงออกเอง โดยที่ HTML ของเราไม่ต้องเปลี่ยนเลย
พิมพ์ถาม AI ได้ว่า: "ใน CSS มี pseudo-class อะไรบ้างที่ใช้กับ state ของ form control และตัวไหนใช้กับ element ทั่วไปได้"
state (ของ UI)
สถานะที่ component หนึ่งกำลังอยู่ ณ ตอนนั้น — enabled · hover · focus · pressed · disabled เป็นคำที่ทีม design ใช้กันมากกว่าคำว่า pseudo-class
ถาม senior ได้ว่า: "ปุ่มในดีไซน์ตัวนี้ ทีม design กำหนดสีของ state ไหนไว้บ้าง มีสีของ pressed กับ disabled ให้ไหม หรือให้ผมคิดเอง"

2 · :hover — ตอนที่เมาส์ลอยอยู่เหนือมัน

:hover เป็นจริงตั้งแต่วินาทีที่ตัวชี้เมาส์เข้ามาอยู่ในพื้นที่ของ element และเป็นเท็จทันทีที่เมาส์ออกไป ไม่เกี่ยวกับการคลิกเลย
ใช้กับ element อะไรก็ได้ ไม่ใช่แค่ปุ่ม — <div> <tr> <li> ก็ hover ได้หมด

<style>
  .row {
    padding: 10px 14px;
    border-bottom: 1px solid #e0e0e0;
    transition: background 0.15s;
  }
  .row:hover { background: #f3ece9; }
</style>

<div class="row">ผัดกะเพราหมูสับ</div>
<div class="row">ต้มยำกุ้งน้ำข้น</div>
<div class="row">ข้าวมันไก่ต้ม</div>

transition ที่ใส่ไว้ทำให้สีค่อย ๆ เปลี่ยนแทนที่จะกระโดด — ไม่ใส่ก็ทำงานได้ แต่ใส่แล้วรู้สึกนุ่มขึ้นมาก และต้องใส่ที่กฎปกติ ไม่ใช่ที่ :hover ไม่งั้นตอนเมาส์ออกจะกระตุกกลับทันที

⚠️ กับดักที่ 1 — บนมือถือไม่มี hover

โทรศัพท์ไม่มีตัวชี้ที่ "ลอยอยู่เหนือ" อะไรได้ มีแต่นิ้วที่แตะหรือไม่แตะ
ผลคือ ถ้าคุณเอาข้อมูลที่จำเป็นไปซ่อนไว้หลัง :hover เช่นปุ่มลบที่โผล่มาตอน hover · ราคาที่โผล่มาตอน hover · คำอธิบายที่โผล่มาตอน hover → คนใช้มือถือจะไม่มีวันเห็นมันเลย ไม่ใช่ "เห็นยาก" แต่คือไม่มีทางเห็น

กฎที่ปลอดภัย: :hover ใช้ตกแต่งได้ ใช้ซ่อนของไม่ได้
ถ้าเนื้อหาชิ้นไหนหายไปเมื่อไม่ hover แล้วผู้ใช้ทำงานต่อไม่ได้ แปลว่าคุณใช้ผิดที่

ถ้าอยากเขียนสไตล์ที่ เฉพาะเครื่องที่มีเมาส์จริงเท่านั้นถึงจะใช้ ให้ครอบด้วย media query ที่ถามเรื่องความสามารถของอุปกรณ์ ไม่ใช่ถามความกว้างจอ:

/* ใช้กฎนี้เฉพาะเครื่องที่มีตัวชี้ซึ่ง hover ได้จริง (เมาส์ / trackpad) */
@media (hover: hover) {
  .card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.18); }
}

ทำไมต้องถาม (hover: hover) แทนที่จะถามความกว้างจอ? เพราะจอกว้างไม่ได้แปลว่ามีเมาส์ — แท็บเล็ตจอใหญ่ก็ไม่มี hover ส่วนหน้าต่างเบราว์เซอร์ที่ย่อแคบ ๆ บนเครื่อง desktop ก็ยังมีเมาส์อยู่ ถามให้ตรงกับสิ่งที่อยากรู้เสมอ

3 · :focus กับ :focus-visible — จุดที่คนทำเว็บพังกันมากที่สุด

focus แปลว่า "ตอนนี้คีย์บอร์ดคุยอยู่กับ element ตัวนี้"
ในหน้าเว็บหนึ่งหน้า มี element ที่ได้ focus ได้แค่ตัวเดียวในเวลาเดียวกัน ถ้าคุณพิมพ์ตัวอักษร มันจะไปโผล่ที่ช่องที่ focus อยู่ ถ้าคุณกด Enter มันจะกดปุ่มที่ focus อยู่
วิธีย้าย focus มีหลายทาง: คลิก · กด Tab ไปข้างหน้า · Shift+Tab ถอยหลัง · สั่งด้วยเสียง · หรือใช้ปุ่มบนอุปกรณ์ช่วยเหลือ

🎤 เหมือนไมค์ตัวเดียวในห้องประชุม

ห้องประชุมมีไมค์ไร้สายอยู่ตัวเดียว ใครถือไมค์อยู่ คำพูดของคนนั้นจะออกลำโพง
คนที่ถือไมค์ = element ที่ได้ focus · เสียงที่ออกลำโพง = ตัวอักษรที่พิมพ์
และเหตุผลที่ห้องประชุมดี ๆ ต้องเห็นได้ชัดว่าใครถือไมค์อยู่ ก็คือเหตุผลเดียวกับที่ element ที่ focus ต้องมีกรอบให้เห็น
ที่ไม่เหมือนกัน: ในห้องประชุมคุณมองเห็นคนที่ถือไมค์ได้เองด้วยตา แต่ในหน้าเว็บ ถ้าคุณลบกรอบ focus ทิ้ง มันจะไม่มีร่องรอยอะไรเหลือให้ดูเลย คนที่ใช้คีย์บอร์ดจะกด Tab ไปเรื่อย ๆ โดยไม่รู้ว่าตัวเองอยู่ตรงไหนของหน้า

ทีนี้ปัญหาจริงที่ทำให้เกิด :focus-visible ขึ้นมา:

สถานการณ์:focus:focus-visible
กด Tab มาถึงปุ่มตรง ✅ตรง ✅
คลิกปุ่มด้วยเมาส์ตรง ✅ (กรอบที่คุณเขียนไว้ที่ :focus จะค้างอยู่)ไม่ตรง — เบราว์เซอร์ถือว่าไม่ต้องโชว์กรอบ
คลิกในช่องกรอกข้อความตรง ✅ตรง ✅ (เพราะต้องเห็นว่าพิมพ์ตรงไหน)

แถวกลางคือหัวใจ — สมัยก่อนที่มีแต่ :focus ทุกครั้งที่ผู้ใช้เมาส์คลิกปุ่ม จะมีกรอบดำ ๆ ค้างอยู่รอบปุ่มจนกว่าจะไปคลิกที่อื่น นักออกแบบเกลียดมันมาก
วิธีที่คนแก้กันคือเขียนบรรทัดนี้ — และมันคือบรรทัดที่ทำลาย accessibility ของเว็บมากที่สุดในประวัติศาสตร์:

⚠️ กับดักที่ 2 — outline: none เปล่า ๆ
/* ห้ามเขียนแบบนี้เด็ดขาด */
button:focus { outline: none; }

บรรทัดนี้ลบกรอบทิ้งทุกกรณี รวมทั้งกรณีที่คนกด Tab มา
ผลที่ตามมาไม่ใช่ "หน้าเว็บสะอาดขึ้น" แต่คือ คนที่ใช้คีย์บอร์ดอย่างเดียวใช้เว็บของคุณไม่ได้อีกต่อไป เขาจะกด Tab แล้วไม่รู้ว่าตอนนี้อยู่ที่ปุ่มไหน กด Enter ไปก็ไม่รู้ว่ากดอะไร
คนกลุ่มนี้ไม่ได้มีแค่ผู้พิการ — รวมถึงคนที่เมาส์เสีย คนที่ใช้ laptop บนรถไฟ และคนที่พิมพ์เร็วจนไม่อยากละมือจากคีย์บอร์ด

ทางแก้ที่ถูกคือ อย่าลบ — ให้เปลี่ยนแทน โดยย้ายกรอบไปไว้ที่ :focus-visible ซึ่งเบราว์เซอร์จะเปิดให้เฉพาะตอนที่มัน "คิดว่าผู้ใช้ต้องการเห็นกรอบ" (คือตอนใช้คีย์บอร์ด)

<style>
  .pill {
    font: inherit;
    padding: 10px 18px;
    margin-right: 8px;
    border: 1px solid #c9bcbd;
    border-radius: 999px;
    background: #ffffff;
    cursor: pointer;
  }

  /* ✅ ถูก: ลบของ default แล้ววาดของเราแทน ในเงื่อนไขที่ควรเห็น */
  .pill:focus         { outline: none; }
  .pill:focus-visible { outline: 3px solid #1a73e8; outline-offset: 3px; }

  /* ❌ ผิด: ลบทิ้งเฉย ๆ ไม่วาดอะไรกลับมา */
  .ghost:focus { outline: none; }
</style>

<button class="pill">ปุ่มที่ทำถูก</button>
<button class="pill ghost">ปุ่มที่ทำผิด</button>

ลองเองตรงนี้: คลิกในกล่องผลลัพธ์ข้างบนหนึ่งครั้ง แล้วกด Tab ไปเรื่อย ๆ
ปุ่มซ้ายจะมีวงแหวนสีน้ำเงินโผล่มาให้เห็นชัดว่าคุณอยู่ตรงไหน
ส่วนปุ่มขวา — คุณจะรู้ว่าตัวเองยืนอยู่บนมันก็ต่อเมื่อกด Enter แล้วมีอะไรเกิดขึ้นเท่านั้น
ความรู้สึกอึดอัดตอนนั้น คือความรู้สึกที่ผู้ใช้จริงเจอทุกหน้าที่คุณเขียน outline: none ทิ้งไว้

ต้องเขียน :focus { outline: none } ด้วยไหม ในเมื่อจะวาดกรอบใหม่อยู่แล้ว?
ผมลองวัดในเบราว์เซอร์ที่ใช้ตรวจบทนี้: เขียนแค่ :focus-visible { outline: 3px solid blue } อย่างเดียว ไม่เขียน :focus เลย แล้วกด Tab มาที่ปุ่ม ค่าที่อ่านได้จาก getComputedStyle คือ rgb(0, 0, 255) solid 3px — กรอบของเราทับกรอบ default ไปเลย ไม่ได้ซ้อนกันสองชั้น เพราะ outline เป็น property เดียว element ตัวหนึ่งจึงมีได้ค่าเดียว
บรรทัด :focus { outline: none } จึงเป็นบรรทัดกันเหนียว ไว้กวาดกรอบ default ทิ้งในเบราว์เซอร์รุ่นที่ยังวาดกรอบนั้นที่ :focus เฉย ๆ (ไม่ใช่ที่ :focus-visible) — เขียนไว้ไม่เสียหาย แต่ถ้าเขียนต้องระวังลำดับ
เขียน :focus ก่อน :focus-visible เสมอ เพราะสองอันนี้ specificity เท่ากัน ตัวที่เขียนทีหลังจะชนะ — ถ้าสลับลำดับ กรอบสวย ๆ ของคุณจะโดน none ทับหายไปทั้งหมด

🏷️ ติดป้ายศัพท์
focus ring / focus indicator
กรอบที่ล้อมรอบ element ที่กำลังได้ focus — ในโค้ดคือ property outline
ข้อดีของ outline เมื่อเทียบกับ border คือ มันไม่กินพื้นที่ในการจัดวาง ปุ่มจึงไม่ขยับตอนกรอบโผล่มา ส่วน outline-offset คือระยะห่างระหว่างกรอบกับตัว element
ถาม senior ได้ว่า: "โปรเจกต์เรามี focus ring กลางไหม หรือให้ผมเขียน :focus-visible เองในแต่ละ component"
keyboard navigation / tab order
การใช้เว็บด้วยคีย์บอร์ดล้วน โดยกด Tab ไล่ไปทีละ element ที่โฟกัสได้ ลำดับที่ไล่คือลำดับที่ element เรียงอยู่ใน HTML ไม่ใช่ลำดับที่ตาเห็นบนจอ (นี่คือเหตุผลหนึ่งที่ทำให้การใช้ CSS ย้ายของไปมามาก ๆ เป็นเรื่องอันตราย)
พิมพ์บอก AI ได้ว่า: "ช่วยตรวจหน้านี้ให้หน่อยว่า tab order เดินตามลำดับที่ผู้ใช้เห็นบนจอหรือเปล่า และมี element ไหนที่โฟกัสได้แต่ไม่มี focus indicator"
WCAG
ชื่อย่อของมาตรฐาน Web Content Accessibility Guidelines ของ W3C เป็นเอกสารที่บอกว่า "เว็บที่คนทุกกลุ่มใช้ได้ ต้องทำอะไรบ้าง" ข้อ 2.4.7 Focus Visible คือข้อที่พูดถึงกรอบ focus โดยตรง และหลายองค์กร (รวมถึงงานราชการหลายประเทศ) บังคับตามมาตรฐานนี้ในสัญญาจ้าง
ถาม senior ได้ว่า: "โปรเจกต์นี้ต้องผ่าน WCAG ระดับไหน AA หรือ AAA มี checklist ที่ทีมใช้ตรวจก่อนส่งงานไหม"

4 · :active — ตอนกำลังกดค้างอยู่

:active เป็นจริงในช่วงเวลาสั้น ๆ คือตั้งแต่ปุ่มเมาส์กดลง จนถึงตอนปล่อย
สไลด์เรียก state นี้ว่า Pressed ซึ่งเป็นคำที่ทีม design ใช้ ส่วนใน CSS มันชื่อ :active — คนละคำ ของอันเดียวกัน จำไว้ว่าเวลาคุยกับ designer เขาจะพูดว่า "pressed" ไม่ใช่ "active"

<style>
  .push {
    font: inherit;
    padding: 12px 24px;
    border: 0;
    border-radius: 8px;
    background: #b49a9c;
    color: #ffffff;
    cursor: pointer;
  }
  .push:active { background: #6f5559; }
  .push:hover  { background: #96797c; }
</style>

<button class="push">กดค้างที่ปุ่มนี้</button>

โค้ดนี้เขียน :active ไว้ก่อน :hover
ตอนคุณเอาเมาส์ไปกดปุ่มค้างไว้ ทั้งสองเงื่อนไขเป็นจริงพร้อมกัน (เมาส์อยู่เหนือปุ่ม + กำลังกด)
คิดว่าปุ่มจะเป็นสีอะไร — สีเข้มสุดของ :active หรือสีกลางของ :hover? เพราะอะไร?

ได้ สีกลางของ :hover — สีเข้มของ :active ไม่มีวันโผล่ตอนกดด้วยเมาส์

เหตุผล: .push:active กับ .push:hover มี specificity เท่ากันเป๊ะ (class หนึ่งตัว + pseudo-class หนึ่งตัว เท่ากันทั้งคู่) เมื่อ specificity เท่ากัน CSS ตัดสินด้วยกฎเดียว: ตัวที่เขียนทีหลังชนะ
ตอนกดค้าง ทั้งสองกฎเป็นจริงพร้อมกัน :hover ที่อยู่บรรทัดล่างจึงทับ :active

วิธีแก้คือสลับลำดับให้ :active อยู่ล่างสุด
ลำดับที่ปลอดภัยสำหรับปุ่มทั่วไปคือ :hover → :focus-visible → :active
ส่วนลิงก์ <a> มีลำดับที่ต้องท่องเพิ่มคือ LVHA = :link → :visited → :hover → :active (คนจำกันว่า "LoVe—HAte") เพราะถ้าเอา :visited ไว้ล่าง ลิงก์ที่เคยเข้าแล้วจะไม่เปลี่ยนสีตอน hover เลย

โค้ดชุดเดียวกันนี้เอาไปเรนเดอร์จริงแล้วเป็นแบบนี้ — ลองกดค้างดูเอง จะเห็นว่ามันไม่เข้มสุดจริง ๆ

5 · :disabled และ :enabled — ปิดจริง ไม่ใช่แค่ทำให้จาง

disabled เป็นattribute ใน HTML ไม่ใช่ CSS พอแปะเข้าไป เบราว์เซอร์จะทำสามอย่างให้ทันทีโดยเราไม่ต้องเขียนอะไรเลย:

  1. กดแล้วไม่มีอะไรเกิดขึ้น — event click ไม่ยิงออกมาเลย
  2. กด Tab ข้ามมันไป — โฟกัสไม่เข้า
  3. ถ้าอยู่ในฟอร์ม ค่าของมันจะไม่ถูกส่งไปกับฟอร์ม

ส่วน :disabled กับ :enabled ใน CSS คือ pseudo-class ที่ตามหาว่าตัวไหนถูกปิดอยู่ เพื่อให้เราเปลี่ยนหน้าตาตาม

🚪 เหมือนประตูที่ล็อกกับประตูที่แปะป้าย

การใส่ attribute disabled = ล็อกประตูจริง ๆ ผลักยังไงก็ไม่เปิด
การเขียน CSS ให้ปุ่มจางลงเฉย ๆ = แค่แปะป้าย "ปิดปรับปรุง" ไว้หน้าประตูที่ไม่ได้ล็อก ดูเหมือนปิด แต่ใครผลักก็เข้าได้อยู่ดี
ของจริงต้องทำทั้งสองอย่าง: ล็อกด้วย attribute (เพื่อความถูกต้อง) และแปะป้ายด้วย CSS (เพื่อให้คนเห็น)
ที่ไม่เหมือนกัน: ประตูที่ล็อกอยู่ ต่อให้ไม่มีป้าย คนก็รู้ทันทีที่ผลักแล้วไม่เปิด แต่ปุ่มที่ disabled โดยไม่ทำให้จาง ผู้ใช้จะกดแล้วกดอีกหลายรอบ เพราะไม่มีอะไรบอกว่ามันพัง เขาจะคิดว่าเว็บของคุณค้าง

ข้อควรระวังที่คนไม่ค่อยรู้: :enabled กับ :disabled ใช้ได้กับ form control เท่านั้น ผมลองในเบราว์เซอร์แล้วได้ผลนี้:

ทดสอบด้วย el.matches(…)ผลจริงใน Chrome 148
<input> ธรรมดา ตรงกับ :enabled ไหมtrue
<div> ตรงกับ :enabled ไหมfalse
<a href="#"> ตรงกับ :enabled ไหมfalse
<button> ที่อยู่ใน <fieldset disabled> ตรงกับ :disabled ไหมtrue — ปิดยกชุดได้
เรียก .click() ใส่ปุ่มที่ disabled — handler ยิงกี่ครั้ง0 ครั้ง (ปุ่มปกติได้ 1)

แถวสุดท้ายคือหลักฐานว่า disabled ปิดของจริง ไม่ใช่แค่หน้าตา — แม้แต่โค้ดที่สั่งกดตรง ๆ ก็ยังไม่ผ่าน
ส่วนสองแถวกลางแปลว่า ถ้าคุณทำ "ปุ่ม" ด้วย <div> คุณจะไม่ได้อะไรจาก disabled เลย — เป็นอีกหนึ่งเหตุผลที่ปุ่มควรเป็น <button> จริง ๆ

<style>
  input, button {
    font: inherit;
    padding: 8px 12px;
    border: 1px solid #c9bcbd;
    border-radius: 6px;
  }
  input:enabled  { background: #ffffff; }
  input:disabled { background: #f0f0f0; color: #9a9a9a; cursor: not-allowed; }

  /* ปิดยกชุดด้วย fieldset ตัวเดียว — ของข้างในโดนหมด */
  fieldset { border: 1px dashed #c9bcbd; border-radius: 8px; }
  fieldset:disabled { opacity: 0.6; }
</style>

<input value="ช่องนี้พิมพ์ได้">
<input value="ช่องนี้ถูกปิด" disabled>

<fieldset disabled>
  <legend>ปิดทั้งกล่องด้วย fieldset</legend>
  <input value="ตัวนี้ไม่ได้แปะ disabled เอง">
  <button>แต่ก็กดไม่ได้</button>
</fieldset>

ลองกด Tab ไล่ในกล่องข้างบนดู — คุณจะข้ามช่องที่ปิดไว้ไปเองโดยอัตโนมัติ และเข้าไปในกรอบ fieldset ไม่ได้เลย ทั้งที่ข้างในไม่ได้แปะ disabled ทีละตัว

🏷️ ติดป้ายศัพท์
form control
ชื่อรวมของ element ที่ผู้ใช้ "กรอกหรือสั่ง" ได้ — <input> <button> <select> <textarea> <fieldset> <option>
เป็นเส้นแบ่งที่สำคัญมาก เพราะ pseudo-class หลายตัว (:disabled · :enabled · :checked · :required) ทำงานเฉพาะกับกลุ่มนี้ ถ้าเอาไปใช้กับ <div> มันจะไม่ error แต่จะเงียบไปเฉย ๆ
พิมพ์บอก AI ได้ว่า: "ช่วยดูหน่อยว่าในหน้านี้มีที่ไหนที่ผมทำ interactive element ด้วย <div> ทั้งที่ควรใช้ form control จริง ๆ"
attribute กับ pseudo-class — คนละชั้นกัน
disabled (ไม่มี :) คือ attribute ใน HTML — มันเปลี่ยนพฤติกรรมจริง ๆ ของ element
:disabled (มี :) คือ pseudo-class ใน CSS — มันแค่เลือก element ที่มี attribute นั้นมาแต่งหน้าตา ไม่ได้ปิดอะไรเลย
คนใหม่มักคิดว่าเขียน CSS แล้วปุ่มจะกดไม่ได้ ซึ่งไม่จริง — ต้องแปะ attribute ด้วยเสมอ
ถาม senior ได้ว่า: "ปุ่มนี้ควรปิดด้วย attribute disabled หรือควรให้กดได้แล้วขึ้น error message ดี ผู้ใช้แบบไหนจะสับสนน้อยกว่ากัน"

6 · "Dragged" ในสไลด์ — state ที่ CSS ไม่มีให้

สไลด์หน้า 63 ใส่ state ชื่อ Dragged (ตอนผู้ใช้กดแล้วลากของ) ไว้ในชุดเดียวกับตัวอื่น
แต่ถ้าคุณไปหา :dragged ใน CSS คุณจะไม่เจอ เพราะมันไม่มีอยู่จริง

⚠️ จุดที่ต้องอ่านสไลด์ให้ระวัง

รายการ state ในสไลด์ (enabled · hover · focused · pressed · disabled · dragged) ไม่ใช่รายการ pseudo-class ของ CSS — มันคือรายการ state ในภาษาของ design system ซึ่ง Material Design 3 ใช้คำชุดนี้เป๊ะ ๆ
ห้าตัวแรก (enabled · hover · focused · pressed · disabled) มี pseudo-class ให้ใช้ตรงตัว — :enabled · :hover · :focus · :active · :disabled ตามลำดับ — ส่วนตัวสุดท้ายคือ dragged ไม่มี
ผมลองถามเบราว์เซอร์ตรง ๆ ด้วย CSS.supports('selector(:dragged)') ได้ false · ส่วน :-webkit-drag ได้ true แต่ตัวนั้นขึ้นต้นด้วย -webkit- แปลว่าเป็นของเฉพาะเบราว์เซอร์ตระกูลเดียว ไม่ใช่มาตรฐาน อย่าเอาไปใช้ในงานจริง

ในทางปฏิบัติ สิ่งที่ทำได้ด้วย CSS ล้วนคือบอกใบ้ด้วยรูปตัวชี้เมาส์: cursor: grab แปลว่า "จับได้นะ" และ cursor: grabbing แปลว่า "กำลังจับอยู่" ซึ่งจับคู่กับ :active ได้พอดี

<style>
  .chip {
    display: inline-block;
    padding: 12px 20px;
    border-radius: 10px;
    background: #b49a9c;
    color: #ffffff;
    cursor: grab;              /* มือเปิด = ลากได้ */
    user-select: none;
  }
  .chip:active {
    cursor: grabbing;          /* มือกำ = กำลังลาก */
    box-shadow: 0 6px 14px rgba(0,0,0,0.25);
  }
</style>

<div class="chip">กดค้างที่การ์ดนี้แล้วดูตัวชี้เมาส์</div>

ส่วน "การลากของจริง ๆ ให้ย้ายที่ได้" ต้องใช้ JavaScript เข้ามาช่วย โดยตอนเริ่มลากก็แปะ class เช่น .is-dragging เข้าไปเอง แล้วเขียน CSS รับที่ class นั้น
เรื่องนี้อยู่นอกขอบเขตบทนี้ แต่รู้ไว้ว่าทุกครั้งที่ design system พูดถึง state ที่ CSS ไม่มีให้ วิธีแก้คือแปะ class เอง — ไม่ใช่ไปหา pseudo-class แปลก ๆ มาใช้

7 · สรุปรวม — เขียนเรียงลำดับยังไงถึงไม่ทับกันเอง

เขียนเป็นจริงตอนใช้กับต้องระวัง
:hover ตัวชี้ลอยอยู่เหนือ element อะไรก็ได้ มือถือไม่มี — ห้ามซ่อนของสำคัญไว้หลังมัน
:focus คีย์บอร์ดคุยอยู่กับตัวนี้ (คลิกก็นับ) ตัวที่โฟกัสได้ อย่าใส่ outline: none แล้วจบแค่นั้น
:focus-visible โฟกัสอยู่ และ เบราว์เซอร์เห็นว่าควรโชว์กรอบ ตัวที่โฟกัสได้ ต้องเขียนหลัง :focus ไม่งั้นโดนทับ
:active กำลังกดค้างอยู่ (designer เรียก pressed) element อะไรก็ได้ ต้องเขียนไว้ล่างสุด ไม่งั้น :hover ทับ
:disabled / :enabled มี / ไม่มี attribute disabled form control เท่านั้น <div> กับ <a> ใช้ไม่ได้
✅ ลำดับที่จำไว้แล้วไม่พลาด

ปุ่มทั่วไป: ปกติ → :hover → :focus-visible → :active → :disabled
ลิงก์ <a>: LVHA — :link → :visited → :hover → :active
เหตุผลเดียวกันทั้งคู่: pseudo-class พวกนี้ specificity เท่ากันหมด ตัวล่างสุดจึงชนะเสมอ เรียงจาก "เกิดบ่อย/ถาวร" ไปหา "เกิดชั่วขณะ" แล้วจะไม่มีวันทับกันผิด

🛠️ ภารกิจ — ทำปุ่มหนึ่งปุ่มให้มี state ครบ แล้วพิสูจน์ว่าคนใช้คีย์บอร์ดใช้ได้จริง

สร้างไฟล์ states.html ไฟล์เดียวจบ ไม่ต้องมี JavaScript
เป้าหมายไม่ใช่ "ทำให้สวย" แต่คือ ทำให้ใช้ได้โดยไม่ต้องแตะเมาส์เลย

  1. ทำปุ่ม 3 ปุ่ม ที่ใช้ class .btn เหมือนกัน — ปุ่มที่ 3 ใส่ attribute disabled
  2. เขียน state ให้ครบ 4 อย่าง ตามลำดับในตารางข้างบน: :hover เปลี่ยนสีพื้น · :focus-visible ให้ outline หนาอย่างน้อย 2px · :active เปลี่ยนสีเข้มกว่า hover · :disabled จางลงและ cursor: not-allowed
  3. เพิ่ม @media (hover: hover) ครอบกฎ :hover ไว้
  4. ทดสอบด้วยคีย์บอร์ดล้วน — ห้ามแตะเมาส์: เอามือออกจากเมาส์จริง ๆ แล้วกด Tab ตั้งแต่บนสุดของหน้า
    เกณฑ์ผ่าน: ทุกครั้งที่กด Tab คุณต้องบอกได้ทันทีว่าตอนนี้อยู่ที่ปุ่มไหน โดยไม่ต้องเดา และปุ่มที่ 3 ต้องถูกข้ามไปเอง
    ถ้ามีจังหวะไหนที่คุณไม่รู้ว่าตัวเองอยู่ตรงไหน = ยังไม่ผ่าน กลับไปดูข้อ 2
  5. ทดสอบตรงข้าม เพื่อพิสูจน์ว่า :focus-visible ทำงานจริง: คราวนี้คลิกปุ่มที่ 1 ด้วยเมาส์
    เกณฑ์ผ่าน: ต้องไม่มีกรอบค้างอยู่หลังคลิก (ถ้ามีกรอบค้าง แปลว่าคุณเขียนที่ :focus ไม่ใช่ :focus-visible)
  6. ทดสอบว่า disabled ปิดของจริง: ลองกดปุ่มที่ 3 รัว ๆ แล้วเปิด DevTools ดูว่ามี event อะไรเกิดขึ้นไหม — ควรจะเงียบสนิท

โจทย์เสริมสำหรับคนที่เสร็จเร็ว: ลองสลับให้ :hover อยู่ล่าง :active แล้วกดค้างดู — คุณควรทำให้สีของ :active หายไปได้ตามที่ predict ในหัวข้อ 4 อธิบายไว้
ถ้าทำให้มันพังตามที่คาดได้ แปลว่าคุณเข้าใจเรื่องลำดับจริง ไม่ใช่แค่ท่องจำ

8 · ที่ฝึกต่อ และคู่มือที่ควรรู้ว่ามีอยู่

สไลด์ปิดท้ายด้วยลิงก์สองชุด ผมแยกให้ชัดว่าชุดไหนใช้ทำอะไร เพราะสองชุดนี้คนละหน้าที่กันเลย

ชุดที่ 1 — เกมฝึกมือ (เล่นตอนอยากได้ความคล่อง)

ที่ไหนฝึกอะไรควรเล่นตอนไหน
CSS Diner selector ล้วน ๆ 32 ด่าน ตั้งแต่ div จนถึง selector ซับซ้อน เล่นก่อนเพื่อนเลย — selector คือของที่ใช้ทุกวันและฝึกได้เร็วที่สุด
Flexbox Froggy flexbox 24 ด่าน — พากบไปเกาะใบบัวด้วย justify-content / align-items หลังบท 61 · ด่านละไม่ถึงนาที จบได้ในนั่งเดียว
Flexbox Zombies flexbox เหมือนกัน แต่ยาวกว่าและลึกกว่า มี 12 บทแยกตาม property ถ้าเล่น Froggy จบแล้วยังรู้สึกไม่มั่นใจ — ตัวนี้บังคับให้ใช้ property เดียวจนคล่องทีละบท
Coding Fantasy เกม RPG ที่ใช้ CSS (และภาษาอื่น) เป็นอาวุธ เล่นเอาสนุก · ไม่ต้องรีบ ไม่ใช่ทางลัดของเรื่องไหนโดยเฉพาะ

ชุดที่ 2 — คู่มือที่เอาไว้เปิดตอนต้องตัดสินใจ

สี่อันนี้ไม่ใช่ของที่อ่านรวดเดียวจบ — ไม่มีใครอ่านหมด และไม่ต้องอ่านหมด
หน้าที่ของมันคือ "เวลาไม่แน่ใจว่าควรทำยังไง ให้เปิดดูว่าคนที่คิดเรื่องนี้มาสิบปีแล้วเขาสรุปว่าไง"

คู่มือเอาไว้ตอบคำถามแบบไหนเข้าไปแล้วดูตรงไหนก่อน
CSS-Tricks Guides "property นี้มันทำงานยังไงกันแน่" หน้า A Complete Guide to Flexbox — เป็นหน้าที่คนทำเว็บทั้งโลกเปิดบ่อยที่สุดหน้าหนึ่ง
Material Design 3 "ปุ่มตอน hover ควรเข้มขึ้นเท่าไหร่ ตอน pressed ควรเป็นยังไง" หัวข้อ Foundations → Interaction states — ที่มาของรายการ state ในสไลด์หน้า 58–64 พอดี
Apple HIG "ปุ่มควรใหญ่แค่ไหนถึงกดโดนบนมือถือ" หัวข้อ Inputs และ Foundations → Accessibility
WCAG 2.1 "เว็บนี้คนที่มองไม่เห็น / ใช้เมาส์ไม่ได้ ใช้ได้ไหม" ข้อ 2.4.7 Focus Visible ข้อเดียวก่อน — สั้นมาก และมันคือหัวข้อ 3 ของบทนี้ทั้งดุ้น

💬 ถามกลับได้ตลอด — ถ้าอ่านแล้วยังไม่แน่ใจว่า :focus กับ :focus-visible ต่างกันตรงไหนกันแน่ หรือทำภารกิจแล้วกรอบไม่โผล่สักที ให้ถามมาพร้อมโค้ดที่คุณเขียน
คำถามที่ตอบง่ายที่สุดคือ "ผมเขียนแบบนี้ คาดว่าจะได้ X แต่ได้ Y" — ถ้าบอกทั้งสิ่งที่คาดและสิ่งที่ได้ จะหาสาเหตุเจอเร็วกว่าถามว่า "ทำไมไม่ทำงาน" มาก

9 · ทดสอบความเข้าใจ

1. ทีมออกแบบสั่งว่า "ราคาสินค้าให้โผล่มาตอนเอาเมาส์ชี้ที่การ์ด เพื่อให้หน้าเว็บดูสะอาด" — ควรตอบเขายังไง?

ราคาคือข้อมูลที่ผู้ใช้ต้องมีเพื่อตัดสินใจ ไม่ใช่ของตกแต่ง — เอาไปซ่อนหลัง :hover แปลว่าคนใช้มือถือซื้อของไม่ได้
ตัวเลือกที่ 1 แก้ผิดจุด: @media (hover: hover) ทำให้มือถือไม่ได้กฎ hover ซึ่งแปลว่าราคาถูกซ่อนถาวรยิ่งกว่าเดิม ไม่ได้ทำให้มันโผล่มา
ตัวเลือกที่ 2 ผิดที่ข้อเท็จจริง — บางเบราว์เซอร์บนมือถือแกล้งยิง :hover ให้ตอนแตะครั้งแรกก็จริง แต่มันไม่แน่นอน ต่างกันไปตามเบราว์เซอร์ และทำให้ต้องแตะสองครั้งกว่าจะกดลิงก์ได้ ซึ่งแย่กว่าเดิม
ตัวเลือกที่ 4 ใช้เกณฑ์ผิด: ความกว้างจอไม่ได้แปลว่ามีหรือไม่มีเมาส์ แท็บเล็ตจอใหญ่ก็ไม่มี hover ส่วนหน้าต่างที่ย่อแคบบน desktop ก็ยังมีเมาส์
จุดที่ต้องจำ: :hover ใช้เน้นของที่เห็นอยู่แล้วได้ แต่ใช้เปิดเผยของที่ยังไม่เห็นไม่ได้ — ถ้าเนื้อหาหายแล้วผู้ใช้ทำงานต่อไม่ได้ คือใช้ผิดที่

2. เขียน button:focus { outline: none; } ไว้บรรทัดเดียวโดยไม่เขียนอะไรเพิ่ม ผลที่ตามมาคืออะไร?

:focus เป็นจริงทุกกรณีที่ element ได้โฟกัส ไม่ว่าจะมาจากเมาส์หรือคีย์บอร์ด การลบ outline ที่นั่นจึงลบทิ้งหมดทุกทาง
ตัวเลือกที่ 1 คือสิ่งที่หลายคนเข้าใจว่าจะเกิด — แต่เบราว์เซอร์ไม่ได้ใส่กรอบกลับมาให้เอง ถ้าอยากได้ผลแบบนั้นต้องเขียน :focus-visible เพิ่มเองเสมอ
ตัวเลือกที่ 3 สับสนระหว่าง "มองไม่เห็นโฟกัส" กับ "โฟกัสไม่ได้" — ปุ่มยังโฟกัสได้ปกติ และยังกด Enter สั่งงานได้ มันแค่ไม่มีอะไรให้ตาเห็น ซึ่งอันตรายกว่าเพราะดูเหมือนไม่มีอะไรเสีย
ตัวเลือกที่ 4 ผิดเรื่อง specificity — สไตล์ default ของเบราว์เซอร์อยู่ชั้นล่างสุดเสมอ กฎที่เราเขียนเองชนะทุกครั้ง ไม่ว่าจะเขียนสั้นแค่ไหน
จุดที่ต้องจำ: outline: none ไม่ผิดในตัวมันเอง — ที่ผิดคือลบแล้วไม่วาดอะไรกลับมา ลบเมื่อไหร่ ต้องมี :focus-visible ตามมาเสมอ

3. อยากทำ <div class="my-button"> ให้กดไม่ได้ โดยเขียน .my-button:disabled { opacity: 0.5 } — จะเกิดอะไรขึ้น?

ผมทดสอบในเบราว์เซอร์ตรง ๆ ว่า <div> ตรงกับ :enabled ไหม ได้ false และ <a href="#"> ก็ได้ false เหมือนกัน ส่วน <input> ได้ true — สอง pseudo-class นี้ใช้กับ form control เท่านั้น
แปลว่ากฎนั้นไม่มีวันถูกใช้ ไม่ว่าคุณจะแปะ attribute disabled ให้ <div> หรือไม่ก็ตาม
ตัวเลือกที่ 1 ผิดทั้งสองท่อน: <div> ไม่จางลง (เพราะกฎไม่ทำงาน) และ CSS ไม่เคยทำให้อะไร "กดไม่ได้" อยู่แล้ว — สิ่งที่ปิดการกดจริง ๆ คือ attribute ใน HTML
ตัวเลือกที่ 2 ถูกครึ่งเดียวและเป็นตัวที่หลอกที่สุด: pointer-events: none ปิดการคลิกได้จริง แต่ <div> จะไม่จางลง เพราะกฎแรกไม่ทำงานตั้งแต่ต้น และถึงปิดคลิกได้ก็ยังไม่แก้ปัญหาคีย์บอร์ด
ตัวเลือกที่ 3 ผิดเรื่องพฤติกรรมของ CSS — selector ที่ไม่ตรงกับอะไรถูกข้ามไปเงียบ ๆ ไม่เคยทำให้ไฟล์พัง นี่แหละคือสิ่งที่ทำให้ bug ประเภทนี้หายากมาก
จุดที่ต้องจำ: อยากได้พฤติกรรมของปุ่ม ให้ใช้ <button> แล้วคุณจะได้ :disabled · การข้าม Tab · และการปิด click มาฟรีทั้งชุด — ทำเองด้วย <div> ต้องไล่เขียนเองทุกอย่าง แล้วก็จะลืมบางอย่างเสมอ

4. เขียน CSS ของปุ่มไว้ตามลำดับนี้: :active · :hover · :focus-visible — กดค้างด้วยเมาส์แล้วสีของ :active ไม่โผล่ ทำไม?

ตอนกดค้าง เงื่อนไขเป็นจริงพร้อมกันสองอย่าง (เมาส์อยู่เหนือปุ่ม + กำลังกด) ทั้ง .btn:active และ .btn:hover นับ specificity ได้เท่ากันเป๊ะ (class 1 + pseudo-class 1) → CSS ตัดสินด้วยลำดับที่เขียน ตัวล่างชนะ
ตัวเลือกที่ 2 ผิดสองชั้น: specificity ไม่ได้ต่างกัน และ !important คือการเอาค้อนทุบปัญหาลำดับ ซึ่งจะย้อนกลับมาทำให้แก้อะไรไม่ได้ในไฟล์ใหญ่ ๆ — แค่สลับบรรทัดก็จบ
ตัวเลือกที่ 3 ผิดที่ข้อเท็จจริง :active ใช้ได้กับ element อะไรก็ได้ รวมทั้ง <div>
ตัวเลือกที่ 4 ผิดตรงที่ การคลิกด้วยเมาส์ไม่ทำให้ :focus-visible เป็นจริง บนปุ่ม — นั่นคือเหตุผลทั้งหมดที่ pseudo-class ตัวนี้ถูกสร้างขึ้นมา
จุดที่ต้องจำ: เจอ CSS ที่ "เขียนไปแล้วไม่เห็นผล" ให้เช็คสองอย่างตามลำดับ — specificity เท่ากันไหม แล้วค่อยดู ใครเขียนทีหลัง เกือบทุกครั้งคำตอบอยู่ที่ข้อสอง และแก้ได้ด้วยการย้ายบรรทัด ไม่ใช่เพิ่มโค้ด

อ้างอิง