Theming and styling channels
How a consumer changes what a primitive looks like without forking it: token overrides, className, and xstyle
@nyte-ai/ui divides styling responsibility three ways, and each primitive exposes exactly the
channels that division needs.
| Layer | Owns | Mechanism |
|---|---|---|
| Base UI | Interaction, focus, ARIA, data attributes | Headless components |
@nyte-ai/ui | Reusable component styling | StyleX stylex.create reading tokens |
| The app | Layout, typography, product identity, composites | Token overrides, className, xstyle, own components |
Token overrides
Override on an ancestor. The app root is the right place; a preview container is also fine, and that is how the examples on this site are themed independently of the docs chrome.
.my-app {
color-scheme: dark;
--nyte-color-avatar-orange-background: #5f2a06;
--nyte-color-avatar-orange-foreground: #ffb27d;
}Override only the tokens that carry identity. Overriding --nyte-control-height-md to fix one
layout is a layout problem; solve it with className.
The three props every styled primitive accepts
Every component in packages/ui/src/components/ui accepts these and merges them in this order.
| Prop | Type | Purpose |
|---|---|---|
className | string | Consumer layout, including Tailwind utilities. Appended after the StyleX class. |
style | React.CSSProperties | Inline overrides. Spread after StyleX's inline style, so it wins. |
xstyle | XStyle | A deliberate StyleX override. Passed as the last argument to stylex.props, so it wins by StyleX's merge rules. |
XStyle is stylex.StyleXArray of compiled styles, booleans, and null; you can pass one style,
an array, or a conditional. The merge itself is mergeStyleProps in packages/ui/src/style.ts:
export function mergeStyleProps(base, className, style) {
return {
className: [base.className, className].filter(Boolean).join(" ") || undefined,
style: { ...base.style, ...style },
};
}StyledProps<P> is the helper type that strips className/style from a Base UI props type and
adds the three channels back. Use it when wrapping a new part.
unstyled
Button, Input, and Textarea accept unstyled. When true the component renders the Base UI
part with no StyleX class at all, keeping only the interaction layer and the data-slot
attribute. On Button, unstyled: true also forbids variant and size at the type level.
unstyled is for product surfaces that supply a complete xstyle treatment of their own. It is
not for "I need a slightly different button"; that is a token override or xstyle.
Data attributes
Every primitive sets data-slot="<part>" so a consumer can target a part from CSS without knowing
the StyleX class. Variant-bearing primitives also expose their appearance:
| Component | Attributes |
|---|---|
Button | data-slot="button", data-variant, data-size (omitted when unstyled) |
Avatar | data-slot="avatar", data-size, data-tone |
AvatarImage | data-slot="avatar-image" |
AvatarFallback | data-slot="avatar-fallback" |
Input | data-slot="input" |
Textarea | data-slot="textarea" |
DropdownMenuItem | data-slot="dropdown-menu-item", data-variant, data-inset |
DropdownMenuLabel | data-inset |
Base UI adds its own state attributes on top (data-disabled, data-popup-open,
data-highlighted, data-checked, …). Those are listed on each component page.
Headless subpaths
For anything without a styled wrapper, import the Base UI namespace from its subpath and style it in the app:
import { Popover } from "@nyte-ai/ui/popover";Import from the subpath, not from @base-ui/react, so the Base UI version stays behind the shared
package. See Headless.
Dark mode
Tokens use light-dark(), so a primitive follows whichever color-scheme is in effect. There is
no .dark class in the primitives. @nyte-ai/ui/styles.css does declare
@custom-variant dark (&:is(.dark *)) for Tailwind consumers that want class-based variants in
their own utilities; it does not affect the primitives.