Chapter 14
The component set beyond the core
This chapter covers the seventeen components that fill the gap between a working library and one people can build a product with.
Chapters 7 through 9 built the components that justify a library existing: the ones with real accessibility complexity, where a hand-rolled version would be subtly broken. That is roughly a third of what ships.
The rest is this chapter. Individually these components are unremarkable, and that is the point. Nobody adopts a library for its Divider. They abandon one for the absence of a Divider, because the first thing they build needs a horizontal rule and now they are writing CSS again, and once they are writing CSS they are making spacing decisions your token system was supposed to make for them.
Rather than seventeen implementations at chapter 7’s depth, this chapter covers the API of each and stops to explain the ones where the design decision is interesting.
Menu
Menu is the exception in this chapter: it has real complexity, and it is the one component in the library built on react-aria-components rather than the hooks.
<RudiMenuTrigger>
<RudiIconButton aria-label="Actions">
<RudiIcon icon="mdi:dots-vertical" />
</RudiIconButton>
<RudiMenu onAction={handleAction}>
<RudiMenuSection title="Edit">
<RudiMenuItem id="rename" icon="mdi:pencil" label="Rename" shortcut="⌘R" />
<RudiMenuItem id="duplicate" icon="mdi:content-copy" label="Duplicate" />
</RudiMenuSection>
<RudiMenuSeparator />
<RudiMenuItem id="delete" icon="mdi:delete" label="Delete" isDestructive />
</RudiMenu>
</RudiMenuTrigger>
Seven exports: RudiMenuTrigger, RudiMenu, RudiMenuItem, RudiMenuSection, RudiMenuSeparator, RudiSubmenuTrigger, and RudiKeyboard for rendering shortcut hints.
Each is a thin wrapper over the equivalent from react-aria-components, adding class names and, on RudiMenuItem, five convenience props:
export interface RudiMenuItemProps extends AriaMenuItemProps {
isDestructive?: boolean
icon?: string
label?: string
description?: ReactNode
shortcut?: ReactNode
}
Why hooks lost here
Every other interactive component in the library composes React Aria hooks, and that was the right call: assembling a Button from useButton teaches you what the hook handles, and the assembly is twenty lines.
A menu is not twenty lines. It needs a collection, roving focus, typeahead, submenu positioning with flip and shift behavior, hover intent so a diagonal mouse path toward a submenu does not close it, and a focus contract between trigger and popover. Hand-assembling that from useMenu, useMenuTrigger, useMenuItem, and useMenuSection is several hundred lines whose bugs surface only under keyboard use.
react-aria-components is the same team’s higher-level layer over the same hooks. Using it here costs consistency with the rest of the library and saves the part most likely to be wrong.
The rule worth taking from this: drop to hooks when you need control over structure, and use the component layer when you need behavior you would otherwise reimplement badly. Applying one blanket rule to every component is how you end up with either a library of thin wrappers or a broken menu.
Icon
Eight of the components in this chapter render an Icon, so it comes first.
export interface RudiIconProps {
icon: string
size?: 'sm' | 'md' | 'lg' | number
color?: string
label?: string
className?: string
}
A wrapper over @iconify/react that maps size onto the token scale and handles the accessibility branch. The icon prop is an Iconify name such as mdi:check, and icon data loads on demand rather than shipping a set.
The label prop controls semantics. Without it the icon renders aria-hidden, which is correct for the common case: an icon next to a text label is decorative, and announcing “checkmark icon, Complete” is noise. With a label, the icon becomes role="img" with an accessible name, for the rarer case where the icon carries meaning alone.
Defaulting to hidden is deliberate. The failure mode of a decorative icon accidentally announced is a slightly worse experience. The failure mode of a meaningful icon silently hidden is missing information, and requiring an explicit label makes that a decision rather than an oversight.
Text and content
Textarea mirrors Input from chapter 7 almost exactly. It adds two props:
rows?: number
resize?: 'none' | 'vertical' | 'horizontal' | 'both'
and drops Input’s type union, which has no meaning on a textarea. Everything else, including the label / description / errorMessage triple and the aria-describedby wiring, is identical. Build it by copying Input.
Link is a styled anchor:
export interface RudiLinkProps
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href: string
variant?: 'default' | 'subtle' | 'standalone'
underline?: 'always' | 'hover' | 'none'
external?: boolean
children: React.ReactNode
className?: string
}
Extending AnchorHTMLAttributes and spreading the rest onto the anchor is the pattern to copy for any component wrapping a single element. It means download, hreflang, and every data attribute work without you enumerating them.
external adds target="_blank", rel="noreferrer noopener", and an outbound-arrow icon. The rel pair is the security part: noopener stops the new page reaching back through window.opener, and noreferrer withholds the referrer.
underline="none" exists because designers ask for it. Use it for navigation and never for links inside body text, where the underline is the only non-color signal that something is a link.
Image:
export interface RudiImageProps {
src: string
alt: string
aspectRatio?: CSSProperties['aspectRatio']
fit?: 'cover' | 'contain'
radius?: 'none' | 'sm' | 'md' | 'lg' | 'full'
loading?: 'lazy' | 'eager'
overlay?: ReactNode
className?: string
}
alt is required, with no ?. It is the same argument as IconButton’s required aria-label in chapter 7: a decorative image passes alt="", which is explicit and takes one keystroke, while an optional prop makes forgetting invisible.
aspectRatio prevents layout shift by reserving space before the image loads.
Status and indicators
Avatar takes src, alt, name, size, and status. When src is absent or fails to load it falls back to initials derived from name, so a missing image degrades to something readable rather than a broken-image glyph.
Tag carries variant, dismissible, onDismiss, onPress, and isDisabled. It differs from Badge, which chapter 9 built, in exactly one way that matters: a Tag can be interactive. Badge is a label the user reads; Tag is a chip the user removes or selects. That is why Tag has press handling and Badge does not, and why they are separate components rather than one component with an interactive prop.
Dot is a status indicator with tone, size, pulse, label, and a color escape hatch that overrides tone for one-off cases. Unlabeled it is aria-hidden. Labeled it becomes role="status" with visually-hidden text, rather than Icon’s role="img" with an aria-label. The difference is that a status dot usually changes while the page is open, and role="status" announces the change; a labeled icon is static.
ProgressBar is the most interesting of the group:
export type RudiProgressBarVariant = 'default' | 'success' | 'warning' | 'error'
export interface RudiProgressBarThreshold {
/** Minimum percentage (0–100) at which this variant takes effect. */
at: number
variant: RudiProgressBarVariant
}
export interface RudiProgressBarProps {
value: number
minValue?: number
maxValue?: number
label: string
showValueLabel?: boolean
variant?: RudiProgressBarVariant
thresholds?: RudiProgressBarThreshold[]
size?: 'sm' | 'md'
className?: string
}
thresholds lets the bar change color as it fills:
<RudiProgressBar
label="Storage used"
value={usedGb}
maxValue={totalGb}
thresholds={[
{ at: 80, variant: 'warning' },
{ at: 95, variant: 'error' },
]}
/>
Without it, every consumer with a quota meter writes the same value > 80 ? 'warning' : 'default' expression, and each writes it slightly differently. Color should not be the only signal. label is required, and showValueLabel renders the number, but it is off by default, so turn it on whenever you use thresholds. That way the state is available to someone who cannot distinguish the colors.
CircularProgress is the same idea in a ring, and accepts children to render content in the center.
StatCard takes label, value, delta, and trend, with children as a slot for a sparkline. trend defaults to 'neutral' and drives the delta’s color through a rudi-stat-card__delta--{trend} modifier.
Color is the only thing it drives, which makes trend direction a color-only signal. The usual fix is an arrow glyph or a +/− prefix rendered from trend rather than left to whatever string the consumer passes as delta.
Rating takes value, max, onChange, showValue, count, and label. It is read-only without onChange and interactive with it. Note that read-only mode is the common case for a rating display, so the interactive branch is opt-in rather than the reverse.
Navigation
Breadcrumb:
export interface RudiBreadcrumbItem {
label: string
href?: string
}
export interface RudiBreadcrumbProps {
items: RudiBreadcrumbItem[]
separator?: string
label?: string
className?: string
}
The last item always renders as plain text with aria-current="page", even if you gave it an href. Any other item without an href renders as text with no aria-current. Dropping the final link is deliberate: a link to the page you are already on is a dead control, and aria-current is what communicates position.
The whole thing is a <nav> with an accessible name, so screen reader users can skip it.
Note that separator is an Iconify name rather than a character. Separators rendered as text get announced (“slash Home slash Products”), so the icon is aria-hidden and the list structure carries the relationship instead.
Pagination:
export interface RudiPaginationProps {
page: number
pageCount: number
onPageChange: (page: number) => void
siblingCount?: number
label?: string
className?: string
}
siblingCount controls how many page numbers appear either side of the current one before collapsing into an ellipsis. The component computes the window; the consumer supplies only the current page and the total.
NavItem takes label, icon, isActive, href, onPress, badge, and isDisabled. The rendered element branches on href alone: with one it is an <a>, without one it is a <button>. Passing both gives you a link that also fires onPress.
That branch matters. A navigation item that changes the URL should be a link, so it works with middle-click, keyboard modifiers, and “copy link address”. A button calling history.push looks identical and breaks all three.
Stepper:
export type RudiStepStatus = 'complete' | 'current' | 'upcoming'
export interface RudiStepperStep {
label: string
description?: string
icon?: string
status?: RudiStepStatus
}
Takes steps, orientation, and size. Status drives the node appearance, and the current step carries aria-current="step".
Structure
Divider takes orientation, spacing, and an optional label that renders inline within the rule. Its element depends on both: a plain horizontal divider is an <hr>, while a vertical one, or a horizontal one with a label, is a <div role="separator" aria-orientation="…"> wrapping rule, label, rule. A labeled divider cannot be an <hr>, since <hr> takes no children.
The spacing prop is worth noting as a counterexample to chapter 5’s rule that spacing props take CSS lengths. Divider uses a keyword scale (none | sm | md | lg) because a divider’s margins should come from a small set of consistent values, not arbitrary lengths. Layout primitives are general-purpose and take any length; a component with one job can be opinionated.
The unexported five
The companion repository also contains AppShell, TopBar, Footer, PageHeader, and SectionHeader. They are built, styled, and have stories, and they are deliberately absent from src/index.ts.
They exist to compose the example pages from chapter 10 without those pages duplicating a hundred lines of chrome each. Exporting them would be a commitment: an application shell with a responsive drawer has opinions about routing, breakpoints, and where navigation state lives, and every one of those opinions becomes an API you support forever.
What you have now
The library covers what a product actually needs: forms, overlays, navigation, status, and content. A page built from it should not require any custom CSS.
That is a testable claim, and the example pages from chapter 10 are the test. Build one and count how many times you reach for a raw element or a one-off style rule. Each instance is either a missing component or a missing prop, and finding them this way is considerably cheaper than hearing about them from someone who already adopted the library.
The next two chapters cover the parts that need real dependencies: charts, and a drag-and-drop board.
This chapter has been corrected since it was first published: see what changed. Found an error? Report it.