Styling
How className and render interact when you override a component's styles.
Every component merges the classes you give it with its own using tailwind-merge, so a conflicting utility replaces the component's rather than sitting alongside it and leaving the winner to stylesheet order.
Overriding with className
<Badge className="rounded-full" />rounded-full replaces the component's rounded-sm. Non-conflicting classes
are kept from both sides, so you only have to name what you're changing.
Overriding through render
render swaps the underlying element or composes the component with another
one. A class you put on the render element behaves exactly like one passed via
className — it wins:
<Badge render={<a className="rounded-full" />} />These two spellings are interchangeable for styling purposes. Pick whichever
reads better; render when you also need to change the element, className
when you don't.
A
rendergiven as a function can't be inspected, so its classes are concatenated rather than merged. If you need an override to win, put it on the component'sclassNameinstead.
Composing two components
When the render element is itself a design-system component, it merges what it is handed, so the outer component's classes reach it as ordinary input:
<InputGroup.Input render={<Input.Text />} />Here InputGroup.Input contributes layout and Input.Text contributes the
control's own styling. Container components own their frame and reset it on
whatever they wrap — see Input Group — so you
size the group rather than the control inside it.
Overriding size on a component that changes size
A spacing override applies to every state of a component, including the ones you weren't looking at when you wrote it. That is easy to miss, because the state you were solving for is the one in front of you.
The question to ask isn't did my override change what I was looking at — it's did it start applying somewhere I never looked.
// Written so a two-line value doesn't tower. Also applies to the one-line
// placeholder state, which now measures 33px next to 36px siblings.
<Select.Trigger className="h-auto py-1.5" />h-auto removes the fixed frame, and py-1.5 is smaller than the padding it
replaced, so the collapsed state shrinks. Nothing looks wrong in isolation —
you notice it because the control no longer lines up with the input beside it.
Pair the override with a min-* companion, so the frame holds when the content
is small and still grows when it isn't:
<Select.Trigger className="h-auto min-h-9 py-1.5" />The same shape applies to width (min-w-0 on a flex child that must be allowed
to shrink but not collapse). InputGroup does this in-package with
has-[>textarea]:h-auto: the group keeps its control height until it actually
wraps a textarea, rather than dropping the frame for every case.
This is checked, not just documented. The visual harness renders each size-varying component's collapsed and expanded states in a row with reference siblings and asserts they share a collapsed height — because a control that is 33px tall is only obviously wrong next to one that is 36px.
What a component can tell about your render
Styling isn't the only thing inferred across this seam. Tabs.Tab reads the
render element to decide whether it's handing Base UI a real <button>, which
determines the keyboard and ARIA semantics it applies — so a router-link tab
gets anchor semantics without you asking:
<Tabs.Tab value="homes" render={<Link to="/homes" />}>
Homes
</Tabs.Tab>Both inferences read the same thing and stop at the same wall, so it's worth knowing what is and isn't visible to a component:
The last two rows are why an override that depends on the element has to be stated rather than guessed. For styling that means classes concatenate instead of merging.
nativeButton is about semantics the element doesn't already have
Button and Popover.Trigger infer nativeButton from what you hand them.
The prop reads backwards easily: it does not mean "is this literally a
<button> tag". It means should Base UI synthesize button semantics onto this
element — role="button", focusability, Space to activate.
Those come apart exactly where the element has semantics of its own:
The anchor row is the one that bites. Passing nativeButton={false} alongside
a link looks like the careful thing to do and is actively worse than leaving it
off: a control that navigates stops being announced as a navigation.
If you're rendering something inert that really should act like a button, pass
nativeButton={false} explicitly. It always wins over the inference.
Tabs.Tabkeeps its own rule, because a tab is announced asrole="tab"whatever it renders — the link-vs-button question never reaches the user there, andnativeButtononly decides keyboard handling.
Where the item inset lives
Every item-list popup insets its items by the same 4px (p-component-xs), and
it always lives on the innermost element the component renders no matter what
you compose into it — which is also the element that scrolls:
- Menu, Context Menu — the popup itself. Items go directly inside it, and it is its own scroll container.
- Select, Command — the list inside the popup, because the popup has siblings that must reach its edges (Select's scroll arrows, Command's input).
Two things follow. Items never need a group or any other wrapper to be spaced
correctly — grouping expresses grouping, not layout. And separators bleed to
the popup's edges on their own (-mx-component-xs cancels the inset), so you
never compensate for the inset when composing items and separators.
Popups with non-item content
A popup that mixes items with something else — a calendar, a header, a form —
opts out with p-0 and re-applies the inset per section:
<Menu.Content className="p-0">
<div className="flex">
<div className="p-component-xs">{/* menu items */}</div>
<div className="p-component-xs">{/* anything else */}</div>
</div>
</Menu.Content>