Reach UI is an accessible foundation for React applications and design systems.
The three equally important goals are to be:
- Accessible
- Composable
- Stylable
The secondary goals are to:
- Be reference implementation of accessiblity principles for other libraries/apps
- Have the source code be a place of learning for React techniques.
- Keep the kilobytes minimal (mostly when considering dependencies)
As Michael puts it, we want to catch a developer when they:
- Get a design from a designer and are about to make their own combobox/accordion/tabs. Build on top of Reach instead so they get the accessibility right but have the flexibility to fulfill the design.
- Are tasked with creating a design system for their company. They get an accessible foundation and can just focus on your styles.
- Are building an internal app or side project where branded design isn't a priority.
So much of accessibility can be abstracted away, especially WAI-ARIA. But that doesn't mean we only do WAI-ARIA components, for example, we have VisuallyHidden
and SkipNav
. Our goal is to abstract as much as is useful to a product developer. For example, we don't have a Button
component that renders a <div role="button"/>
because we don't really have anything to add to <button/>
.
Every user interaction that shows or hides content should move focus. Most of the time that is the job of Reach UI. When a dialog opens, we move focus to the first focusable element in the dialog. When a menu is being navigated with the keybaord, we move focus to that item (or use aria-activedescendant). When a menu closes, we move focus to the menu trigger that opened it.
Sometimes Reach UI won't have enough knowledge though. For example, if a form in a Dialog
is submit and the developer navigates to a new route, the original button that triggered the Dialog is gone. In this case, we should check if document.activeElement === document.body
. If that is true, then know the app didn't manage focus and we should warn them to move focus when they changed the UI.
In many (most?) cases, the developer doesn't have to know anything about ARIA or accessibility to use Reach UI. Most of the ARIA APIs require element ids so we generate ids automatically because we can and requiring the developer to provide them would be annoying and error prone. Anything related to accessibility that can be abstracted probably should be
Avoid creating new props for aria when possible. If Reach needs a user-provided aria-attribute (like aria-label or aria-labelledby) just use the aria attribute as a required prop instead of coming up with something like ariaText
or accessibleLabel
. Exceptions will occur but should be strongly reconsidered.
A natural instinct when building reusable tabs in React is to couple the tab label with the tab panel content:
<Tabs>
<Tab label="One">
<div>The panel content</div>
</Tab>
<Tab label="Two">
<div>The other panel content</div>
</Tab>
</Tabs>
While this is tempting, it makes it difficult to compose tabs with the rest of the UI. Consider if you wanted to style the dom element all the tab labels were rendered in. How would you expose that? You'd probably end up with something weird like:
<Tabs tabListProps={{ className: 'what-have-we-done' }}>
{/* ... */}
</Tabs>
Now lets say you need to wrap that element in some other element for CSS grid or flexbox, you might start getting even weirder:
<Tabs renderTabList={tabs => <div>{tabs}</div>} />
This scenario never ends. Every time somebody opens a new issue on GitHub we'd add a new prop and the API explodes in an apropcalypse.
The following rules/principles will help us avoid these pitfalls from the start of every component's development.
Instead of passing a bunch of render-logic props down from the top to control the actual dom hierarchy rendered, we expose an API that feels like it's built into HTML, which is another way of saying there's a one-to-one relationship between a rendered DOM element, and a Reach UI component.
<Tabs> --> <div role="tabs">
<TabList> --> <div role="tablist">
<Tab/> --> <div role="tab"/>
<Tab/> --> <div role="tab"/>
</TabList> --> </div>
<TabPanels> --> <div role="tabpanels">
<TabPanel/> --> <div role="tabpanel">
<TabPanel/> --> <div role="tabpanel">
</TabPanels> --> </div>
</Tabs> --> </div>
(The aria roles won't always line up so nicely, but often they will.)
Now if the developer wants to style the tablist, they just ... style it.
<Tabs>
<TabList className="no-weird-tricks"/>
</Tabs>
Or if they want the tabs on bottom or top, they can just put them there because the dom ordering is not the concern of Reach UI.
By following this rule, every API will be maximally composable with anything else. We haven't hidden anything so there's no need to expose render-logic props from the top--developers have access to the elements they need by virtue of the API.
Additionally, any other props that need to go to that component can be passed through as well:
<Tab onClick={() => ga.send(['event', 'tabclick', 1])}>
Tracked Tab
</Tab>
Sometimes the composable API is a little much, especially in situations where 90% of the time you don't need the composition. In these cases we should consider a higher level API.
For example, with Dialog
, it would be annoying to have to type this out for every single dialog you created:
<DialogOverlay isOpen={true}>
<DialogContent>
<p>Always these two parents ... why?</p>
</DialogContent>
</DialogOverlay>
What you really want almost all of the time is:
<Dialog isOpen={true}>
<michael-scott-grumpy>Thank you!</michael-scott-grumpy>
</Dialog>
Dialog is really just this:
const Dialog = ({ isOpen, onDismiss, ...rest }) => (
<DialogOverlay isOpen={isOpen} onDismiss={onDismiss}>
<DialogContent {...rest} />
</DialogOverlay>
)
You have to be careful about which props go where, for example, most people would probably expect the <Dialog className/>
to go onto the <DialogContent/>
, not the <DialogOverlay/>
.
What we don't want to do is create the apropcalypse that we avoid with composable APIs--so don't do this:
<Dialog
overlayClassName="boo"
contentClassName="nooooo"
/>
As soon as people need extra control, they should drop down to the composable API, rather than us exploding the size of our API.
<DialogOverlay className="yay">
<DialogContent className="the-best"/>
</DialogOverlay>
forwardRef
on every component so any other component can manage its focus (and apps can do whatever they need with the component as well)- forward all props to the underlying element
- wrap event handlers Reach UI adds so they can be
preventDefault
-ed by apps--again, pretend we're HTML. - use
as
prop whenever a semantic tag isn't needed (almost always) so people can pass in their styled components or react-native-web buttons or whatever else they've got.
Many component libraries in React bring along with them opinions on how to style them. Often you pass in a big "theme" object from the top, or play along with whatever they chose (styled components, etc.)
A normal element can be styled in various ways, and so should every Reach UI component:
<Thing className="yep"/>
<Thing style={{ color: 'red' }}/>
const StyledThing = styled(Thing)(`
background: red;
`)
<Thing css={{ ':focus': { border: 'red' } }}/>
Supporting all of this really means supporting none of them directly. Any props passed to us we send to the underlying DOM element and it should all work just fine. This is another reason 1:1 dom element to React element is important.
There's a line where a "Component Library" becomes a "Design System". For example, <Badge/>
. It has no interaction and no special aria labeling. It's simply a styling/rendering abstraction. It's a display component.
Reach UI exists to abstract complex interactive components, but not display components. The fact that they need to be styled is more of a burden of the library than a feature 😂.
For that reason, Reach UI components ship with minimal styles provided by stylesheets.
When authoring the css the goal is to keep the selectors as simple as possible to keep the CSS specificity score as low as possible, and to keep the rules as minimal as possible so that overriding our styles is easy (avoiding what it feels like to style a button!).
A rule of thumb is the style should be "not completely embarrassing". But don't confuse this with "the ux can be not completely embarassing". The UX should be the best UX you've ever created after studying loads of prior work.
The expectation is people will add their own styles, so the less the better.
Display/style props like width, height, size etc. are venturing off into "Design System" territory. Save that for Reach DS, not Reach UI.
Each component has a predictable CSS selector:
<Combobox> --> [data-reach-combobox] {}
<Tabs> --> [data-reach-tabs] {}
<TabPanel> --> [data-reach-tab-panel] {}
Usually states of the component need to be styled differently, like highlighted or active states of menu items. For these states, we use what I lovingly call pseudo-pseudo-selectors or pp selectors if I'm feeling juvenile.
HTML controls have pseudo-selectors like :active
. Since we can't create new pseudo-selectors, we use data attributes as pseudo-pseudo-selectors.
[data-active] {}
/* parallels to: */
:active {}
Note that there is no data-reach
prefix. I did it this way since pseudo selectors don't have :button-active
, just :active
, and that's the analogy.
So, in practice you pair a pseudo-pseudo selector with the element selector
[data-reach-menu-item][data-active] {
background: lightblue;
}
/* parallels to: */
button:active {
background: lightblue;
}
Pseudo pseudo selectors are great for styling with CSS, but if you're using inline styles (or creating animations) you need that internal state to be exposed in JavaScript too.
For these cases, put any internal state that drives pseudo pseudo selectors onto context and then expose through a hook. It's usually already on context, the only thing to do is provide a hook that only exposes the state needed for JS styling.
This isn't implemented in Tabs yet, but it was on my radar to change earlier this year. Right now it uses cloneElement to tell a tab that it's active, but it should really render a <TabContext.Provider value={{ isActive: bool }}/>
like in our workshop material, and then we can expose that state with a hook.
This way people can style in JavaScript:
const TabWrapper = props => {
const isActive = useTabState()
return (
<Tab
style={{
color: isActive ? 'red' : 'black'
}}
{...props}
/>
)
}
<Tabs>
<TabList>
<TabWrapper>Alright!</TabWrapper>
</TabList>
</Tabs>
This is the same approach as React Router. Put values on context and then expose them through a hook that wraps useContext
. We never expose context directly, always through wrappers.
Note: This state, or a derivative of it (or source of the derivative!) is very likely to be exposed in onChange
and onToggle
type props.
In summary, whenever you have a pseudo-pseudo-selector you should also have a hook that exposes it (if there are multiple states, a single hook can expose multiple states).
-
prefer context over cloneElement
- only need cloneElement if the parent needs information from the child to render itself
-
prefer hooks over render props to expose state
-
use globals instead of context for "intercomponent communication"
- like menu button tooltips not displaying when menus are open
- alternative is to have all reach components to have a peer depedency on something like
@reach/utils
, but peer deps are a pain and a couple global variables keep things interesting 😂
-
avoid type checking in parents, instead do context registering or bail and use dom APIs.
-
no component really needs more than one index.js file, files are the root of all evil.
- Lead with the simplest case that can be easily copy/pasted
- Make examples that mimic real-world use-cases
- Add sample code for everything so developers can copy/paste. No API is too simple to skip an example. The simple cases don't have to be a fully functional thing, just a quick bit of code to show where the API fits in.
- End with the really cool but complicated examples (like animating, getting access to internal state and composing into an app level abstraction, etc.)
@ryanflorence great writeup. Can you post more about these two topics whenever you are free:
If you have some examples about these in reach-ui or somewhere else that would also be great.