Proper UI

Component API conventions

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

RootShapeSlots
Selectroot + slotsComboBox, Item
MultiSelectroot + slotsItem, Footer, EmptyState
TagSelectroot + slotsItem
PinInputroot + slotsSlot, Label, Group, Separator, Description
TextEditorroot + slotsToolbar, SelectionToolbar, Group, Separator, Content, Hint, Bold, Italic, Underline, TextColor, AlignLeft, AlignCenter, AlignRight, BulletList, Link, Image, Generate, FontFamily, FontSize
Tableroot + slotsBody, Cell, Head, Header, Row
SlideoutMenuroot + slotsTrigger, Content, Header, Footer
PageHeaderroot + slotsBanner, Content, Heading, Title, Description, Avatar, Actions, Footer
FilterBarroot + slotsRoot, Content, Actions, FilterRow, FilterIconButton, FilterButton, FilterDropdown
EmptyStateroot + slotsTitle, Header, Footer, Content, Description, Illustration, FeaturedIcon, FileTypeIcon, AvatarRadius, AvatarRow, AvatarGrid
DescriptionListroot + slotsItem, Term, Details
CommandMenuroot + slotsTrigger, Popover, Search, List, Group, Item, Shortcut, Empty, Footer
TreeViewroot + slotsItem, ItemContent
ProgressStepsroot + slotsMinimal
MessageListroot + slotsDivider
Messageroot + slotsBubble, Quote, File, Audio, Image, LinkPreview, LinkCard, Reactions, Reaction, Typing
Breadcrumbsroot + slotsItem, Collapsed, Account, AccountMenu
ActivityFeedroot + slotsItem, Link, File, Labels, Message, Quote
AIConversationroot + slotsContent, ScrollButton
AIMessageroot + slotsActions, Action
Dropdownnamespace onlyRoot, Popover, Menu, Section, SectionHeader, Item, Separator, DotsButton
Carouselnamespace onlyRoot, Content, Item, PrevTrigger, NextTrigger, IndicatorGroup, Indicator
ColorPickernamespace onlyProvider, Panel, Area, HueSlider, AlphaSlider, EyeDropper, ColorFormatSelect, ColorValueInput, Swatches, SavedColors, Palette, Preview
GradientPickernamespace onlyProvider, Area, Slider, TypeSelect, Reverse, StopList, SavedGradients
Kanbannamespace onlyBoard, Column, Card
Paginationnamespace onlyRoot, PrevTrigger, NextTrigger, Item, Ellipsis, Context
FileUploadnamespace onlyRoot, List, DropZone, ListItemProgressBar, ListItemProgressFill
ImagePickernamespace onlyProvider, DropZone, FillModeSelect, RotateButton, AdjustmentSliders
Tabsflat siblingsTabList, 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 href and switches its own rendered element. Button/Link is the fullest example (href discriminates the ButtonProps/LinkProps overload, rendering AriaLink instead of AriaButton); ActivityFeed.Item and Breadcrumbs.Item do the same in miniature — href present renders AriaLink, absent renders a plain element.
  • Compose with Button itself for an inline link-styled action, rather than reinventing link styling: ActivityFeedLink is <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.