diagram ก่อนและหลังใส่ clip-path: การ์ดสีน้ำเงินเต็มใบที่มีเงาดำ กับการ์ดใบเดียวกันที่ถูกตัดขอบล่างเป็นเส้นเฉียง ส่วนมุมและเงาที่ถูกตัดเหลือเพียงเส้นประ

ทำไมใส่ clip-path แล้วเงาหาย และมุมที่ตัดไปกดไม่ได้

เข้าใจ clip-path ผ่านภาพแผ่นฉลุ รู้ว่ามันตัดอะไรทิ้งและอะไรยังอยู่ ทำไม circle(50%) ถึงไม่ได้วงกลมพอดีกล่อง และทำ animation เปลี่ยนรูปทรงให้ลื่น พร้อมผลทดสอบจริงใน Chrome, Firefox และ Safari

ตัดมุมการ์ดให้เฉียงด้วย clip-path: polygon(...) เสร็จ หน้าตาดูดีขึ้น แต่พอใช้จริงกลับเจอของแปลกๆ ตามมาเป็นพวง box-shadow ที่ตั้งไว้หายเกลี้ยง กด Tab แล้วไม่เห็น focus ring มุมที่ถูกตัดคลิกไม่ติด แถม section ถัดไปยังเว้นช่องว่างไว้ราวกับการ์ดยังเป็นสี่เหลี่ยมเต็มใบ ทั้งหมดนี้ไม่ใช่บั๊ก มันคือวิธีที่ clip-path ทำงานตามปกติ และถ้าเรามองมันแบบเดียวกับที่เบราว์เซอร์มอง อาการพวกนี้จะเดาได้ล่วงหน้าทุกข้อ

clip-path คือแผ่นฉลุที่วางทับตอนวาด

ลองนึกถึงแผ่นฉลุที่เจาะรูเป็นรูปทรงไว้ เบราว์เซอร์จะจัด layout และวาด element ให้เสร็จก่อนตามปกติ ทั้ง background, border, ข้อความ และลูกทุกตัว แล้วค่อยเอาแผ่นฉลุมาวางทับ อะไรที่อยู่ตรงรูเราจะมองเห็น อะไรที่อยู่นอกรูจะไม่ถูกวาดลงจอเลย พื้นที่ตรงรูนี้ spec ของ CSS Masking เรียกว่า clipping region

แผ่นฉลุวางลงมาตอนที่ทุกอย่างจัดที่ทางเสร็จแล้ว layout จึงไม่รู้เลยว่ามีอะไรถูกตัด การ์ดที่โดนตัดมุมยังจองพื้นที่เป็นสี่เหลี่ยมเต็มใบ section ถัดไปเลยไม่ขยับขึ้นมาเติมมุมที่หายไป spec เองก็เขียนไว้ว่า clipping path "does not affect the element's inherent geometry" ถ้าวัดด้วย getBoundingClientRect() ทั้งก่อนและหลังใส่ clip-path จะได้ตัวเลขเท่าเดิมทุกพิกเซล

.card {
  /* ตัดมุมล่างขวาให้เฉียง แต่กล่องยังสูงเท่าเดิม */
  clip-path: polygon(0 0, 100% 0, 100% 70%, 0 100%);
}

ลองติ๊กเปิดปิด clip-path ใน demo แผ่นฉลุดู เส้นประคือขอบของการ์ดตัวจริง จะตัดหรือไม่ตัด เส้นประก็อยู่ที่เดิม และกล่องถัดไปก็ไม่ขยับตาม

อีกเรื่องคือแผ่นฉลุต้องวางเทียบกับอะไรสักอย่าง เวลาเราเขียน polygon(0 0, 100% 0, ...) คำถามคือ 100% ของอะไร คำตอบคือของ reference box ซึ่งspec ของ CSS Shapesนิยามว่าเป็นกล่องที่ใช้วัดรูปทรง โดยมีมุมซ้ายบนเป็นจุด 0 0 จุดที่ต้องระวังคือค่าเริ่มต้นของกล่องนี้คือ border-box ไม่ใช่ content 100% จึงยาวไปถึงขอบนอกของ border และรวม padding ไว้ด้วย ถ้าอยากให้วัดจากกล่องอื่น ให้ใส่ชื่อกล่องต่อท้ายรูปทรง

.card-padded {
  padding: 2rem;
  clip-path: inset(0) content-box; /* ← วัดจาก content box: ส่วน padding ถูกตัดทิ้ง */
}

ลองติ๊กสลับใน demo reference box ดู เส้นประคือ border box ของการ์ด ตอนวัดจาก content-box ส่วน padding 2rem รอบๆ จะถูกตัดหายไป พอเอาติ๊กออก รูปทรงกลับไปวัดจาก border-box ซึ่งเป็นค่าเริ่มต้น การ์ดก็กลับมาเต็มกรอบ

เขียนรูปทรงด้วย basic shape

basic shape คือชุดฟังก์ชันสำหรับบอกว่ารูบนแผ่นฉลุเป็นรูปอะไร ทุกตัววัดพิกัดและเปอร์เซ็นต์จาก reference box เหมือนกัน เข้าใจตัวหนึ่งแล้วตัวที่เหลือก็อ่านออก

.inset   { clip-path: inset(10% 20% round 1rem); }            /* สี่เหลี่ยมหดเข้าจากขอบ มนมุมได้ */
.circle  { clip-path: circle(40% at 50% 50%); }               /* วงกลม: รัศมี at จุดศูนย์กลาง */
.ellipse { clip-path: ellipse(50% 30%); }                     /* วงรี: รัศมีแนวนอน รัศมีแนวตั้ง */
.polygon { clip-path: polygon(50% 0, 100% 100%, 0 100%); }    /* หลายเหลี่ยม: คู่พิกัด x y */
.shape   { clip-path: shape(from 0 0, line to 100% 0, line to 50% 100%, close); } /* เส้นทางอิสระ: วาดทีละคำสั่ง */

สี่ตัวแรกเป็น Baseline Widely available ใช้ได้ทุกเบราว์เซอร์มานานแล้ว ส่วน shape() เพิ่งเป็น Baseline 2026 ใช้ได้ตั้งแต่ Chrome 135, Firefox 148 และ Safari 18.4 ใครเคยใช้ path() จะรู้ว่ามันวาดรูปอะไรก็ได้ แต่ตัวเลขทุกตัวเป็นพิกเซลตายตัว กล่องยืดแต่รูปไม่ยืดตาม shape() เข้ามาแก้ตรงนี้ เพราะมันรับหน่วยของ CSS ได้ เช่น % และ calc()

กับดักของ circle(50%)

ปกติเปอร์เซ็นต์แนวนอนคิดจากความกว้าง แนวตั้งคิดจากความสูง แต่รัศมีวงกลมไม่มีแนว แล้วมันคิดจากอะไร คำตอบคือคิดจากสูตร sqrt(width² + height²) / sqrt(2) ฟังดูยุ่ง แต่ผลของมันจำได้ไม่ยาก บนกล่องจัตุรัส ค่านี้เท่ากับความยาวด้านพอดี circle(50%) จึงได้วงกลมแนบขอบ แต่พอเป็นกล่องผืนผ้า ผลจะไม่ใช่อย่างที่คิด

.banner {
  width: 400px;
  height: 200px;
  clip-path: circle(50%); /* ← รัศมี 158px ไม่ใช่ 100px: ขอบบนและล่างถูกตัดแบน */
}

.banner-fit {
  width: 400px;
  height: 200px;
  clip-path: circle();    /* ← ค่าเริ่มต้นคือ closest-side: รัศมี 100px พอดีด้านสั้น */
}

ถ้าไล่ document.elementFromPoint() จากขอบซ้ายของกล่อง 400×200 พิกเซลเข้าหาศูนย์กลาง จะเริ่มโดนวงกลมที่ระยะ 158 พิกเซลจากศูนย์กลาง ตรงกับสูตรพอดี ถ้าอยากได้วงกลมที่แนบด้านสั้นทุกครั้ง ใช้ circle() เปล่าๆ ดีกว่า

ทำไมเงาและ outline หาย

ทีนี้ลองนึกภาพแผ่นฉลุอีกครั้ง box-shadow ถูกวาดอยู่นอก border box ส่วน outline ก็อยู่รอบนอก border เหมือนกัน แต่รูของแผ่นฉลุวัดจาก border-box สองอย่างนี้เลยอยู่นอกรูตั้งแต่แรก spec ระบุไว้ตรงๆ ด้วยว่าสิ่งที่ถูกตัดรวมถึง "background, borders, text decoration, outline" ของ element และลูกทุกตัว

ทางออกแรกที่มักนึกถึงคือเปลี่ยนไปใช้ filter: drop-shadow() บน element เดิม ซึ่งก็หายเหมือนกัน เพราะเบราว์เซอร์ทำ filter ก่อน แล้วจึง clip, mask และ opacity ตามลำดับ เงาที่ filter เพิ่งวาดเสร็จก็เลยโดนแผ่นฉลุตัดทิ้งไปอีกรอบ ไม่ว่าจะเป็น box-shadow, outline หรือ drop-shadow() ส่วนที่ล้นออกนอกการ์ดจะเหลือแต่สีพื้นหลัง หน้าตาของสองแบบที่เงาหายเป็นแบบนี้

.card {
  clip-path: polygon(0 0, 100% 0, 100% 70%, 0 100%);
}

.card.with-box-shadow {
  box-shadow: 8px 8px 0 currentColor;          /* ← วาดนอก border box: ถูกตัด */
}

.card.with-drop-shadow {
  filter: drop-shadow(8px 8px 0 currentColor); /* ← filter ทำก่อน clip: ถูกตัด */
}

ทางแก้คือย้ายเงาไปไว้ที่ element ที่ไม่มีแผ่นฉลุมาบัง

  1. ห่อการ์ดด้วย wrapper
  2. ใส่ filter: drop-shadow() ที่ wrapper
  3. ใส่ clip-path ที่การ์ดตามเดิม
<div class="card-shadow">
  <div class="card">...</div>
</div>
.card-shadow {
  filter: drop-shadow(8px 8px 0 currentColor); /* ← wrapper ไม่มี clip-path เงาจึงไม่ถูกตัด */
}

.card {
  clip-path: polygon(0 0, 100% 0, 100% 70%, 0 100%);
}

ที่ได้ผลเพราะ wrapper วาดการ์ดที่ถูกตัดเสร็จแล้วก่อน แล้วค่อยใส่ filter เงาเลยออกมาเป็นรูปเฉียงตามการ์ด ส่วนถ้าใส่ box-shadow ที่ wrapper แทน เงาจะเป็นสี่เหลี่ยมตามกล่องของ wrapper ไม่ได้ตามรูปทรง สีเงาใช้ currentColor ให้เป็นสีเดียวกับตัวอักษร เงาจึงมองเห็นทั้งโหมดสว่างและโหมดมืด

focus ring ก็ใช้วิธีเดียวกัน ถ้าการ์ดเป็นลิงก์หรือปุ่มที่กด Tab ไปถึงได้ ให้ย้าย outline ไปวาดที่ wrapper แทน

.card-shadow:has(.card:focus-visible) {
  outline: 3px solid #0066cc; /* ← outline อยู่ที่ wrapper จึงไม่ถูกตัด */
  outline-offset: 4px;
}

ส่วนที่ถูกตัดกดไม่ได้ แต่ screen reader ยังอ่านได้

แผ่นฉลุไม่ได้ซ่อนแค่ภาพ มันบังพื้นที่กดไปด้วย spec เขียนว่า pointer event "must not be dispatched on the clipped-out (non-visible) regions" ถ้าถาม document.elementFromPoint() ที่มุมของกล่อง 200×200 พิกเซลที่ตัดเป็นวงกลม จะได้ element ที่อยู่ข้างหลังแทน แต่ถ้าถามที่ศูนย์กลางจะได้กล่องนี้ตามปกติ ที่น่าสนใจคือถ้าทำวงกลมด้วย mask แทน มุมกล่องจะยังกดได้ เพราะ spec ระบุว่า "Masking does not affect hit testing" ถ้าอยากให้พื้นที่กดตรงกับรูปทรง จึงต้องใช้ clip-path เทียบสองแบบได้จากโค้ดนี้

