Skip to main content
العودة إلى المدونة
CSSContainer QueriesTypographyFrontendPerformance

لجعل النص يتلاءم مع حاويته باستخدام CSS خالص — دون JavaScript

الجميع يكتب خطاف JavaScript يقلّص font-size حتى يتّسع النص داخل الحاوية. إليك بديلاً يعمل بالكامل ودون أي JS باستخدام وحدات container query وclamp() والفصل المقطعي بشرطة حسب اللغة — بما في ذلك فخ التتالي (cascade) الذي يجعل كثيرين يستسلمون.

نُشر 24 يوليو 202611 دقيقة قراءة

للعناوين العرضية الكبيرة عادة مزعجة: النص الذي يتلاءم تمامًا في تصميمك يفيض بمجرد أن تضيق الحاوية أو يطول النص في لغة أخرى. لسنوات كان الحل القياسي هو JavaScript — قياس النص المُصيَّر، ومقارنته بحاويته، وتقليص الخط حتى يتّسع. يكاد الجميع يعيد اختراع هذا: خطاف useFitText منزلي، أو حلقة ResizeObserver، أو مكتبة مثل fitty أو textFit أو react-textfit.

يعرض هذا المقال بديلاً يعمل بالكامل مع صفر JavaScript: خط يتحجّم وفق حاويته باستخدام وحدات container query في CSS، بالإضافة إلى كسر أسطر سليم بالشرطة. لا خطاطيف، ولا قياس، ولا انزياح تخطيطي بعد الإماهة (hydration).

الـ JavaScript الذي يكتبه الجميع

النمط دائمًا هو ذاته. صيِّر النص بحجم أساسي، اقرأ عرض النص وعرض عنصره الأب، وإن كان النص أعرض فقلّل حجم الخط وحاول مجددًا. النسخة الدنيا تبدو هكذا:

useFitText.ts
import { useLayoutEffect, useRef, useState } from "react";

// Shrinks font-size by 1px until the text fits its parent.
export function useFitText() {
  const parentRef = useRef<HTMLElement>(null);
  const textRef = useRef<HTMLElement>(null);
  const [fontSize, setFontSize] = useState<number>();

  useLayoutEffect(() => {
    const parent = parentRef.current;
    const text = textRef.current;
    if (!parent || !text) return;

    const current = parseFloat(getComputedStyle(text).fontSize);
    if (parent.offsetWidth < text.offsetWidth) {
      setFontSize(current - 1); // re-render, measure again
    }
  }, [fontSize]);

  return { parentRef, textRef, fontSize };
}

إنه يعمل، لكن له تكاليف حقيقية:

  • يعمل بعد تركيب المكوّن، فيُرسم النص أولًا بحجم خاطئ ثم يقفز — انزياح تخطيطي مرئي وضربة لمقياس CLS.
  • في أطر العمل ذات التصيير على الخادم، يرسل الخادم حجمًا ويصحّحه العميل بعد الإماهة، وقد يسبّب ذلك ومضة.
  • يشحن JavaScript لأمر هو في جوهره مسألة تخطيط.
  • حلقة النقصان بمقدار 1px في كل مرة تسبّب عدة إعادات تصيير وتفرض قراءات تخطيط متزامنة — وهو بالضبط نوع العمل الذي لا تريده على الخيط الرئيسي.

لماذا كان JavaScript هو الجواب — حتى وقت قريب

يجدر أن نكون صريحين حول سبب وجود هذه الخطاطيف. قبل وصول container queries إلى المتصفحات، لم يكن بوسع CSS ببساطة تحديد حجم خط نسبةً إلى عنصر. كان بإمكانك التحجيم نسبةً إلى منفذ العرض (viewport) باستخدام vw، لكن عنوانًا داخل شريط جانبي عرضه 400px والعنوان نفسه داخل hero عرضه 1200px سيحصلان على الحجم ذاته المبني على vw، لأن vw لا يعرف شيئًا عن الحاوية.

غيّرت وحدات الطول في container query ذلك. لم تصبح آمنة عبر المتصفحات إلا نحو عام 2023، لذا فأي شيفرة لملاءمة الخط كُتبت قبل ذلك لم يكن لها خيار في CSS ولجأت إلى JavaScript اضطرارًا. زال ذلك القيد الآن — وهذا هو بيت القصيد من المقال.

اللبنة الأساسية: وحدات container query

وحدة الطول في container query هي نسبة مئوية من حجم حاوية الاستعلام. علِّم عنصرًا كحاوية عبر container-type: inline-size، فيصبح بمقدور نسله استخدام cqi — حيث يساوي 1cqi واحدًا بالمئة من حجم الحاوية السطري (عرضها في أوضاع الكتابة الأفقية).

