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

أكورديون سلس بدون أي JavaScript

يلجأ الجميع إلى حالة React وخطّاف يقيس الارتفاع لبناء أكورديون. لقد وفّر المتصفح عنصرًا أصليًا لسنوات — واعتبارًا من 2024 صار يحرّك الفتح والإغلاق بـ CSS خالص. إليك التقنية الكاملة: details[name] و ::details-content و interpolate-size والخاصية الوحيدة التي تجعل الإغلاق يتحرّك أيضًا.

نُشر 28 يوليو 20269 دقيقة قراءة

الأكورديون من أكثر عناصر الواجهة التي يُعاد تنفيذها على الويب. الوصفة المعتادة: قطعة من حالة React لما هو مفتوح، وonClick لتبديله، وبعض CSS (أو خطّاف يقيس الارتفاع) لتحريك الكشف. إنه يعمل، لكنه يحوّل قطعة محتوى ثابت إلى مكوّن عميل يشحن JavaScript ويُميّهه (hydrates) — لمجرد الفتح والإغلاق.

لطالما امتلك المتصفح عنصر أكورديون أصلي لسنوات: <details>. أما ضعفه التاريخي الوحيد — تعذّر تحريكه — فقد زال اعتبارًا من 2024. تبني هذه المقالة أكورديون حصريًا مُحرّكًا بالكامل مع سهم (chevron) دوّار وبـصفر JavaScript: لا حالة، ولا خطّافات، ولا تمييه.

الأكورديون الذي يشحنه الجميع

إليك النمط في أكثر صوره شيوعًا — حالة React للعنصر المفتوح، ومعالِج نقر لتبديله، وحيلة grid-template-rows: 0fr → 1fr لتحريك الارتفاع دون قياس أي شيء:

Accordion.tsx
"use client"; // ← the whole component is now a client bundle

import { useState } from "react";

export function Accordion({ items }: { items: Item[] }) {
  const [openId, setOpenId] = useState<string | null>(null);

  return (
    <div>
      {items.map((item) => {
        const isOpen = openId === item.id;
        return (
          <div key={item.id}>
            <button onClick={() => setOpenId(isOpen ? null : item.id)}>
              {item.title}
            </button>
            {/* grid 0fr → 1fr is the CSS trick that animates the height */}
            <div className="body" data-open={isOpen}>
              <div>{item.content}</div>
            </div>
          </div>
        );
      })}
    </div>
  );
}
.body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 300ms;
}
.body[data-open="true"] { grid-template-rows: 1fr; }
.body > div { overflow: hidden; }

لا بأس به، وحيلة grid ذكية بحق. لكن انتبه إلى ثمنها: عبارة "use client" في الأعلى. صار هذا المكوّن الآن يُنزَّل ويُحلَّل ويُميَّه في كل صفحة يظهر فيها — ويفعل كل ذلك لإدارة قيمة منطقية (boolean) واحدة. أما بالنسبة لقسم أسئلة شائعة عرضي لم يكن بحاجة قط إلى أن يكون تفاعليًا بمعنى React، فهذا عبء صافٍ.

العنصر الأصلي الذي نسيته

<details> و<summary> عنصر إفصاح مدمج. يتولّى المتصفح حالة الفتح/الإغلاق، والتفاعل بالنقر ولوحة المفاتيح، وإمكانية الوصول — مجانًا:

<details>
  <summary>What does it cost?</summary>
  <p>Nothing — it's a native browser element.</p>
</details>

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

لماذا تعذّر تحريكه — حتى وقت قريب

السبب دقيق. عندما يكون <details> مغلقًا، لا يكتفي المتصفح بإخفاء محتواه بصريًا — بل يُخرج المحتوى من العرض (rendering) كليًّا (إخفاء بأسلوب content-visibility). لا يمكنك الانتقال إلى حالة "غير مُعروض" أو منها، ولزمن طويل لم يكن بوسعك أيضًا الانتقال بـheight إلى auto أو منها. فلم يكن هناك شيء يُحرَّك بينهما.

جرّب الناس نفس حيلة grid التي تستخدمها نسخة الـ JavaScript، بنقلها إلى السمة [open]:

/* Looks like it should work — but only animates OPENING. */
details > .body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 300ms;
}
details[open] > .body { grid-template-rows: 1fr; }
/* On close, the browser hides the content the instant [open] is gone,
   so the collapse never animates — it just snaps shut. */