.target {
  width: 200px;
  height: 200px;
}

.target-clip {
  clip-path: circle(); /* ← นอกวงกลมกดไม่ได้ */
}

.target-mask {
  mask-image: radial-gradient(circle closest-side, #000 98%, transparent 100%); /* ← ยังกดได้ทั้งกล่อง */
}

ลองเอาเมาส์ชี้หรือแตะที่มุมของกรอบเส้นประใน demo พื้นที่กดดู กล่อง .target-clip จะเปลี่ยนสีเฉพาะตอนอยู่ในวงกลม ส่วนกล่อง .target-mask เปลี่ยนสีทั้งกรอบ

แต่อีกด้านหนึ่ง แผ่นฉลุไม่ได้ลบอะไรออกจากหน้าเลย WAI-ARIA ให้ตัด element ออกจาก accessibility tree เฉพาะตอนที่มันไม่ถูกแสดง เช่น display: none, visibility: hidden หรือ attribute hidden และ clip-path ไม่อยู่ในกลุ่มนี้ ถ้าเปิด accessibility tree ใน DevTools ดู ข้อความที่ถูกตัดด้วย inset(50%) หรือ circle(0) จะยังอยู่ครบ ต่างจากข้อความที่เป็น display: none ที่หายไป ปุ่มที่ถูกตัดจนมองไม่เห็นก็ยังกด Tab ไปโฟกัสได้ (บน Safari ต้องกด Option+Tab เพราะค่าเริ่มต้นของ Safari ไม่ส่ง Tab ไปที่ปุ่ม)

คุณสมบัตินี้เองที่ทำให้ clip-path เป็นหัวใจของ class "visually hidden" ที่ใช้ซ่อนข้อความจากสายตา แต่ยังให้ screen reader อ่านได้

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  white-space: nowrap;
  clip-path: inset(50%); /* ← inset บนและล่างรวมกันได้ 100% กล่องจึงยุบจนไม่เหลือพื้นที่ */
}

ถ้าไปเจอโค้ดรุ่นเก่า มักจะเห็น clip: rect(0 0 0 0) แทน แต่ property clip ถูกประกาศ deprecated แล้ว และ spec ก็แนะนำให้ใช้ clip-path แทน อีกเรื่องที่ต้องระวังคืออย่าใช้วิธีนี้ซ่อนปุ่มหรือลิงก์ เพราะคนที่ใช้คีย์บอร์ดจะ Tab ไปเจอของที่มองไม่เห็น

ทำ animation เปลี่ยนรูปทรง

clip-path ใช้กับ transition และ @keyframes ได้ แต่เบราว์เซอร์จะค่อยๆ เปลี่ยนรูปทรงให้ก็ต่อเมื่อมันจับคู่ค่าต้นทางกับปลายทางได้ ซึ่งมีเงื่อนไขแบบนี้

  • เป็นฟังก์ชันเดียวกัน และใช้ reference box เดียวกัน
  • polygon() ต้องมีจำนวนจุดเท่ากัน และใช้ fill rule เดียวกัน
  • รัศมีของ circle() และ ellipse() ต้องเป็นค่าความยาว ไม่ใช่ keyword อย่าง closest-side
  • inset() ใช้ข้ามกับ rect() และ xywh() ได้ สองตัวหลังเป็นอีกวิธีเขียนสี่เหลี่ยม (xywh() รับตำแหน่ง x y กับความกว้างความสูง) และ spec แปลงทั้งคู่เป็น inset() ตอนคำนวณ

ถ้าจับคู่ไม่ได้ รูปทรงจะกระโดดเปลี่ยนทันทีตอนครึ่งทาง วิธีดูให้ชัดคือใช้ Web Animations API หยุด animation ไว้ที่ 25% แล้วอ่านค่าด้วย getComputedStyle()

const box = document.querySelector(".badge");
const animation = box.animate(
  [{ clipPath: "circle(0px)" }, { clipPath: "circle(100px)" }],
  { duration: 1000 },
);
animation.pause();
animation.currentTime = 250; // ← หยุดไว้ที่ 25%
console.log(getComputedStyle(box).clipPath); // "circle(25px)"

