Skip to content

Instantly share code, notes, and snippets.

@ryanflorence
Last active November 28, 2023 09:50
Show Gist options
  • Save ryanflorence/e5c794e6093d16a69fa88d2112a292f7 to your computer and use it in GitHub Desktop.
Save ryanflorence/e5c794e6093d16a69fa88d2112a292f7 to your computer and use it in GitHub Desktop.

Reach UI Philosophy

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.

Accessible

Abstract as much as is useful

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/>.

Manage as much focus as possible, warn when we can't

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.

Abstract as much ARIA as possible

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.

Composable

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.

One-to-one rendered DOM element to Reach component

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>

Higher-level abstractions are important too!

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>

Random implementation checklist:

  • 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.

Stylable

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.)

Should feel like styling dom elements

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.

Not a Design System

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.

Keep CSS Simple

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.

No Styling Props

Display/style props like width, height, size etc. are venturing off into "Design System" territory. Save that for Reach DS, not Reach UI.

Component CSS Selectors

Each component has a predictable CSS selector:

<Combobox>  -->  [data-reach-combobox] {}
<Tabs>      -->  [data-reach-tabs] {}
<TabPanel>  -->  [data-reach-tab-panel] {}

Styling States with Pseudo Pseudo Selectors

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;
}

Exposing State for JS Styling

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).

Additional Considerations

  • 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.

Documentation Tips

  • 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.)
@emplums
Copy link

emplums commented Oct 16, 2019

Love the way you've put into words the composability stuff! I tend to lean towards this pattern as well :)

@ryanflorence
Copy link
Author

OMIGOSH EMPLUMS ๐Ÿคฉ, where can we hang out online since you've left the anxiety dot com site?

@emplums
Copy link

emplums commented Oct 16, 2019

In this Gist forever?! ๐Ÿ˜‚๐Ÿ˜‚๐Ÿ˜‚ jk - I created a new anxiety dot com profile to try 2 start fresh: https://twitter.com/emplums I use it minimally (evidenced by my 0 tweets and following list)

@selbekk
Copy link

selbekk commented Oct 17, 2019

Great job on the writeup :) Lots of great tips and tricks here I can't wait to implement in my own component APIs.

@shahankit
Copy link

@ryanflorence great writeup. Can you post more about these two topics whenever you are free:

  • use globals instead of context for "intercomponent communication"
  • avoid type checking in parents, instead do context registering or bail and use dom APIs.

If you have some examples about these in reach-ui or somewhere else that would also be great.

@esprehn
Copy link

esprehn commented Mar 24, 2020

fwiw data attributes and attribute selectors have more overhead than classes. Attribute selectors in particular are making things slower in the recalc style phase. Using class names will get you the best performance from the CSS engine in the browser.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment