مقدمة
يصطدم كل فريق React في النهاية بالجدار نفسه. تبدو مجموعة المكوّنات المنسّقة مسبقًا رائعة في يومها الأول، ثم تقاومك في اليوم الثلاثين عندما يطلب نظام التصميم شيئًا لم تتوقّعه المكتبة قط. تتجاوز الأنماط بمحدّدات يائسة أكثر فأكثر، وتطرح غلافًا، وتكره التجريد في صمت.
يراهن Base UI على العكس تمامًا. بُني على يد الفرق التي تقف خلف Radix UI وFloating UI وMaterial UI، ويأتي بعناصر أولية غير منسّقة ومتاحة بالكامل، ثم يبتعد عن طريقك. تحصل على الأجزاء الصعبة مجانًا — التنقّل بلوحة المفاتيح، وإدارة التركيز، وتوصيل ARIA، وتحديد المواضع المدرك للتصادم — وتملك أنت كل بكسل من التنسيق. لا توجد سمة (theme) تقاومها، ولا CSS تتجاوزها، فقط عناصر HTML نظيفة تزخرفها كما يحلو لك.
في هذا الدرس ستدمج Base UI في مشروع Next.js يستخدم App Router، وتبني أربعة مكوّنات حقيقية: Dialog وSelect وAccordion وTooltip. وفي أثناء ذلك ستتعلّم البنية القائمة على الأجزاء التي تجعل Base UI قابلًا للتركيب، ونموذج التنسيق بسمات البيانات الذي يجعله يبدو أصيلًا.
المتطلبات المسبقة
قبل البدء، تأكّد من توفّر ما يلي:
- Node.js الإصدار 20 أو أحدث
- مشروع Next.js 15 يستخدم App Router (أو استعدادك لإنشاء واحد)
- إلمام بمكوّنات الدوال (function components) والـ hooks في React
- معرفة أساسية بـ CSS — سنستخدم CSS عاديًا، لكن الأنماط تنطبق على Tailwind أو CSS Modules
- نحو 25 دقيقة من التركيز
ما الذي ستبنيه
سطح "إعدادات" صغير يوضّح العناصر الأولية التفاعلية الأربعة الأكثر طلبًا:
- مكوّن Dialog لنافذة تأكيد منبثقة مع حصر التركيز وقفل التمرير
- قائمة Select لاختيار سمة، مع البحث بالكتابة عبر لوحة المفاتيح
- مكوّن Accordion لقسم أسئلة شائعة قابل للطي
- مكوّن Tooltip يضع نفسه بعيدًا عن حواف الشاشة تلقائيًا
كل مكوّن غير منسّق افتراضيًا، فترى بالضبط كيف يفصل Base UI السلوك عن المظهر.
الخطوة 1: أنشئ المشروع وثبّت Base UI
إن لم يكن لديك تطبيق Next.js بالفعل، أنشئ واحدًا:
npx create-next-app@latest base-ui-demo --typescript --app --no-src-dir
cd base-ui-demoثم ثبّت Base UI. إنها حزمة واحدة تكشف كل مكوّن عبر استيرادات عميقة:
npm install @base-ui/reactهذا هو الإعداد بأكمله. لا يوجد مزوّد (provider) تلفّ به تطبيقك، ولا كائن سمة (theme) تضبطه، ولا ملف CSS مطلوب منك استيراده. مكوّنات Base UI قابلة لإزالة الشجرة غير المستخدمة (tree-shakeable): استيراد Dialog يجلب شيفرة Dialog فقط.
الخطوة 2: افهم البنية القائمة على الأجزاء
أهم مفهوم منفرد في Base UI هو أن المكوّنات مجموعات من الأجزاء، لا صناديق سوداء متجانسة. فبدلًا من <Dialog> واحد يأخذ عشرين خاصية (prop)، تركّب أجزاءً أصغر مسمّاة يقدّم كل منها عنصر DOM حقيقيًا واحدًا.
إليك بنية Accordion كما هي في المكتبة:
import { Accordion } from '@base-ui/react/accordion';
<Accordion.Root>
<Accordion.Item>
<Accordion.Header>
<Accordion.Trigger />
</Accordion.Header>
<Accordion.Panel />
</Accordion.Item>
</Accordion.Root>كل جزء عنصر حقيقي يمكنك تنسيقه، وأخذ مرجع (ref) له، وتوسيعه. يملك Accordion.Root الحالة، وAccordion.Trigger هو الزر القابل للنقر، وAccordion.Panel هو المنطقة القابلة للطي. ولأن الأجزاء صريحة، فلا يوجد أي غموض حول العنصر الذي يحطّ عليه أي صنف (class). إنه نموذج التفكير نفسه عبر Dialog وSelect وTooltip وبقية المكوّنات — تعلّمه مرة واحدة فيبدو كل مكوّن مألوفًا.
الركيزة الثانية هي خاصية العرض (render prop). يقبل كل جزء خاصية render تتيح لك تبديل العنصر الأساسي أو تركيبه مع مكوّن آخر دون فقدان السلوك. هكذا توصّل Base UI بمكتبة توجيه (routing):
import NextLink from 'next/link';
import { NavigationMenu } from '@base-ui/react/navigation-menu';
function Link(props) {
return <NavigationMenu.Link render={<NextLink href={props.href} />} {...props} />;
}الخطوة 3: ابنِ Dialog
النوافذ الحوارية هي المكان الذي تخطئ فيه الإتاحة عادةً — يفلت التركيز، وتبقى الخلفية قابلة للتمرير، ولا تعلن قارئات الشاشة شيئًا. يتولّى Base UI كل ذلك. أنشئ الملف components/ConfirmDialog.tsx:
'use client';
import { Dialog } from '@base-ui/react/dialog';
import './confirm-dialog.css';
export function ConfirmDialog() {
return (
<Dialog.Root>
<Dialog.Trigger className="Button">Delete account</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop className="Backdrop" />
<Dialog.Popup className="Popup">
<Dialog.Title className="Title">Delete your account?</Dialog.Title>
<Dialog.Description className="Description">
This action is permanent and cannot be undone.
</Dialog.Description>
<div className="Actions">
<Dialog.Close className="Button">Cancel</Dialog.Close>
<button className="Button Button--danger" type="button">
Confirm
</button>
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}لاحظ التوجيه 'use client'. تعتمد مكوّنات Base UI التفاعلية على الحالة والتأثيرات (effects)، لذا يجب أن تعمل داخل مكوّنات العميل (Client Components). في App Router لا يزال بإمكانك عرضها داخل مكوّنات الخادم (Server Components) — يُرسم الحد عند الملف الذي يستورد Base UI.
يعرض Dialog.Portal النافذة المنبثقة في نهاية body المستند بحيث تفلت من أي سلف يحمل overflow: hidden. أما Dialog.Backdrop فهو الطبقة المعتمة، وDialog.Popup هو الحاوية محصورة التركيز. عند فتح النافذة، ينتقل التركيز إلى داخلها تلقائيًا؛ وعند إغلاقها، يعود التركيز إلى المُشغِّل (trigger).
الخطوة 4: نسّق باستخدام سمات البيانات
يكشف Base UI الحالة عبر سمات البيانات (data attributes) على كل جزء، فتنسّق الحالات بمحدّدات CSS عادية بدلًا من منطق أصناف شرطي. تحمل النافذة المنبثقة data-open عندما تكون مرئية وdata-closed عندما تكون مخفية. أنشئ الملف components/confirm-dialog.css:
.Backdrop {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 0.5);
transition: opacity 200ms;
}
.Backdrop[data-starting-style],
.Backdrop[data-ending-style] {
opacity: 0;
}
.Popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 24rem;
padding: 1.5rem;
border-radius: 0.75rem;
background: white;
box-shadow: 0 10px 40px rgb(0 0 0 / 0.2);
transition: opacity 200ms, transform 200ms;
}
.Popup[data-starting-style],
.Popup[data-ending-style] {
opacity: 0;
transform: translate(-50%, -50%) scale(0.95);
}
.Title { font-size: 1.125rem; font-weight: 600; margin: 0 0 0.5rem; }
.Description { color: #555; margin: 0 0 1.5rem; }
.Actions { display: flex; gap: 0.75rem; justify-content: flex-end; }
.Button {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: 1px solid #ddd;
background: #f5f5f5;
cursor: pointer;
}
.Button--danger { background: #e5484d; color: white; border-color: #e5484d; }السمتان data-starting-style وdata-ending-style هما الجزء الأنيق. يطبّق Base UI السمة data-starting-style لإطار واحد عند تركيب العنصر، ويطبّق data-ending-style أثناء خروجه بالحركة — ما يتيح لك بناء انتقالات الدخول والخروج بـ CSS خالص، دون مكتبة حركة، ودون خطر إزالة العنصر قبل اكتمال خروجه.
الخطوة 5: ابنِ Select
لا يمكن تنسيق عناصر <select> الأصلية بشكل ذي معنى، وهي تتعطّل على أجهزة اللمس. يمنحك Select في Base UI صندوق قائمة (listbox) مخصّصًا بالكامل ومتاحًا مع البحث بالكتابة ودعم لوحة المفاتيح. أنشئ الملف components/ThemeSelect.tsx:
'use client';
import { Select } from '@base-ui/react/select';
const themes = [
{ label: 'System', value: 'system' },
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
];
export function ThemeSelect() {
return (
<Select.Root defaultValue="system" items={themes}>
<Select.Trigger className="Select-trigger">
<Select.Value />
<Select.Icon>▾</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Positioner sideOffset={6}>
<Select.Popup className="Select-popup">
{themes.map((theme) => (
<Select.Item key={theme.value} value={theme.value} className="Select-item">
<Select.ItemText>{theme.label}</Select.ItemText>
<Select.ItemIndicator>✓</Select.ItemIndicator>
</Select.Item>
))}
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
);
}يعمل Select.Positioner بقوة Floating UI خلف الكواليس. تضيف الخاصية sideOffset فجوة بين المُشغِّل والنافذة المنبثقة، ويقلب المُموضِع القائمة فوق المُشغِّل تلقائيًا عندما لا توجد مساحة في الأسفل. يعرض Select.Value الاختيار الحالي، ويُقدَّم Select.ItemIndicator داخل العنصر المحدّد فقط — وهو مثالي لعلامة الاختيار.
نسّق حالات المُشغِّل بسمات البيانات التي تكشفها الوثائق، مثل data-popup-open عندما تكون القائمة مفتوحة وdata-disabled عندما يكون التحكّم معطّلًا:
.Select-trigger {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid #ddd;
border-radius: 0.5rem;
background: white;
cursor: pointer;
}
.Select-trigger[data-popup-open] { border-color: #6366f1; }
.Select-popup {
min-width: 10rem;
padding: 0.25rem;
background: white;
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.15);
}
.Select-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
cursor: pointer;
}
.Select-item[data-highlighted] { background: #6366f1; color: white; }تُضبط السمة data-highlighted على أي عنصر تركّز عليه لوحة المفاتيح أو المؤشّر حاليًا، فتغطّي قاعدة CSS واحدة كلًّا من التنقّل بأسهم لوحة المفاتيح والتمرير بالمؤشّر (hover).
الخطوة 6: ابنِ Accordion
يربط Accordion كل ما تعلّمته عن الأجزاء وسمات البيانات. أنشئ الملف components/Faq.tsx:
'use client';
import { Accordion } from '@base-ui/react/accordion';
const faqs = [
{ q: 'Is Base UI free?', a: 'Yes, it is open source and MIT licensed.' },
{ q: 'Does it ship any CSS?', a: 'No. Every component is unstyled by default.' },
{ q: 'Is it accessible?', a: 'Yes. ARIA roles and keyboard support are built in.' },
];
export function Faq() {
return (
<Accordion.Root className="Accordion">
{faqs.map((faq, index) => (
<Accordion.Item key={index} className="Accordion-item">
<Accordion.Header>
<Accordion.Trigger className="Accordion-trigger">
{faq.q}
<span className="Accordion-chevron">▾</span>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel className="Accordion-panel">{faq.a}</Accordion.Panel>
</Accordion.Item>
))}
</Accordion.Root>
);
}يقيس Base UI اللوحة (panel) ويكشف ارتفاعها عبر متغيّر CSS اسمه --accordion-panel-height، فيمكنك تحريك انتقال الفتح والإغلاق بسلاسة حتى لو كان ارتفاع المحتوى ديناميكيًا:
.Accordion-trigger {
display: flex;
justify-content: space-between;
width: 100%;
padding: 1rem;
background: none;
border: 0;
font-size: 1rem;
text-align: left;
cursor: pointer;
}
.Accordion-chevron { transition: transform 200ms; }
.Accordion-trigger[data-panel-open] .Accordion-chevron { transform: rotate(180deg); }
.Accordion-panel {
overflow: hidden;
height: var(--accordion-panel-height);
transition: height 200ms ease;
padding: 0 1rem;
}
.Accordion-panel[data-starting-style],
.Accordion-panel[data-ending-style] {
height: 0;
}عندما تكون لوحة مفتوحة، يكتسب مُشغِّلها السمة data-panel-open، التي نستخدمها لتدوير السهم (chevron). لا حالة، ولا useEffect، ولا شيفرة قياس — تتعقّب المكتبة ذلك نيابةً عنك.
الخطوة 7: ابنِ Tooltip
أخيرًا، مكوّن Tooltip يحترم حواف الشاشة. لُفَّ قسم تطبيقك داخل Tooltip.Provider بحيث تتشارك عدة tooltips تأخيرات فتح وإغلاق معقولة:
'use client';
import { Tooltip } from '@base-ui/react/tooltip';
export function SaveButton() {
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger className="Button">Save</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Positioner sideOffset={8}>
<Tooltip.Popup className="Tooltip-popup">
Saves your changes to the cloud
<Tooltip.Arrow className="Tooltip-arrow" />
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}يقرأ Tooltip.Arrow موضعه من المُموضِع ويحاذي نفسه إلى المُشغِّل تلقائيًا، حتى بعد قلب ناتج عن تصادم. يُفتح الـ tooltip عند التمرير بالمؤشّر وعند تركيز لوحة المفاتيح، فهو متاح لمستخدمي لوحة المفاتيح دون أي عمل إضافي.
الخطوة 8: جمّع الصفحة
ضع كل شيء في مسار (route). ولأن المكوّنات الطرفية تحمل 'use client'، يمكن أن تبقى صفحتك مكوّن خادم (Server Component):
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ThemeSelect } from '@/components/ThemeSelect';
import { Faq } from '@/components/Faq';
import { SaveButton } from '@/components/SaveButton';
export default function SettingsPage() {
return (
<main style={{ maxWidth: '36rem', margin: '4rem auto', display: 'grid', gap: '2rem' }}>
<h1>Settings</h1>
<section>
<h2>Theme</h2>
<ThemeSelect />
</section>
<section>
<h2>Account</h2>
<ConfirmDialog />
</section>
<section>
<h2>FAQ</h2>
<Faq />
</section>
<SaveButton />
</main>
);
}اختبار تنفيذك
شغّل خادم التطوير وضع المكوّنات على المحك:
npm run devتحقّق من سلوك الإتاحة، فهو جوهر Base UI بأكمله:
- افتح النافذة الحوارية، ثم اضغط Tab مرارًا — يبقى التركيز محصورًا داخل النافذة المنبثقة
- اضغط Escape — تُغلق النافذة ويعود التركيز إلى المُشغِّل
- افتح Select بلوحة المفاتيح واكتب الحرف الأول لأحد الخيارات — يقفز البحث بالكتابة إليه
- انتقل بـ Tab إلى مُشغّلات Accordion واضغط Enter أو Space لتبديل اللوحات
- انتقل بـ Tab إلى زر Save — يظهر الـ tooltip عند التركيز، وليس عند التمرير بالمؤشّر فقط
استكشاف الأخطاء وإصلاحها
أخطاء "Cannot read properties of null" أو أخطاء الترطيب (hydration). نسيت 'use client' في ملف يستخدم مكوّن Base UI. تحتاج الأجزاء التفاعلية إلى زمن تشغيل العميل.
النافذة المنبثقة مقصوصة داخل حاوية قابلة للتمرير. تأكّد من أنك تعرض عبر جزء Portal الخاص بالمكوّن. فبدون الـ portal، تكون النافذة المنبثقة مقيّدة بقواعد overflow للأسلاف.
لا تعمل الحركات عند الخروج. تأكّد من أنك تنسّق السمة data-ending-style وأن transition معلَن على المحدّد الأساسي، لا داخل قاعدة starting-style وحدها.
الأنماط لا تُطبّق. راجع بدقّة أسماء سمات البيانات مقابل وثائق المكوّن. يكشف كل جزء مجموعة مختلفة، فمثلًا data-popup-open على مُشغِّل Select مقابل data-panel-open على مُشغِّل Accordion.
الخطوات التالية
- استبدل CSS العادي بأدوات Tailwind أو CSS Modules — يعمل نموذج سمات البيانات مع متغيّرات
data-[open]:في Tailwind - استكشف العناصر الأولية الأخرى: Menu وPopover وTabs وSwitch وCheckbox وAutocomplete تتشارك البنية نفسها
- لُفَّ كل عنصر أولي في مكوّنك المنسّق الخاص لبناء نظام تصميم داخلي تتحكّم فيه بالكامل
- اقرن سلوك Base UI بانضباط التنسيق من دليلنا لمكتبة المكوّنات shadcn/ui
خاتمة
يقدّم Base UI صفقة مختلفة عن مجموعة مكوّنات "بكل ما تحتاجه". فهو يسلّمك الهندسة الصعبة فعلًا — الإتاحة، وإدارة التركيز، وتحديد المواضع المدرك للتصادم، ودورة حياة الحركة — ولا يسأل شيئًا عن الشكل الذي يجب أن يبدو عليه منتجك. تعلّمت البنية القائمة على الأجزاء، ومخرج الطوارئ عبر خاصية العرض (render prop)، ونموذج التنسيق بسمات البيانات، ثم استخدمتها لبناء Dialog وSelect وAccordion وTooltip متاحة افتراضيًا ومنسّقة بالكامل على يدك.
تلك المقايضة هي كل الجاذبية. تتوقّف عن مقاومة سمة (theme) ذات رأي وتبدأ في تركيب عناصر أولية تملكها، وهو بالضبط ما يحتاجه نظام تصميم طويل الأمد.