تبدو صحيحة، بل إنها تحرّك الفتح فعلًا. لكنها تفشل عند الإغلاق: في اللحظة التي تُزال فيها السمة [open]، يُخفي المتصفح المحتوى فورًا، فلا يحظى الانطواء بفرصة للتحرّك — بل ينغلق دفعة واحدة. وهذا الحل النصفي هو ما أبقى الجميع على الـ JavaScript.

الحل: ::details-content + interpolate-size

تحلّه معًا ثلاث ميزات CSS ظهرت في 2024. المفتاح عنصر زائف جديد، ::details-content، يستهدف بالضبط الجزء القابل للطيّ من <details> — فتستطيع تنسيقه وتحريكه مباشرة:

:root {
  /* lets height/block-size transition to and from the 'auto' keyword */
  interpolate-size: allow-keywords;
}

details::details-content {
  block-size: 0;
  overflow: clip;
  transition:
    block-size 300ms ease,
    /* keeps the content rendered while it collapses, instead of vanishing */
    content-visibility 300ms allow-discrete;
}

details[open]::details-content {
  block-size: auto;
}

بقراءتها كوحدة واحدة، هي ثلاثة أمور تتعاون:

  • ::details-content يمنحك مقبضًا على منطقة المحتوى المخفي، التي لم تكن تستطيع تحديدها من قبل إطلاقًا.
  • interpolate-size: allow-keywords يسمح بتحريك block-size بين 0 والارتفاع الجوهري auto — الأمر الذي رفضت CSS فعله تاريخيًا.
  • transition-behavior: allow-discrete (عبر إدراج content-visibility ضمن الانتقال) يُبقي المحتوى مُعروضًا طوال الانطواء، فيتحرّك الإغلاق بدل أن ينقطع دفعة واحدة.

معًا تُحرّك الاتجاهين — الفتح والإغلاق — مع تخفيف الارتفاع بسلاسة إلى الحجم الطبيعي للمحتوى ومنه. بلا قياس، ولا خطّاف، ولا حالة.

أكورديون حصري، بسمة واحدة

تريد معظم الأكورديونات "واحدًا مفتوحًا فقط في كل مرة". في نسخة الـ JavaScript، هذا ما تخدمه حالة openId. أما أصليًّا فهي سمة واحدة: امنح كل <details> في المجموعة نفس الاسم name، فيفرض المتصفح الحصرية — فتح واحد يُغلق البقية:

<!-- Same name = one exclusive group. Opening one closes the others. -->
<details name="faq"><summary>First</summary><p>…</p></details>
<details name="faq"><summary>Second</summary><p>…</p></details>
<details name="faq"><summary>Third</summary><p>…</p></details>

تنبيه جدير بالمعرفة: تُعرّف name مجموعة حصرية على مستوى الصفحة بأكملها، فإذا عرضت أكورديونين مستقلين، فامنح كلًّا منهما name فريدًا خاصًّا به — وإلا فإن فتح عنصر في أحدهما سيُغلق عنصرًا في الآخر.

السهم، بلا JavaScript

آخر جزء يلجأ فيه الناس إلى JS هو السهم الصغير الذي ينقلب عند فتح القسم. إنه مجرد تحويل (transform) مرتبط بالسمة [open] — المتصفح هو من يضبط تلك السمة، فيكون الدوران بـ CSS خالص. وأخفِ هنا أيضًا مثلث الإفصاح الافتراضي:

summary {
  list-style: none;            /* remove the default triangle marker */
  cursor: pointer;
}
summary::-webkit-details-marker { display: none; }  /* Safari */

summary .chevron {
  transition: transform 200ms ease;
}
details[open] summary .chevron {
  transform: rotate(180deg);   /* flip on open — pure CSS, no JS */
}

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

إليك الأمر كاملًا — عنصر أكورديون حصري ومُحرّك مع سهم دوّار وفجوة بين السؤال والجواب:

<details name="faq" class="item">
  <summary>
    <h3>How does it work?</h3>
    <svg class="chevron" aria-hidden="true"><!-- ↓ --></svg>
  </summary>
  <div class="answer">
    <p>Native open/close, exclusive grouping, smooth animation — zero JS.</p>
  </div>
</details>
:root { interpolate-size: allow-keywords; }

.item::details-content {
  block-size: 0;
  overflow: clip;
  transition:
    block-size 300ms ease,
    content-visibility 300ms allow-discrete;
}
.item[open]::details-content { block-size: auto; }