.card {
  container-type: inline-size; /* this element is now a query container */
}

.card .title {
  /* 10% of .card's width — scales with the container, not the viewport */
  font-size: 10cqi;
}

الآن العنوان نفسه داخل بطاقة 400px يكون 40px، وداخل بطاقة 1200px يكون 120px — تلقائيًا ودون قياس. هذه هي القطعة التي كانت مفقودة قبل container queries: حجم خط يستجيب للأب.

تقييد الحجم باستخدام clamp()

الـ cqi المجرّد سيظل ينمو على الحاويات الضخمة ويتقلّص إلى لا شيء على الصغيرة جدًا. لُفّه بـ clamp() لتضع حدًا أدنى وحدًا أعلى — عادةً حجم الجوال كحد أدنى وحجم سطح المكتب كحد أقصى:

.card {
  container-type: inline-size;
}

.card .title {
  font-size: clamp(
    2rem,   /* min — never smaller than the mobile size */
    10cqi,  /* fluid — scales with the container width   */
    4.5rem  /* max — never larger than the desktop size  */
  );
}

اقرأها هكذا: كن انسيابيًا مع الحاوية، لكن لا تقل أبدًا عن 2rem ولا تزد أبدًا عن 4.5rem. الحد الأوسط (ميل cqi) يتحكّم في سرعة تحجّم الخط؛ والحدّان هما حجما خط الجوال وسطح المكتب في تصميمك. وإن كانت تلك الأحجام في custom properties، يمكنك قيادة كل شيء منها:

.title {
  --size-desktop: 72;
  --size-mobile: 32;
}

.title .fit {
  font-size: clamp(
    calc(var(--size-mobile) * 1px),
    10cqi,
    calc(var(--size-desktop) * 1px)
  );
}

فخّ سيكلّفك ساعة: التتالي (cascade)

هذا هو الفخ الذي يجعل الناس يستسلمون ويستنتجون: "container queries لا تعمل". تُحلّ الحاوية بشكل صحيح، والوحدة سليمة، ومع ذلك لا يغيّر الخط حجمه أبدًا. السبب دائمًا تقريبًا هو التتالي، لا container queries.

العَرَض مُضلِّل بمكر. يبقى خطك مثبَّتًا عند حدّه الأقصى في كل حاوية مهما ضاقت — وهو بالضبط سلوك cqi حين لا يجد حاوية فيرتد إلى منفذ العرض. فتذهب لتفحص البديهي: هل الحاوية مُهيّأة بشكل صحيح؟ تؤكّد وجود container-type: inline-size، وتتنقّل في الـ DOM للتحقق من أن السلف هو الحاوية فعلًا، وتقرأ container-type المحسوب للعنصر فيقول inline-size، وتقيس عرض الحاوية فيكون صحيحًا. كل شيء متطابق. والخط لا يزال عالقًا. أنت تبحث في المكان الخطأ.

أسرع مخرج هو أن تكفّ عن الثقة بقاعدتك وأن تُثبت الوحدة على نحو مستقل. أسقِط داخل الحاوية نفسها مِسبارًا مؤقتًا لا يحوي سوى قيمة cqi مجردة، وقِسه:

// Inject a bare probe as a child of the same container:
const probe = document.createElement("span");
probe.style.fontSize = "10cqi";
container.append(probe);

getComputedStyle(probe).fontSize;
// 90px inside a 900px container, 36px inside 360px → cqi works fine.

probe.remove();

إذا تحجّم المسبار بشكل صحيح بينما ظل عنصرك الحقيقي عالقًا، فذلك هو التشخيص كله: container queries تعمل بشكل مثالي، وشيء آخر يطغى على font-size لديك. إنه التتالي — والمذنب عادةً محدِّد أوسع نسيته.

غالبًا ما تضبط أنظمة التصميم الطباعة على عناصر متداخلة بقاعدة مثل .title p, .title span { font-size: … }. لهذا المحدِّد نوعية (specificity) قدرها (0,1,1). وإن استهدفت قاعدة الملاءمة لديك العنصر نفسه بصنف واحد — .fit { … } بقيمة (0,1,0) — فإن قاعدة نظام التصميم تفوز وتثبّت حجم الخط، فيُطغى على clamp() لديك بصمت. بل قد يقع الاثنان في طبقة التتالي نفسها، فلا تنقذك الطبقات (layers). والأسوأ أن تلك القاعدة الأوسع كانت على الأرجح مبنية على منفذ العرض، ولهذا بدا الحجم كارتداد إلى منفذ العرض — كنت حرفيًا ترى قاعدة أخرى تفوز.