เปลี่ยนค่าต้นทางและปลายทางเป็นคู่อื่นแล้วจะได้ผลแบบนี้

จาก → ไป

ผลที่ 25%

polygon() 3 จุด → 4 จุด

สลับ: ยังเป็นค่าเริ่มต้น

polygon() 4 จุด → 4 จุด

ลื่น: polygon(37.5% 0px, 62.5% 0px, ...)

circle() → circle(100px)

สลับ: ยังเป็น circle()

circle(0px) → circle(100px)

ลื่น: circle(25px)

inset(0 100% 0 0) → xywh(0 0 100% 100%)

ลื่น: inset(0px 75% 0% 0px)

inset(0) border-box → inset(20px) content-box

สลับ: ยังเป็น inset(0px)

ถ้าอยากให้สามเหลี่ยมค่อยๆ กลายเป็นสี่เหลี่ยม ให้เขียนสามเหลี่ยมเป็น 4 จุด โดยใส่จุดยอดซ้ำกันสองครั้ง จำนวนจุดจะได้เท่ากับสี่เหลี่ยม ในโค้ดมี .badge-jump ที่เขียนแบบ 3 จุดไว้เทียบด้วย และใส่ transition ไว้ใน prefers-reduced-motion คนที่ตั้งเครื่องให้ลดการเคลื่อนไหวจะได้ไม่ต้องเห็น animation

.badge {
  clip-path: polygon(50% 0, 50% 0, 100% 100%, 0 100%); /* ← จุดยอดซ้ำ: 4 จุดเท่ากับสี่เหลี่ยม */
}

.badge-jump {
  clip-path: polygon(50% 0, 100% 100%, 0 100%); /* ← 3 จุด: จับคู่กับสี่เหลี่ยมไม่ได้ */
}

.badge:hover,
.badge:focus-visible {
  clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
}

@media (prefers-reduced-motion: no-preference) {
  .badge {
    transition: clip-path 0.4s;
  }
}

ลองเอาเมาส์ชี้หรือกด Tab ไปที่แต่ละช่องใน demo เปลี่ยนรูปทรงดู ช่อง .badge-jump ที่ใช้ 3 จุดจะกระโดดเปลี่ยนทีเดียว ส่วนช่อง .badge ที่ใช้ 4 จุดจะค่อยๆ ขยายออก

ข้อควรระวัง

  • clip-path สร้าง Stacking Context ค่าใดก็ตามที่ไม่ใช่ none สร้าง Stacking Context แบบเดียวกับ opacity ที่น้อยกว่า 1 ลูกที่มี z-index: 100 จะโดนกล่องข้างๆ ที่มีแค่ z-index: 1 ทับทันทีที่พ่อได้ clip-path: inset(0) และถ้าข้างในมี dropdown หรือ tooltip ก็จะโดนแผ่นฉลุตัดไปด้วย
  • ลูกที่เป็น position: fixed ก็ถูกตัด clip-path ไม่ได้ทำให้ element กลายเป็น containing block แบบที่ transform ทำ ลูกที่เป็น position: fixed จึงยังวางตำแหน่งเทียบกับ viewport ตามปกติ แต่หนีแผ่นฉลุของพ่อไม่พ้น ส่วนที่ไปอยู่นอกรูจะทั้งมองไม่เห็นและกดไม่ได้ ถ้ามี modal หรือ toast อยู่ข้างใน ให้ย้ายออกไปไว้นอก element ที่มี clip-path

สรุป

  • clip-path คือแผ่นฉลุที่วางทับหลังวาดเสร็จ layout จึงเหมือนเดิม แต่ทุกอย่างนอกรูหายไป รวมทั้ง box-shadow, outline และ drop-shadow() บน element เดียวกัน ให้ย้ายเงาและ focus ring ไปไว้ที่ wrapper
  • ส่วนที่อยู่นอกรูกดไม่ได้ แต่ screen reader ยังอ่านและ Tab ยังโฟกัสได้
  • จะทำ animation เปลี่ยนรูปทรง ต้องใช้ฟังก์ชันเดียวกัน reference box เดียวกัน และ polygon() ต้องมีจำนวนจุดเท่ากัน