.item[open] summary .chevron { transform: rotate(180deg); }
.answer { padding-top: 12px; }   /* gap between question and answer */

هذه هي الميزة الكاملة. فتح/إغلاق، ودعم لوحة المفاتيح، وإمكانية وصول، وحصرية، وحركة ثنائية الاتجاه سلسة، وسهم ينقلب — ولا يشحن بايتًا واحدًا من JavaScript. يُعرَض بوصفه HTML ثابتًا على الخادم ولا يحتاج إلى أي شيء على العميل ليعمل.

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

::details-content وinterpolate-size وallow-discrete حديثة (Chromium 129–131، Safari 18.x، ثم Firefox لاحقًا). يبدو ذلك محفوفًا بالمخاطر، لكن التدهور من أفضل نوع — لا تحتاج إلى @supports إطلاقًا:

/* No @supports needed: browsers that don't know ::details-content simply
   ignore these rules. The <details> still opens and closes — just instantly.
   The animation is a progressive enhancement, never a requirement. */

في متصفح لا يفهم هذه القواعد، يظل <details> يفتح ويغلق على نحو صحيح؛ لكنه يفعل ذلك فوريًا فحسب، بلا حركة. الحركة تحسين تدريجي مُضاف فوق عنصر يعمل أصلًا في كل مكان — فلا بديل احتياطي تكتبه ولا شيء ينكسر.

السلوك — الفتح والإغلاق ولوحة المفاتيح وإمكانية الوصول والحصرية — أصلي ويعمل في كل مكان. والحركة CSS خالص فوقه. وإذا كان المتصفح أقدم من أن يدعم الحركة، فإن الأكورديون يظل يعمل؛ إنما لا يخفّف الحركة.

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

المسألةأكورديون JS (حالة + خطّاف)‏<code>&lt;details&gt;</code> الأصلي + CSS
الفتح / الإغلاقحالة React + onClickمدمج في المتصفح
‏JavaScript المشحوننعم (مكوّن عميل، يُميَّه)لا شيء
حركة سلسةنعم (grid / ارتفاع مقيس)نعم (::details-content)
واحد-مفتوح-فقطحالة يدوية (openId)السمة name
لوحة المفاتيح + إمكانية الوصولتوصّلها بنفسك (أو مكتبة)أصلي
يعمل قبل التمييه / مع تعطيل JSلانعم
انقلاب السهمصنف (class) يبدّله JS‏CSS على [open]

لا يزال JavaScript يتفوّق حين يكون محتوى اللوح ديناميكيًا حقًّا — يُحمَّل عند الطلب، أو تقوده حالة التطبيق، أو حين يجب أن يُطلق الفتح آثارًا جانبية تتحكّم بها في JS. لكن في الحالة الشائعة الغالبة — قسم أسئلة شائعة عرضي أو قائمة إفصاح — يضاهي العنصر الأصلي نسخة الـ JS ميزةً بميزة، ودون أي حجم حزمة.

الخلاصات

  • الأكورديون بلغة JavaScript الذي يكتبه الجميع وُجد أساسًا لأن <details> الأصلي كان عاجزًا عن التحريك — وهو قيد زال الآن.
  • ::details-content + interpolate-size: allow-keywords يحرّكان الارتفاع بين 0 وauto.
  • الحيلة التي تجعل الإغلاق يتحرّك (لا الفتح فقط) هي transition-behavior: allow-discrete على content-visibility.
  • <details name="…"> يمنحك مجموعة حصرية "واحد مفتوح في كل مرة" بصفر JavaScript — استخدم اسمًا فريدًا لكل أكورديون.
  • انقلاب السهم هو transform بلغة CSS مرتبط بالسمة [open].
  • يتدهور بأناقة دون @supports: المتصفحات القديمة تفتح/تغلق فوريًا، والحركة تحسين خالص.

لا شيء من هذه القطع غريبٌ — فـ<details> عمره عقود، وميزات الحركة CSS مملّة وموثّقة المواصفات جيدًا. التحوّل هو إدراكك أنك لم تعد مضطرًا للجوء إلى حالة React لتحصل على أكورديون مصقول. احذف مكوّن العميل، واشحن HTML ثابتًا، ودع المتصفح يفعل ما ظلّ قادرًا على فعله بهدوء طوال الوقت.