الإصلاح هو أن تمنح قاعدتك نوعية مساوية على الأقل. تعشيقها تحت صنف الحاوية هو الأبسط — .title .fit قيمته (0,2,0)، وهو يتغلّب على .title p بقيمة (0,1,1):

/* Loses to `.title p { font-size: … }` — same layer, higher specificity */
.fit { font-size: clamp(2rem, 10cqi, 4.5rem); }

/* Wins: (0,2,0) > (0,1,1) */
.title .fit { font-size: clamp(2rem, 10cqi, 4.5rem); }

إذا بدا أن حجم خط container query لديك مُتجاهَل، افحص العنصر وتحقّق أي قاعدة تضبط <code>font-size</code> فعلًا. في تسع من كل عشر مرات يفوز محدِّد أوسع من نظام التصميم بالتتالي — أما container query نفسها فسليمة.

مشكلة الكلمة الطويلة — ولماذا ليست فشلًا في container query

ثمة أمر واحد لا يستطيع cqi فعله حقًا: فهو يحجّم الخط وفق عرض الحاوية، لا وفق طول سلسلة نصية بعينها. فكلمة واحدة غير قابلة للكسر قد تظل أعرض من الحاوية عند الحجم المحسوب. الألمانية هي المذنب الكلاسيكي — كلمة مثل Fremdsprachenkenntnisse ستفيض بسرور من صندوق ضيّق حتى عند حجم خط ملائم للحاوية.

هذا ليس عيبًا في المقاربة؛ بل هو حدّها الصادق. كان الخطاف القديم في JavaScript يعالج هذه الحالة بقياس تلك الكلمة بالذات ثم يقلّص أكثر. في CSS تعالجها على نحو مختلف — تدع الكلمة تنكسر بدلًا من تقليص العنوان بأكمله. خاصيتان تؤديان المهمة:

.title .fit {
  font-size: clamp(2rem, 10cqi, 4.5rem);

  /* Break a too-long word instead of overflowing the box */
  overflow-wrap: break-word;

  /* Balance line lengths for nicer multi-line headings */
  text-wrap: balance;
}

overflow-wrap: break-word هو شبكة الأمان: إن لم تتّسع كلمة في سطر، كسرها المتصفح بدلًا من تركها تفيض. وtext-wrap: balance هو اللمسة الأخيرة — يوازن أطوال الأسطر كي لا ينتهي عنوان مكسور بكلمة وحيدة يتيمة.

الكسر بشرطة — تلقائيًا، وحسب اللغة

كسر كلمة من منتصفها دون شرطة يبدو فظًّا. يستطيع CSS إدراج شرطة حقيقية عند نقطة صحيحة لغويًا عبر hyphens: auto. النقطة الجديرة بالفهم: الفصل المقطعي مرتبط باللغة. يستخدم المتصفح قاموس الفصل الخاص بلغة العنصر، والتي يقرؤها من السمة lang.

.title .fit {
  font-size: clamp(2rem, 10cqi, 4.5rem);
  hyphens: auto;              /* insert a real hyphen at valid break points */
  overflow-wrap: break-word;  /* last-resort break for words with no valid point */
  text-wrap: balance;
}

الجزء الأنيق: إن كان مستندك يضبط اللغة عند الجذر أصلًا — <html lang="de"> لصفحة ألمانية — فإن الفصل المقطعي يعمل من تلقاء نفسه، لكل لغة، دون توصيل إضافي. الصفحات الألمانية تُفصل بالقاموس الألماني، والإنجليزية بالإنجليزي. تحصل على شُرَط صحيحة في كل لغة مجانًا، ما دام lang يعكس المحتوى.

أبقِ overflow-wrap: break-word إلى جانبها. يتعامل hyphens: auto مع الكلمات التي يعرف كيف يفصلها؛ ويلتقط البديل كل ما تبقّى (الروابط، أسماء العلامات التجارية، المركّبات المُختلقة) كي لا يفيض شيء أبدًا.

تجميع كل ذلك معًا

إليك المكوّن الكامل، دون تبعيات. لاحظ بنية العنصرين: عنصر خارجي هو الحاوية، وعنصر داخلي يقرأ cqi منها. لا يستطيع عنصر أن يحجّم خطه الخاص من حاويته الخاصة، فأنت بحاجة إلى فصل الأب/الابن.

<h2 class="title" style="--size-desktop: 72; --size-mobile: 32">
  <span class="fit">Learn anything, beautifully</span>
