Introduction
Every React team eventually hits the same wall. A pre-styled component kit looks great on day one, then fights you on day thirty when the design system demands something the library never anticipated. You override styles with increasingly desperate selectors, ship a wrapper, and quietly resent the abstraction.
Base UI takes the opposite bet. Built by the teams behind Radix UI, Floating UI, and Material UI, it ships unstyled, fully accessible primitives and gets out of your way. You get the hard parts for free — keyboard navigation, focus management, ARIA wiring, collision-aware positioning — and you own every pixel of the styling. There is no theme to fight, no CSS to override, just clean HTML elements you decorate however you like.
In this tutorial you will integrate Base UI into a Next.js App Router project and build four real components: a Dialog, a Select, an Accordion, and a Tooltip. Along the way you will learn the part-based anatomy that makes Base UI composable, and the data-attribute styling model that makes it feel native.
Prerequisites
Before starting, make sure you have:
- Node.js 20 or newer
- A Next.js 15 project using the App Router (or willingness to create one)
- Comfort with React function components and hooks
- Basic CSS knowledge — we will use plain CSS, but the patterns apply to Tailwind or CSS Modules
- About 25 minutes of focused time
What You Will Build
A small "Settings" surface that demonstrates the four most-requested interactive primitives:
- A Dialog for a confirmation modal with focus trapping and scroll locking
- A Select menu for choosing a theme, with keyboard typeahead
- An Accordion for a collapsible FAQ section
- A Tooltip that positions itself away from screen edges automatically
Every component is unstyled by default, so you will see exactly how Base UI separates behavior from appearance.
Step 1: Create the Project and Install Base UI
If you do not already have a Next.js app, scaffold one:
npx create-next-app@latest base-ui-demo --typescript --app --no-src-dir
cd base-ui-demoThen install Base UI. It is a single package that exposes every component through deep imports:
npm install @base-ui/reactThat is the entire setup. There is no provider to wrap your app in, no theme object to configure, and no CSS file you are required to import. Base UI components are tree-shakeable: importing the Dialog pulls in only the Dialog code.
Step 2: Understand the Part-Based Anatomy
The single most important concept in Base UI is that components are collections of parts, not monolithic black boxes. Instead of one <Dialog> that takes twenty props, you compose smaller named parts that each render one real DOM element.
Here is the anatomy of an Accordion straight from the library:
import { Accordion } from '@base-ui/react/accordion';
<Accordion.Root>
<Accordion.Item>
<Accordion.Header>
<Accordion.Trigger />
</Accordion.Header>
<Accordion.Panel />
</Accordion.Item>
</Accordion.Root>Each part is a real element you can style, ref, and extend. Accordion.Root owns the state, Accordion.Trigger is the clickable button, and Accordion.Panel is the collapsible region. Because parts are explicit, there is never a mystery about which element a class lands on. This is the same mental model across Dialog, Select, Tooltip, and the rest — learn it once and every component feels familiar.
A second pillar is the render prop. Every part accepts a render prop that lets you swap the underlying element or compose it with another component without losing behavior. This is how you wire Base UI into a routing library:
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} />;
}Step 3: Build a Dialog
Dialogs are where accessibility usually goes wrong — focus escapes, the background stays scrollable, screen readers announce nothing. Base UI handles all of that. Create 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>
);
}Note the 'use client' directive. Interactive Base UI components rely on state and effects, so they must run in Client Components. In the App Router you can still render them inside Server Components — the boundary is drawn at the file that imports Base UI.
Dialog.Portal renders the popup at the end of the document body so it escapes any overflow: hidden ancestor. Dialog.Backdrop is the dimmed overlay, and Dialog.Popup is the focus-trapped container. When the dialog opens, focus moves inside automatically; when it closes, focus returns to the trigger.
Step 4: Style with Data Attributes
Base UI exposes state through data attributes on each part, so you style states with plain CSS selectors rather than conditional class logic. A popup carries data-open when visible and data-closed when hidden. Create 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; }The data-starting-style and data-ending-style attributes are the elegant part. Base UI applies data-starting-style for one frame when an element mounts and data-ending-style while it animates out — letting you build enter and exit transitions with pure CSS, no animation library, and no risk of the element unmounting before the exit finishes.
Step 5: Build a Select
Native <select> elements cannot be styled meaningfully and break on touch devices. Base UI's Select gives you a fully custom, accessible listbox with typeahead and keyboard support. Create 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 is powered by Floating UI under the hood. The sideOffset prop adds a gap between the trigger and the popup, and the positioner automatically flips the menu above the trigger when there is no room below. Select.Value shows the current selection, and Select.ItemIndicator renders only inside the selected item — perfect for a checkmark.
Style the trigger states with the data attributes the docs expose, such as data-popup-open when the menu is open and data-disabled when the control is 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; }The data-highlighted attribute is set on whichever item the keyboard or pointer is currently focusing, so a single CSS rule covers both arrow-key navigation and hover.
Step 6: Build an Accordion
The Accordion ties together everything you have learned about parts and data attributes. Create 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 measures the panel and exposes its height through a CSS variable named --accordion-panel-height, so you can animate the open and close transition smoothly even though the content height is dynamic:
.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;
}When a panel is open, its trigger gains data-panel-open, which we use to rotate the chevron. No state, no useEffect, no measuring code — the library tracks it for you.
Step 7: Build a Tooltip
Finally, a Tooltip that respects screen edges. Wrap your app section in a Tooltip.Provider so multiple tooltips share sensible open and close delays:
'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 reads its position from the positioner and aligns itself to the trigger automatically, even after a collision flip. The tooltip opens on hover and on keyboard focus, so it is accessible to keyboard users without any extra work.
Step 8: Assemble the Page
Drop everything into a route. Because the leaf components carry 'use client', your page can stay a 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>
);
}Testing Your Implementation
Run the dev server and put the components through their paces:
npm run devVerify the accessibility behavior, which is the whole point of Base UI:
- Open the dialog, then press Tab repeatedly — focus stays trapped inside the popup
- Press Escape — the dialog closes and focus returns to the trigger
- Open the select with the keyboard and type the first letter of an option — typeahead jumps to it
- Tab to the accordion triggers and press Enter or Space to toggle panels
- Tab to the Save button — the tooltip appears on focus, not just hover
Troubleshooting
"Cannot read properties of null" or hydration errors. You forgot 'use client' on a file that uses a Base UI component. Interactive parts need the client runtime.
The popup is clipped inside a scrolling container. Make sure you are rendering through the component's Portal part. Without the portal, the popup is constrained by ancestor overflow rules.
Animations do not run on exit. Confirm you are styling the data-ending-style attribute and that your transition is declared on the base selector, not only inside the starting-style rule.
Styles are not applying. Double-check the data attribute names against the component's docs. Each part exposes a different set, for example data-popup-open on a Select trigger versus data-panel-open on an Accordion trigger.
Next Steps
- Replace the plain CSS with Tailwind utilities or CSS Modules — the data-attribute model works with
data-[open]:variants in Tailwind - Explore the other primitives: Menu, Popover, Tabs, Switch, Checkbox, and Autocomplete share the same anatomy
- Wrap each primitive in your own styled component to build an internal design system you fully control
- Pair Base UI behavior with the styling discipline from our shadcn/ui component library guide
Conclusion
Base UI is a different deal than a batteries-included component kit. It hands you the genuinely hard engineering — accessibility, focus management, collision-aware positioning, animation lifecycle — and asks nothing about how your product should look. You learned the part-based anatomy, the render-prop escape hatch, and the data-attribute styling model, then used them to build a Dialog, Select, Accordion, and Tooltip that are accessible by default and styled entirely by you.
That trade is the whole appeal. You stop fighting an opinionated theme and start composing primitives you own, which is exactly what a long-lived design system needs.