A few years ago, keeping up with the JS ecosystem was my part-time job. I’d spend weekends trying out libraries and staying informed about what bug the latest minor version of each one fixed.
Now I hardly have the time to do that, so I wait until a library starts making the rounds on Twitter (X?) before I spend some time with it. This time, it was Radix’s Primitives that surfaced.
What is Radix Primitives?
Radix Primitives is a collection of extensible, unstyled, and accessible components made to serve as the building blocks of your application. That was a mouthful.
Every time you’re starting a new project you need common components like dropdowns, accordions and dialog pop-ups. You have two options - you build them yourself or you use a component library.
The first option is time consuming and nonsensical in a world where millions of dropdowns have already been created. So we most often go with the second one.
But component libraries come with a problem of their own - they often have opinionated styling that is a challenge to override. On top of this, you don’t know if the markup underneath follows the semantic conventions.
Radix solves these problems. It gives you components with a composable API so you have control over what and where you use, but the functionality has already been implemented for you.
Together with that the Radix Primitives are compatible with latest accessibility standards and written with care toward unnecessary re-rendering.
With this sales pitch over, let’s look at the code.
A Primitive Component
Each primitive is a separate package that exports a set of components which you can put together as you need. In contrast to most component libraries that give you an accordion component with an API like this:
<Collapse items={items} defaultActiveKey={['1']} />
Radix gives you a more expressive and composable API that looks like so:
<Accordion.Root
className="AccordionRoot"
type="single"
defaultValue="item-1"
collapsible
>
<Accordion.Item className="AccordionItem" value="item-1">
<Accordion.Trigger>Is it accessible?</Accordion.Trigger>
<Accordion.Content>
Yes. It adheres to the WAI-ARIA design pattern.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item className="AccordionItem" value="item-2">
<Accordion.Trigger>Is it unstyled?</Accordion.Trigger>
<Accordion.Content>
Yes. It's unstyled by default, giving you freedom over the look
and feel.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item className="AccordionItem" value="item-3">
<Accordion.Trigger>Can it be animated?</Accordion.Trigger>
<Accordion.Content className="AccordionContent">
<div className="AccordionContentText">
Yes! You can animate the Accordion with CSS or JavaScript.
</div>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
And while your initial reflex might be to favor the short and opinionated syntax, the Radix component serves a different purpose. It’s aimed at teams that are willing to exchange some speed at the cost of better control over the look and internals of their components.
You can style the primitives without fear of complex style clashes and selector specificity. Attach classes to the components like in the example above, and rely on data attributes to style specific states, like an open accordion.
.AccordionItem {
border-bottom: 1px solid gainsboro;
}
.AccordionItem[data-state='open'] {
border-bottom-width: 2px;
}
This is powerful because in contrast to most libraries that put a ton of props on their components for every imaginable situation, Radix uses the DOM to communicate. In the world of frameworks this is an underutilized method
Think of data attributes as states that you can take action on. And you can target them even with CSS-in-JS libraries.
const StyledItem = styled(Accordion.Item)`
borderbottom: 1px solid gainsboro;
&[data-state='open']: {
borderbottomwidth: 2px;
}
`
Alternatively, since you can provide your own classes, you can use Tailwind as well - that’s what I would suggest. It makes styling states a little bit harder, though.
function TabsComponent() {
const [value, setValue] = React.useState('one')
const getTriggerClass = (triggerValue) => {
return triggerValue === value ? 'bg-blue-300' : 'bg-blue-500'
}
return (
<Tabs.Root value={value} onValueChange={setValue}>
<Tabs.List>
<Tabs.Trigger value="one" className={getTriggerClass('one')}>
One
</Tabs.Trigger>
<Tabs.Trigger value="two" className={getTriggerClass('two')}>
Two
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="one">One Content</Tabs.Content>
<Tabs.Content value="two">Two Content</Tabs.Content>
</Tabs.Root>
)
}
This is an example with a Tabs
component that shows how we can achieve this by using the onValueChange
prop and local state. Notice that the name of the sub-components are consistent with the ones used for the Accordion
.
It shows attention to detail. Now, let’s look under the hood.
The Primitive Package
Here’s the basic structure of the Primitives’ repository. Like many other libraries I’ve written about, it’s split into core
and react
modules, allowing for a clear distinction between the fundamental utilities and primitives (core) and the React components that build on them (react).
├── core
| ├── number
| | ├── ...
| ├── primitive
| | ├── ...
| ├── rect
| | ├── ...
├── react
| ├── accordion
| | ├── src
| | | ├── index.ts
| | | ├── Accordion.tsx
| | | ├── Accordion.stories.tsx
| | | ├── Accordion.test.tsx
| ├── ...
Inside the react
folder we find all the components split into packages. Each package corresponds to a set of functionalities or components that are logically related, making it easier to manage, update, and use them individually. They’re also distributed as separate components.
npm install @radix-ui/react-accordion
That’s how you’d install the accordion, for example.
No Single Component Per File
The structure of each package is straight forward - a component, stories, and tests. We immediatelly notice that even though the Accordion
is most likely made up of multiple components, they’re not split into separate files.
This is in contrast to the “single component per file” rule we often follow when building applications with React. It makes the folder structure a lot simpler, but it will, perhaps, bloat the component file.
The Accordion Component
The file is around 500 lines long, which isn’t that much taking into account the functionality of an entire accordion. Since I wasn’t aware of the naming conventions and what to look for in the file, I went to find the default export.
Usually, I export my components immediatelly together with their definition. In this case, all components are grouped, renamed, and exported at the bottom.
It makes for a good reading experience, though.
const Root = Accordion
const Item = AccordionItem
const Header = AccordionHeader
const Trigger = AccordionTrigger
const Content = AccordionContent
export {
createAccordionScope,
//
Accordion,
AccordionItem,
AccordionHeader,
AccordionTrigger,
AccordionContent,
//
Root,
Item,
Header,
Trigger,
Content,
}
The components are renamed for consistency with the other packages, and notice the commented lines to split the exported object into multiple sections. That’s a nice detail. I continued with the Accordion
component that makes the root.
// Details omitted for brevity...
const Accordion = React.forwardRef((props, forwardedRef) => {
const { type, ...accordionProps } = props
const singleProps = accordionProps as AccordionImplSingleProps
const multipleProps = accordionProps as AccordionImplMultipleProps
return (
<Collection.Provider scope={props.__scopeAccordion}>
{type === 'multiple' ? (
<AccordionImplMultiple
{...multipleProps}
ref={forwardedRef}
/>
) : (
<AccordionImplSingle {...singleProps} ref={forwardedRef} />
)}
</Collection.Provider>
)
})
This is the main component that acts as a wrapper for the accordion functionality. It accepts props to determine whether it should behave as a single or multiple accordion, and accordingly renders either AccordionImplSingle
or AccordionImplMultiple
.
It also uses a Collection.Provider
to manage the collection of items within the component. Underneath it holds a ref
to the provider and uses querySelector
to access the item elements inside the accordion and handle their opened/closed state.
This is a necessity because of the composable nature of the primitives. Since they don’t have complete control over the internals, passing state around is much harder and there are no guarantees what components the users would include there.
This way they once again use the DOM as a source of truth.
But enough about the Collection.Provider
, let’s look at the other components. If you’ve read my other articles, you are aware of the Impl
pattern used to name the “private” part of a component that’s not meant to be exposed directly.
const AccordionImplSingle = React.forwardRef(
(props, forwardedRef) => {
const {
value: valueProp,
defaultValue,
onValueChange = () => {},
collapsible = false,
...accordionSingleProps
} = props
const [value, setValue] = useControllableState({
prop: valueProp,
defaultProp: defaultValue,
onChange: onValueChange,
})
return (
<AccordionValueProvider
scope={props.__scopeAccordion}
value={value ? [value] : []}
onItemOpen={setValue}
onItemClose={React.useCallback(
() => collapsible && setValue(''),
[collapsible, setValue]
)}
>
<AccordionCollapsibleProvider
scope={props.__scopeAccordion}
collapsible={collapsible}
>
<AccordionImpl
{...accordionSingleProps}
ref={forwardedRef}
/>
</AccordionCollapsibleProvider>
</AccordionValueProvider>
)
}
)
Here’s the single-item Accordion
. Notice that it is yet another layer that is setting up handlers and data before passing them down to the AccordionImpl
component which is the heart of the implementation.
Also, notice that it barely holds any logic at all. It relies on a custom hook called useControllableState
to set up the state and the required handlers. There are many other layers we can continue exploring, but we will stop at the lengthy AccordionImpl
component.
const AccordionImpl = React.forwardRef<
AccordionImplElement,
AccordionImplProps
>((props: ScopedProps<AccordionImplProps>, forwardedRef) => {
const {
__scopeAccordion,
disabled,
dir,
orientation = 'vertical',
...accordionProps
} = props
const accordionRef = React.useRef<AccordionImplElement>(null)
const composedRefs = useComposedRefs(accordionRef, forwardedRef)
const getItems = useCollection(__scopeAccordion)
const direction = useDirection(dir)
const isDirectionLTR = direction === 'ltr'
const handleKeyDown = composeEventHandlers(
props.onKeyDown,
(event) => {
if (!ACCORDION_KEYS.includes(event.key)) return
const target = event.target as HTMLElement
const triggerCollection = getItems().filter(
(item) => !item.ref.current?.disabled
)
// ... removed 60 lines for brevity
}
)
return (
<AccordionImplProvider
scope={__scopeAccordion}
disabled={disabled}
direction={dir}
orientation={orientation}
>
<Collection.Slot scope={__scopeAccordion}>
<Primitive.div
{...accordionProps}
data-orientation={orientation}
ref={composedRefs}
onKeyDown={disabled ? undefined : handleKeyDown}
/>
</Collection.Slot>
</AccordionImplProvider>
)
})
I’ve removed a big chunk of it, but wanted to show how the collection we mentioned above is used here. The AccordionImpl
calls the useCollection
hook to retrieve an items getter, naming it getItems
.
This function returns a reference to the elements of the items in the accordion. Then when someone is clicking the keyboard arrows, the focused accordion item will change. This is the kind of accessibility-related functionality that you get out of the box with these primitives, but still keeping control over the visual look.
A last interesting detail is the Primitive.div
component that is used in place of a regular div
.
Layers, Layers, Layers
The more you browse open-source libraries, the more layers you will find. Codebases that are actively worked on and changed frequently need boundaries between parts of the code so they can be changed without dragging other modifications with them.
A layered approach gives a good foundation for the growth of a library or software. It allows you to add things in the future while preserving what already exists. Notice how the same AccordionImpl
powers both the single-item and multi-item accordions.
But instead of getting riddled with conditionals and edge-case handlers, it’s kept at a lower layer. It’s not just a choice, but a necessity.
We’d do good if we employ similar practices so we don’t create software that is only functional, but also extensible, scalable and maintainable.