How Proper UI decides whether a component group is one component or several, the three shapes a compound API takes in this codebase, and the className/children/href idioms that repeat across it.
How Proper UI decides whether a component group is one component or several, how the parts of a
compound group are attached to each other, and the small set of idioms (className, children,
href, icon slots) that repeat across almost every file in packages/ui/src/components. This is
a survey of what the library already does, not a proposal — every claim below was checked against
the source, and the table is exhaustive as of this page.
Flat props component, or compound?
Most groups are a single component with props: Button, Badge, Checkbox, Toggle, Avatar,
Input, TextArea, Slider. One call, one element (or a small fixed internal structure the props
control), nothing the caller assembles by hand. Reach for this shape whenever the group has no
independently-composable pieces — there's no scenario where a consumer needs Badge's dot but not
its label, so Badge stays one component with a type/color/size prop set.
A group becomes compound — Table.Header, Tabs.Panel, PageHeader.Actions — when the pieces
are independently composable: a caller picks which parts appear, in what order, and what goes
inside them. Table is compound because a consumer chooses which columns exist and what each cell
renders; Badge isn't, because there's nothing to choose beyond its own props.
The three compound shapes
1 & 2 — root component with slots attached
The parent identifier is itself a renderable component and carries its parts as static properties. Two different-looking but functionally identical ways to write that:
// Object.assign — Breadcrumbs, ActivityFeed, MessageList, Message, AIConversation, AIMessage,
// TreeView, ProgressSteps, TextEditor
export const Breadcrumbs = Object.assign(BreadcrumbsRoot, {
Item: BreadcrumbsItem,
Collapsed: BreadcrumbsCollapsed,
Account: BreadcrumbsAccount,
AccountMenu: BreadcrumbsAccountMenu,
});
// as typeof X & { … } — Select, MultiSelect, TagSelect, PinInput, Table, SlideoutMenu,
// PageHeader, FilterBar, EmptyState, DescriptionList, CommandMenu
const _Select = Select as typeof Select & {
ComboBox: typeof ComboBox;
Item: typeof SelectItem;
};
_Select.ComboBox = ComboBox;
_Select.Item = SelectItem;
export { _Select as Select };
There is no behavioural difference between the two for a consumer — both produce X callable on
its own and X.Part callable. Don't read anything into which one a given file uses; it's a
stylistic accident of when the file was written, not a signal. Match whichever shape a file you're
extending already uses; reach for Object.assign in a brand-new file.
3 — plain namespace object, root not directly renderable
export const Carousel = {
Root: CarouselRoot,
Content: CarouselContent,
Item: CarouselItem,
PrevTrigger: CarouselPrevTrigger,
NextTrigger: CarouselNextTrigger,
IndicatorGroup: CarouselIndicatorGroup,
Indicator: CarouselIndicator,
};
Carousel itself is not a component — <Carousel /> renders nothing; every real usage writes
Carousel.Root, Carousel.Content, and so on. Used for the heavier, usually stateful "widget"
groups with no single obvious element to render bare: Dropdown, Carousel, ColorPicker,
GradientPicker, Kanban, Pagination, FileUpload, ImagePicker.
A fourth, narrower case: flat sibling exports
When the underlying React Aria component is already multi-part with its own context wiring
(Tabs/TabList/Tab/TabPanel), Proper UI re-exports each part as its own flat, top-level
named export instead of inventing a dot-notation on top of Aria's own composition model. There's
no Tabs.Panel — you compose exactly the way react-aria-components itself expects, through
Proper UI's styled wrappers.
Every compound component in the repo
| Root | Shape | Slots |
|---|---|---|
Select | root + slots | ComboBox, Item |
MultiSelect | root + slots | Item, Footer, EmptyState |
TagSelect | root + slots | Item |
PinInput | root + slots | Slot, Label, Group, Separator, Description |
TextEditor | root + slots | Toolbar, SelectionToolbar, Group, Separator, Content, Hint, Bold, Italic, Underline, TextColor, AlignLeft, AlignCenter, AlignRight, BulletList, Link, Image, Generate, FontFamily, FontSize |
Table | root + slots | Body, Cell, Head, Header, Row |
SlideoutMenu | root + slots | Trigger, Content, Header, Footer |
PageHeader | root + slots | Banner, Content, Heading, Title, Description, Avatar, Actions, Footer |
FilterBar | root + slots | Root, Content, Actions, FilterRow, FilterIconButton, FilterButton, FilterDropdown |
EmptyState | root + slots | Title, Header, Footer, Content, Description, Illustration, FeaturedIcon, FileTypeIcon, AvatarRadius, AvatarRow, AvatarGrid |
DescriptionList | root + slots | Item, Term, Details |
CommandMenu | root + slots | Trigger, Popover, Search, List, Group, Item, Shortcut, Empty, Footer |
TreeView | root + slots | Item, ItemContent |
ProgressSteps | root + slots | Minimal |
MessageList | root + slots | Divider |
Message | root + slots | Bubble, Quote, File, Audio, Image, LinkPreview, LinkCard, Reactions, Reaction, Typing |
Breadcrumbs | root + slots | Item, Collapsed, Account, AccountMenu |
ActivityFeed | root + slots | Item, Link, File, Labels, Message, Quote |
AIConversation | root + slots | Content, ScrollButton |
AIMessage | root + slots | Actions, Action |
Dropdown | namespace only | Root, Popover, Menu, Section, SectionHeader, Item, Separator, DotsButton |
Carousel | namespace only | Root, Content, Item, PrevTrigger, NextTrigger, IndicatorGroup, Indicator |
ColorPicker | namespace only | Provider, Panel, Area, HueSlider, AlphaSlider, EyeDropper, ColorFormatSelect, ColorValueInput, Swatches, SavedColors, Palette, Preview |
GradientPicker | namespace only | Provider, Area, Slider, TypeSelect, Reverse, StopList, SavedGradients |
Kanban | namespace only | Board, Column, Card |
Pagination | namespace only | Root, PrevTrigger, NextTrigger, Item, Ellipsis, Context |
FileUpload | namespace only | Root, List, DropZone, ListItemProgressBar, ListItemProgressFill |
ImagePicker | namespace only | Provider, DropZone, FillModeSelect, RotateButton, AdjustmentSliders |
Tabs | flat siblings | TabList, Tab, TabPanel (separate top-level exports, not Tabs.*) |
Files for each root live at packages/ui/src/components/<layer>/<group>/<file>.tsx; the full
version of this table with file paths is in
docs/component-api.md.
If you're adding a new compound group: reach for Object.assign unless you're extending an
existing file that already uses as typeof X & { … }, in which case match that file's style —
the two are interchangeable for consumers. Use the plain-namespace shape only when the group
genuinely has no sensible bare-root render, the way a color picker or a kanban board doesn't.
Don't invent a dot-notation on top of a React Aria primitive that is already itself multi-part;
re-export its parts flat instead, the way Tabs does.
Icon and content slots
A prop typed to accept either a component reference or an element —
iconLeading?: ComponentType<{ className?: string }> | ReactNode on Button, icon?: FC | ReactNode
on Select — is Proper UI's icon-slot idiom: pass the bare component (<Button iconLeading={ArrowRight}>)
so the wrapper can size it and set data-icon, except from a true server component (no "use client"),
which can't hand a component reference across the boundary and must pass an element carrying
data-icon itself instead (<ArrowRight data-icon="leading" />). See
AGENTS.md for the full rule.
className merging
Every component merges an incoming className onto its own recipe with cx() — the caller's
classes always come last, so they win conflicts with the component's own Tailwind classes:
className={cx(styles.common.root, styles.sizes[size].root, className)}
Some components go further and accept the same function-of-render-state shape React Aria's own
className prop accepts, when their prop type is inherited straight from an Aria*Props type that
already allows it (Toggle, Checkbox, TextField, Select's root wrapper):
className={(state) =>
cx("relative flex w-max items-start", state.isDisabled && "cursor-not-allowed", styles[size].root,
typeof className === "function" ? className(state) : className)}
Button is the counter-example: its className is a plain string, not a function, because
ButtonProps/LinkProps omit className from the underlying Aria*Props entirely rather than
inheriting it. Check the prop's declared type rather than assuming every component supports a
function className.
children as a React Aria render prop
Several components consume React Aria's children-as-a-function convention internally to read
interaction state, then translate it into class toggles — not something a consumer of Checkbox or
Select ever has to reach for; it happens inside the component:
<AriaCheckbox {...ariaCheckboxProps} className={...}>
{({ isSelected, isIndeterminate, isDisabled, isFocusVisible }) => (
<CheckboxBase size={size} isSelected={isSelected} isIndeterminate={isIndeterminate} ... />
)}
</AriaCheckbox>
Select's root wrapper does the same with {(state) => (...)} to read state.isRequired /
state.isInvalid before rendering its Label and HintText. Building on top of an Aria*
primitive that exposes state this way, prefer reading it through this render-prop children over
re-deriving isHovered/isFocusVisible/etc. with your own event handlers.
Composing with href
Two related idioms, both meant to avoid the consumer wrapping their own <a>:
- A component takes an optional
hrefand switches its own rendered element.Button/Linkis the fullest example (hrefdiscriminates theButtonProps/LinkPropsoverload, renderingAriaLinkinstead ofAriaButton);ActivityFeed.ItemandBreadcrumbs.Itemdo the same in miniature —hrefpresent rendersAriaLink, absent renders a plain element. - Compose with
Buttonitself for an inline link-styled action, rather than reinventing link styling:ActivityFeedLinkis<Button href={href} size="sm" color="link-color" className="inline …">, not its own anchor markup.
Reach for the first when the whole element the prop belongs to should become a link; reach for the second when only a nested piece of text should.