Introduction
Toute équipe React finit par se heurter au même mur. Une trousse de composants déjà stylés a fière allure le premier jour, puis se met à vous résister le trentième jour, quand le design system réclame quelque chose que la bibliothèque n'avait jamais anticipé. Vous écrasez les styles avec des sélecteurs de plus en plus désespérés, vous livrez un wrapper et vous finissez par détester discrètement l'abstraction.
Base UI fait le pari inverse. Conçue par les équipes derrière Radix UI, Floating UI et Material UI, elle livre des primitives sans style, entièrement accessibles, et vous laisse travailler en paix. Vous récupérez gratuitement les parties difficiles — navigation au clavier, gestion du focus, câblage ARIA, positionnement conscient des collisions — et vous maîtrisez chaque pixel du style. Il n'y a aucun thème à combattre, aucun CSS à écraser, juste de propres éléments HTML que vous décorez comme bon vous semble.
Dans ce tutoriel, vous intégrerez Base UI dans un projet Next.js basé sur l'App Router et construirez quatre composants réels : un Dialog, un Select, un Accordion et un Tooltip. En chemin, vous apprendrez l'anatomie par parties qui rend Base UI composable, ainsi que le modèle de style par attributs de données qui lui donne un rendu natif.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20 ou plus récent
- Un projet Next.js 15 utilisant l'App Router (ou la volonté d'en créer un)
- De l'aisance avec les composants fonction et les hooks de React
- Des connaissances CSS de base — nous utiliserons du CSS pur, mais les patterns s'appliquent à Tailwind ou aux CSS Modules
- Environ 25 minutes de temps concentré
Ce que vous allez construire
Une petite surface « Paramètres » qui met en scène les quatre primitives interactives les plus demandées :
- Un Dialog pour une modale de confirmation, avec piégeage du focus et verrouillage du défilement
- Un menu Select pour choisir un thème, avec recherche au clavier
- Un Accordion pour une section FAQ repliable
- Un Tooltip qui se positionne automatiquement à l'écart des bords de l'écran
Chaque composant est sans style par défaut, vous verrez donc précisément comment Base UI sépare le comportement de l'apparence.
Étape 1 : créer le projet et installer Base UI
Si vous n'avez pas déjà une application Next.js, générez-en une :
npx create-next-app@latest base-ui-demo --typescript --app --no-src-dir
cd base-ui-demoInstallez ensuite Base UI. C'est un paquet unique qui expose chaque composant via des imports profonds :
npm install @base-ui/reactVoilà toute la configuration. Il n'y a aucun provider dans lequel envelopper votre application, aucun objet de thème à configurer, et aucun fichier CSS que vous seriez obligé d'importer. Les composants Base UI sont compatibles avec le tree-shaking : importer le Dialog ne tire que le code du Dialog.
Étape 2 : comprendre l'anatomie par parties
Le concept le plus important de Base UI est que les composants sont des collections de parties, et non des boîtes noires monolithiques. Au lieu d'un seul <Dialog> qui accepte vingt props, vous composez de plus petites parties nommées qui rendent chacune un véritable élément du DOM.
Voici l'anatomie d'un Accordion telle qu'elle vient de la bibliothèque :
import { Accordion } from '@base-ui/react/accordion';
<Accordion.Root>
<Accordion.Item>
<Accordion.Header>
<Accordion.Trigger />
</Accordion.Header>
<Accordion.Panel />
</Accordion.Item>
</Accordion.Root>Chaque partie est un véritable élément que vous pouvez styler, référencer et étendre. Accordion.Root détient l'état, Accordion.Trigger est le bouton cliquable, et Accordion.Panel est la région repliable. Comme les parties sont explicites, on ne se demande jamais sur quel élément une classe atterrit. C'est le même modèle mental pour Dialog, Select, Tooltip et tous les autres — apprenez-le une fois et chaque composant vous semblera familier.
Un second pilier est la render prop. Chaque partie accepte une prop render qui vous permet de remplacer l'élément sous-jacent ou de le composer avec un autre composant sans perdre le comportement. C'est ainsi que vous branchez Base UI sur une bibliothèque de routage :
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} />;
}Étape 3 : construire un Dialog
Les dialogues sont l'endroit où l'accessibilité dérape habituellement — le focus s'échappe, l'arrière-plan reste défilable, les lecteurs d'écran n'annoncent rien. Base UI gère tout cela. Créez 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>
);
}Remarquez la directive 'use client'. Les composants Base UI interactifs s'appuient sur de l'état et des effets, ils doivent donc s'exécuter dans des Client Components. Dans l'App Router, vous pouvez tout de même les rendre à l'intérieur de Server Components — la frontière est tracée au niveau du fichier qui importe Base UI.
Dialog.Portal rend le popup à la fin du corps du document afin qu'il échappe à tout ancêtre overflow: hidden. Dialog.Backdrop est la superposition assombrie, et Dialog.Popup est le conteneur où le focus est piégé. À l'ouverture du dialogue, le focus se déplace automatiquement à l'intérieur ; à la fermeture, il revient sur le déclencheur.
Étape 4 : styler avec des attributs de données
Base UI expose l'état via des attributs de données sur chaque partie, ce qui vous permet de styler les états avec de simples sélecteurs CSS plutôt qu'avec une logique de classes conditionnelle. Un popup porte data-open lorsqu'il est visible et data-closed lorsqu'il est masqué. Créez 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; }Les attributs data-starting-style et data-ending-style sont la partie élégante. Base UI applique data-starting-style pendant une image lorsqu'un élément se monte, et data-ending-style pendant qu'il s'anime en sortie — ce qui vous permet de construire des transitions d'entrée et de sortie en pur CSS, sans bibliothèque d'animation, et sans risque que l'élément se démonte avant la fin de la sortie.
Étape 5 : construire un Select
Les éléments <select> natifs ne peuvent pas être stylés de manière significative et se comportent mal sur les appareils tactiles. Le Select de Base UI vous offre une listbox entièrement personnalisée et accessible, avec recherche au clavier et prise en charge du clavier. Créez 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 est propulsé par Floating UI en coulisses. La prop sideOffset ajoute un espace entre le déclencheur et le popup, et le positionneur retourne automatiquement le menu au-dessus du déclencheur lorsqu'il n'y a pas de place en dessous. Select.Value affiche la sélection courante, et Select.ItemIndicator ne se rend qu'à l'intérieur de l'élément sélectionné — parfait pour une coche.
Stylez les états du déclencheur avec les attributs de données exposés par la documentation, comme data-popup-open lorsque le menu est ouvert et data-disabled lorsque le contrôle est désactivé :
.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; }L'attribut data-highlighted est positionné sur l'élément que le clavier ou le pointeur est en train de cibler, de sorte qu'une seule règle CSS couvre à la fois la navigation par flèches et le survol.
Étape 6 : construire un Accordion
L'Accordion réunit tout ce que vous avez appris sur les parties et les attributs de données. Créez 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 mesure le panneau et expose sa hauteur via une variable CSS nommée --accordion-panel-height, ce qui vous permet d'animer en douceur la transition d'ouverture et de fermeture même lorsque la hauteur du contenu est dynamique :
.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;
}Lorsqu'un panneau est ouvert, son déclencheur reçoit data-panel-open, que nous utilisons pour faire pivoter le chevron. Aucun état, aucun useEffect, aucun code de mesure — la bibliothèque le suit pour vous.
Étape 7 : construire un Tooltip
Enfin, un Tooltip qui respecte les bords de l'écran. Enveloppez la section de votre application dans un Tooltip.Provider afin que plusieurs tooltips partagent des délais d'ouverture et de fermeture cohérents :
'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 lit sa position depuis le positionneur et s'aligne automatiquement sur le déclencheur, même après un retournement dû à une collision. Le tooltip s'ouvre au survol et lorsque le focus arrive au clavier, il est donc accessible aux utilisateurs du clavier sans le moindre effort supplémentaire.
Étape 8 : assembler la page
Déposez le tout dans une route. Comme les composants feuilles portent 'use client', votre page peut rester un 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>
);
}Tester votre implémentation
Lancez le serveur de développement et mettez les composants à l'épreuve :
npm run devVérifiez le comportement d'accessibilité, qui est tout l'intérêt de Base UI :
- Ouvrez le dialogue, puis appuyez plusieurs fois sur Tab — le focus reste piégé à l'intérieur du popup
- Appuyez sur Échap — le dialogue se ferme et le focus revient sur le déclencheur
- Ouvrez le select au clavier et tapez la première lettre d'une option — la recherche au clavier saute jusqu'à elle
- Tabulez vers les déclencheurs de l'accordéon et appuyez sur Entrée ou Espace pour basculer les panneaux
- Tabulez vers le bouton Save — le tooltip apparaît au focus, et pas seulement au survol
Dépannage
« Cannot read properties of null » ou erreurs d'hydratation. Vous avez oublié 'use client' sur un fichier qui utilise un composant Base UI. Les parties interactives ont besoin du runtime client.
Le popup est rogné à l'intérieur d'un conteneur défilable. Assurez-vous d'effectuer le rendu via la partie Portal du composant. Sans le portail, le popup est contraint par les règles overflow des ancêtres.
Les animations ne se déclenchent pas en sortie. Confirmez que vous stylez l'attribut data-ending-style et que votre transition est déclarée sur le sélecteur de base, et pas uniquement à l'intérieur de la règle starting-style.
Les styles ne s'appliquent pas. Vérifiez à deux fois les noms des attributs de données par rapport à la documentation du composant. Chaque partie en expose un ensemble différent, par exemple data-popup-open sur un déclencheur de Select contre data-panel-open sur un déclencheur d'Accordion.
Étapes suivantes
- Remplacez le CSS pur par des utilitaires Tailwind ou des CSS Modules — le modèle par attributs de données fonctionne avec les variantes
data-[open]:de Tailwind - Explorez les autres primitives : Menu, Popover, Tabs, Switch, Checkbox et Autocomplete partagent la même anatomie
- Enveloppez chaque primitive dans votre propre composant stylé pour construire un design system interne que vous maîtrisez totalement
- Associez le comportement de Base UI à la discipline de style de notre guide de la bibliothèque de composants shadcn/ui
Conclusion
Base UI propose un marché différent de celui d'une trousse de composants tout-en-un. Elle vous remet l'ingénierie véritablement difficile — accessibilité, gestion du focus, positionnement conscient des collisions, cycle de vie des animations — et ne demande rien sur l'apparence que devrait avoir votre produit. Vous avez appris l'anatomie par parties, l'échappatoire de la render prop et le modèle de style par attributs de données, puis vous les avez utilisés pour construire un Dialog, un Select, un Accordion et un Tooltip accessibles par défaut et stylés entièrement par vous.
C'est tout l'attrait de ce compromis. Vous cessez de combattre un thème dogmatique et vous commencez à composer des primitives que vous maîtrisez, ce qui est exactement ce dont un design system de longue durée a besoin.