</h2>
title.css
.title {
  /* The container the inner text scales against */
  container-type: inline-size;
}

/* Nested selector so it out-specifies design-system typography rules */
.title .fit {
  display: block;

  /* Fluid between the mobile and desktop sizes, driven by container width */
  font-size: clamp(
    calc(var(--size-mobile) * 1px),
    10cqi,
    calc(var(--size-desktop) * 1px)
  );

  /* Never overflow: break long words, hyphenate by language, balance lines */
  overflow-wrap: break-word;
  hyphens: auto;
  text-wrap: balance;
}

هذه هي الميزة بأكملها: عنوان يتحجّم مع حاويته، ويحترم حدًا أدنى للجوال وحدًا أعلى لسطح المكتب، ولا يفيض أبدًا، وينكسر بشُرَط صحيحة في أي لغة — ولا يشحن بايتًا واحدًا من JavaScript.

CSS مقابل JavaScript، بصراحة

الجانبخطاف / مكتبة JS لملاءمة النصCSS خالص (cqi + clamp)
يستجيب لعرض الحاويةنعم (عبر القياس)نعم (عبر cqi)
يستجيب للطول الدقيق للسلسلةنعملا — يُقيَّد بدلًا من ذلك عبر overflow-wrap / hyphens
انزياح تخطيطي بعد التحميلشائع (قياس ثم إعادة تحجيم)لا شيء — صحيح من أول رسم (paint)
يعمل أثناء SSR قبل الإماهةلانعم
JavaScript مشحوننعملا شيء
الكلفة على الخيط الرئيسيإعادات تدفّق + إعادات تصييرصفر
فصل مقطعي حسب اللغةيدويمدمج عبر hyphens: auto

العمود الوحيد الذي لا يزال JavaScript يفوز فيه هو الملاءمة الدقيقة لكل سلسلة — إجبار عنوان بعينه على سطر واحد بالضبط بأكبر حجم يتّسع. إن كان هذا السلوك الدقيق مطلبًا صارمًا، فإن القياس (JS، أو عنصر SVG <text> يتحجّم إلى viewBox) يبقى السبيل الوحيد. لكن بالنسبة للغالبية العظمى من عناوين العرض، يبدو التحجيم النسبي للحاوية جيدًا بالقدر نفسه أو أفضل — ودون أيٍّ من تلك التكاليف.

دعم المتصفحات

وحدات الطول في container query (cqi وأخواتها) مدعومة في كل المتصفحات دائمة التحديث الحالية، ومنذ عام 2023. كما أن clamp() وoverflow-wrap وhyphens وtext-wrap: balance متاحة على نطاق واسع أيضًا (وtext-wrap: balance هو الأحدث، ويتراجع بلطف إلى الالتفاف العادي حيثما غاب). وللحصول على بديل على المتصفحات القديمة جدًا، يكفي font-size بسيط مُعلَن قبل سطر clamp().

الخلاصات

  • الـ JavaScript الخاص بملاءمة الخط الذي يكتبه الجميع موجود في الأساس لأن CSS لم يكن بإمكانه تحجيم الطباعة حسب الحاوية إلى أن وصلت container queries.
  • container-type: inline-size + font-size: clamp(min, Ncqi, max) يمنح طباعة نسبية للحاوية بحد أدنى للجوال وحد أعلى لسطح المكتب.
  • إن بدا الحجم مُتجاهَلًا، فهو التتالي — تجاوز نوعية محدِّدات نظام التصميم الواسعة بتعشيق قاعدتك.
  • cqi يتحجّم وفق عرض الحاوية لا وفق طول السلسلة؛ استخدم overflow-wrap: break-word وhyphens: auto كي تنكسر الكلمات الطويلة بنظافة بدل أن تفيض.
  • hyphens: auto يراعي اللغة مجانًا حين تكون lang المستند مضبوطة بشكل صحيح.
  • لا تلجأ إلى JavaScript إلا حين تحتاج فعلًا إلى ملاءمة دقيقة بسطر واحد لكل سلسلة.

أصبحت الآن منشورة — حزمتان صغيرتان جدًا وبلا تبعيات: @oleksiimazurenko/react-fit-text لـ React و@oleksiimazurenko/fit-text لنواة CSS المستقلة عن أطر العمل (الشيفرة المصدرية على GitHub):

npm install @oleksiimazurenko/react-fit-text
import { FitText } from '@oleksiimazurenko/react-fit-text'
import '@oleksiimazurenko/fit-text/style.css'

// No props needed — scales to its container with sensible defaults.
<FitText>Learn anything, beautifully</FitText>