Container Queries: רכיב שמכיר את המיכל שלו, לא את ה-viewport
Container Queries: רכיב שמכיר את המיכל שלו, לא את ה-viewport
יש לכם כרטיסיית מוצר. תמונה מעל הכותרת, מחיר, כפתור "הוסף לעגלה". ברשת הראשית של החנות, מרוחב מסך מעל 768px, אתם עוברים דרך media query לפריסה אופקית – תמונה משמאל, תוכן מימין, כי כך מנוצל טוב יותר הרוחב הזמין. עובד מצוין. אחר כך צוות המוצר רואה את אותה כרטיסייה ורוצה אותה בסרגל צד צר של "מוצרים מומלצים", לצד התוכן הראשי של המאמר. אתם מכניסים את הרכיב בלי שינוי – והוא נשבר. הפריסה האופקית, שתוכננה בהתחשב ב-700+ פיקסלים, מנסה להיכנס לעמודה של 260 פיקסלים. התמונה נדחסת לפס דק, הטקסט נשבר למילה בודדת בכל שורה.
לא טעיתם ב-CSS. ה-media query שלכם עובד בדיוק כפי שהוא אמור לעבוד – רק שהוא עונה על השאלה הלא נכונה. @media (min-width: 768px) שואל: "כמה רחב ה-viewport?". לכרטיסיית המוצר, כדי להיראות טוב, אף פעם לא היה אמור להיות אכפת מרוחב ה-viewport – היה צריך להיות לה אכפת כמה מקום היא קיבלה מההורה שלה. אלה שתי שאלות שונות לגמרי, ש-CSS התייחס אליהן במשך שני עשורים כאילו הן אחת, כי לא הייתה לו דרך לשאול את השנייה.
למה media query לא מספיק
media query הוא שאילתה גלובלית. לא משנה אם הרכיב שלכם יושב בעמודה של 1200 פיקסלים או בכרטיסיית צד צרה של 260 – @media (min-width: 768px) בשני המקרים יחזיר את אותו true, כי הוא שואל על חלון הדפדפן, לא על הורה האלמנט. רכיב שנבנה תוך הסתמכות בלעדית על media query הוא אפוא נכון רק בהקשר שבו הוא תוכנן במקור – ומפסיק להיות נכון ברגע שאותו קוד מגיע לכל מקום אחר. זה שובר באופן יסודי את ההבטחה שרכיבים אמורים לתת: שאפשר להשתמש בהם שוב בבטחה, בכל מקום בפריסה.
לפני 2023 הפתרון היחיד שהיה בר-ביצוע היה ResizeObserver ב-JavaScript – אתם עוקבים אחרי אלמנט, מודדים את רוחבו בכל שינוי, מכניסים את התוצאה למצב (state), ומוסיפים בתנאי מחלקת CSS. זה עובד, אבל יש לזה מחיר: מצב נוסף לרכיב, רינדור מחדש בכל שינוי גודל, קוד שצריך לכתוב ידנית לכל רכיב בנפרד, וקוד שלא קיים עד ש-JavaScript מתבצע – כך שברינדור בצד השרת הפריים הראשון יציג בכל מקרה פריסה "בעיוורון", לפני ש-ResizeObserver הספיק למדוד משהו.
jsx
// עוקף מלפני Container Queries – עובד, אבל עולה משהוimport { useEffect, useRef, useState } from "react";const ProductCard = ({ product }) => {const cardRef = useRef(null);const [isWide, setIsWide] = useState(false);useEffect(() => {const el = cardRef.current;const observer = new ResizeObserver((entries) => {setIsWide(entries[0].contentRect.width > 320);});observer.observe(el);return () => observer.disconnect();}, []);return (<article ref={cardRef} className={`card ${isWide ? "card--wide" : ""}`}>{/* ... */}</article>);};
Container Queries פותרים את אותה בעיה בדיוק בלי שורת JavaScript אחת, בלי state ובלי רינדור מחדש – כי השאלה "כמה מקום יש לי לרשותי" חוזרת בדיוק לאן ש-CSS תמיד שאל אותה: לגיליון הסגנונות.
איך זה באמת עובד מתחת למכסה המנוע: containment, לא רק תחביר חדש
התחביר @container בפני עצמו נראה כמו @media עם שם אחר – אבל ההבדל טמון במקום אחר לגמרי, במה שחייב לקרות לפני שהדפדפן בכלל מסכים לענות על שאילתה כזו. כדי שאלמנט יוכל להיות "מיכל שאילתה", עליכם להצהיר על כך במפורש:
css
.card-slot {container-type: inline-size;container-name: card;}@container card (min-width: 400px) {.card {grid-template-columns: 40% 1fr;}}
container-type: inline-size הוא לא רק מתג שמפעיל מצב שאילתות – זו הצהרת CSS Containment, מפרט נפרד, שאומר לדפדפן: "התוכן של האלמנט הזה לא משפיע על גודלו בציר ה-inline, אז אתה יכול להתייחס אליו בבטחה כאי עצמאי בזמן חישוב הפריסה". זה לא פרט מימוש חסר משמעות – זה תנאי הכרחי כדי ששאילתות מיכל בכלל יוכלו להתקיים בלי להיכנס ללולאה. אילו הדפדפן היה מאפשר לאלמנט גם לשנות גודל בתגובה לרוחב שלו עצמו וגם להשפיע על אותו רוחב דרך התוכן שלו, הייתה נוצרת תלות מעגלית: התוכן משנה את גודל המיכל → שינוי הגודל מפעיל @container → הכללים החדשים משנים את התוכן → התוכן שוב משנה את גודל המיכל. ה-containment חותך את הלולאה הזו במקור, בכך שהוא מכריח שגודל המיכל בציר נתון ייקבע באופן בלתי תלוי במה שנמצא בפנים.
לזה יש השלכה מעשית וקונקרטית בבחירת הערך של container-type:
inline-size– containment רק בציר ה-inline (בדרך כלל האופקי). הגובה של האלמנט עדיין נובע באופן חופשי מהתוכן. זהו הערך שאליו פונים ב-95% מהמקרים – בדיוק כמו בדוגמת כרטיסיית המוצר.size– containment בשני הצירים בבת אחת. האלמנט מפסיק לחלוטין להסתמך על התוכן שלו בקביעת הגודל – מה שאומר שאתם חייבים לתת לו גובה מפורש (למשל דרךheightאוaspect-ratio), אחרת ה-containment ינתק אותו מהגובה הטבעי של התוכן והגובה בפועל יצנח לאפס.normal– ערך ברירת המחדל, אין containment, האלמנט אינו מיכל שאילתה.
container-name הוא אופציונלי, אבל שווה להרגיל את עצמכם אליו – בלעדיו, @container (min-width: 400px) שואל את הקרוב ביותר מבין אבות המיכל, יהיה מי שיהיה. ברכיב שטוח זה לא מזיק, אבל בפריסה מקוננת (כרטיסייה בתוך פאנל, פאנל בתוך עמודה – וגם הפאנל וגם העמודה הם מיכלים) מיכל בעל שם אומר במפורש איזה אב אתם שואלים, במקום להסתמך על קרבה מקרית בעץ ה-DOM.
דוגמה מעשית: כרטיסייה אחת, שני הקשרים, אפס JavaScript
חוזרים לכרטיסיית המוצר מהפתיחה – הפעם בנויה כך שאותו רכיב יתרנדר נכון גם ברשת רחבה וגם בסרגל צד צר, בלי שום ידיעה על המקום שבו הוא בסופו של דבר הוצב.
jsx
const ProductCard = ({ product }) => (<div className="product-card-slot"><article className="product-card"><imgsrc={product.image}alt={product.name}className="product-card__image"/><div className="product-card__body"><h3 className="product-card__title">{product.name}</h3><p className="product-card__price">{product.price} ₪</p><button type="button" className="product-card__cta">הוסף לעגלה</button></div></article></div>);export default ProductCard;
css
/* ההורה של הרכיב מצהיר על עצמו כמיכל שאילתה */.product-card-slot {container-type: inline-size;container-name: product-card;}/* פריסת ברירת המחדל: תמונה מעל התוכן – בטוחה במרחב צר */.product-card {display: grid;grid-template-columns: 1fr;gap: 12px;}.product-card__image {width: 100%;aspect-ratio: 4 / 3;object-fit: cover;border-radius: 8px;}/* מעל 360px רוחב של המיכל, לא של ה-viewport – עברו לפריסה אופקית */@container product-card (min-width: 360px) {.product-card {grid-template-columns: 40% 1fr;align-items: center;}.product-card__image {aspect-ratio: 1 / 1;height: 100%;}}
ההחלטה המרכזית כאן חבויה במבנה, לא ב-CSS: container-type יושב על .product-card-slot, כלומר ההורה של הכרטיסייה, ולא על .product-card עצמו. זה לא מקרי ולא סגנון מוגזם – המפרט שולל במפורש את האפשרות שאלמנט ישאל על הגודל של עצמו דרך @container (שוב אותה בעיה של תלות מעגלית: אלמנט לא יכול בו-זמנית להגדיר מיכל ולהיות מעוצב בתגובה לגודל שלו). לכן הדפוס האופייני והחוזר הוא wrapper-מיכל דק בחוץ ורכיב אמיתי בפנים, מעוצב בתוך בלוק @container. אותו קובץ CSS בדיוק, כשמכניסים אותו פעם אחת לרשת מוצרים (שבה ה-slot הוא 480px) ופעם אחת לסרגל צד (שבה ה-slot הוא 240px), ייתן שתי פריסות שונות ונכונות – בלי prop, בלי מחלקת מודיפייר, בלי שורת JavaScript אחת.
יחידות מיכל: נזילות בלי viewport
Container Queries הביאו איתם חלק שני, פחות מוכר, מאותו מפרט בדיוק: יחידות יחסיות למיכל, לא ל-viewport – cqw (1% מרוחב המיכל), cqh (1% מהגובה), cqi ו-cqb (1% בציר ה-inline/block, כלומר נכונות גם בשפות שנכתבות אנכית), ו-cqmin/cqmax, מקבילות ל-vmin/vmax אבל מחושבות מהמימד הקטן או הגדול יותר של המיכל.
השימוש הטבעי שלהן הוא טיפוגרפיה נזילה בתוך הרכיב, בלי קשר לרוחב המסך הנוכחי של המשתמש:
css
.product-card__title {/* משתנה בהתאם לרוחב הכרטיסייה, לא לרוחב המסך */font-size: clamp(1rem, 0.85rem + 2cqi, 1.375rem);}
ההבדל מהטריק המוכר של clamp() עם יחידת vw הוא עדין, אבל בפועל יסודי: כותרת שמשתנה לפי vw משנה גודל ביחד עם כל הדף, כך ששני עותקים של אותה כרטיסייה – אחד ברשת ברוחב מלא, שני בסרגל צד צר – יקבלו גודל גופן זהה, כי שניהם רואים את אותו viewport. כותרת שמשתנה לפי cqi מגיבה למרחב האמיתי שהוקצה לכרטיסייה הספציפית – בסרגל הצד היא תהיה קטנה יותר, ברשת גדולה יותר, למרות שה-viewport שומר כל הזמן על אותו רוחב בדיוק.
מלכודות ושיטות עבודה טובות
container-type: sizeבלי גובה מפורש "אוכל" את התוכן. containment בציר ה-block מנתק את האלמנט מהגובה הטבעי של הצאצאים שלו – אם לא תתנוheightאוaspect-ratio, המיכל יתכווץ בפועל לגובה אפס, והילדים שלו ייעלמו ויזואלית, למרות שהם עדיין ב-DOM. ברוב המכריע של המקריםinline-sizeמספיק, ואז אין את הבעיה הזו כלל.- מיכל לא יכול לשאול על עצמו. אם אתם נלחמים בזה ש-
@container"לא עובד", החשד הראשון הוא בדיוק זה:container-typeוהסלקטור המעוצב על ידי@containerיושבים על אותו אלמנט. הפרידו ביניהם –container-typeעל ה-wrapper, הסגנונות המכוונים על הילד, בדיוק כמו בדוגמת כרטיסיית המוצר למעלה. - container queries לא מחליפים media queries – הם משלימים אותם. media query עדיין הכלי הנכון להחלטות ברמת הדף (למשל האם בכלל להציג סרגל צד, או לעבור לניווט המבורגר). container query אחראי על החלטות ברמת הרכיב, בכל מקום שהוא ינחת בו. פריסה טובה ב-2026 בדרך כלל משתמשת בשניהם יחד, כל אחד במקום שבו השאלה שהוא שואל באמת מתאימה.
- אל תהפכו כל div למיכל "ליתר ביטחון". ל-containment יש עלות ממשית עבור מנוע הרינדור – זה סימן "התייחס אליי כאל יחידת פריסה מבודדת", שימושי במקום שבו יש לכם באמת רכיב שמגיב לגודל שלו, לא הגדרת ברירת מחדל לכל עץ ה-DOM.
- תמיכת הדפדפנים כבר לא בעיה.
container-typeו-@containerהגיעו למעמד Baseline (זמין באופן נרחב) בתחילת 2023 – הם עובדים באופן טבעי ב-Chrome, Safari ו-Firefox בלי polyfills ובלי@supports. ליחידותcqw/cqiולשאר המשפחה יש בדיוק את אותה כיסוי.
סיכום
רכיב לשימוש חוזר הוא לא כזה שיש לו מספיק props ומחלקות מודיפייר כדי לטפל ידנית בכל מקום שבו הוא עלול לנחות – זה כזה שיודע בעצמו כמה מקום יש לו, ומגיב לזה בלי עזרת אף אחד. media query היה במשך שני עשורים הכלי היחיד של CSS לרספונסיביות, אז באופן טבעי ניצלנו אותו יתר על המידה גם במקומות שבהם באמת היה מדובר בהורה, לא ב-viewport. Container Queries הם לא עוד וריאציה תחבירית על אותו מנגנון – זו שאלה אחרת, שנשאלת במקום הנכון בעץ ה-DOM, נתמכת ב-containment שמבטיח שהתשובה לעולם לא תיכנס ללולאה. הקוד מהמאמר הזה – רכיב אחד, קובץ CSS אחד, אפס JavaScript – עובד נכון ברשת, בסרגל צד, ובכל מקום אחר שעוד לא ידוע לכם שיום אחד תכניסו אותו אליו.