The Best Style API for Reusable React Components

When creating reusable React Components, either in a library or for your own use, you need a flexible way to style your components.

There are 2 parts to this, what library or style system should you use, and then how do you implement that?

Regarding the what style system to use, you have a lot of options. Styled Components, Emotion, Styled System, static CSS, Vanilla Extract, Tailwind, and more. Which one should you choose?

Every approach has pros and cons, and you should choose based on your needs. My team ended up going with static CSS for a few reasons:

  • CSS-in-JS is slow
  • You can’t separate styles from your components
  • Complex selectors, themes, and variants can be a challenge

You can read about why we chose this approach here. In this post I want to talk about how you implement your style system in the smartest way.

The Problem

Here’s what we are trying to achieve:

  • Create lightweight, reusable components
  • Allow using them unstyled
  • Don’t ship a bunch of styles or dependencies (like Emotion or Styled Components)
  • Allow developers to access every part of the markup with styling for maximum customizability

To achieve these goals, we first expose all child components using dot notation because we don’t want any black boxes.

Next, we need to apply default styles, but also allow the consumer to choose not to apply them. That way you can style from scratch if you need to.

Then we need to allow customization of all parts of our components as easily as possible. This means styling from scratch, or just making a few edits to our default styles.

Let’s take a complex component like a Listbox (a styled select menu) as an example:

<Listbox>
 <Listbox.Button>Select a framework...</Listbox.Button>
 <Listbox.Options>
  <Listbox.Option value="React">React</Listbox.Option>
  <Listbox.Option value="Solid">Solid</Listbox.Option>
  <Listbox.Option value="Remix">Remix</Listbox.Option>
</Listbox.Options>
</Listbox>

How do you apply default styles (optionally), and then also allow the consumer to customize every part of this?

The Solution: A ClassNames API

My team’s approach is to have a classNames prop on the root component, and a single className allowed on all child components. We adopted this approach from Mantine.

<Listbox
  classNames={{
    button: 'bg-black text-white',
    options: 'border bg-white',
    option: 'p-2',
  }}
>
  <Listbox.Button>Select a framework...</Listbox.Button>
  <Listbox.Options>
    <Listbox.Option value="React">React</Listbox.Option>
    <Listbox.Option className="bg-blue-500" value="Solid">Solid</Listbox.Option>
    <Listbox.Option value="Remix">Remix</Listbox.Option>
  </Listbox.Options>
</Listbox>

We’ve found this approach to work incredibly well. (This example uses Tailwind, but it’s just classes, so you can use anything you want)

The classNames prop is great for applying a style to all the options for example. You’ll see we have a background of white above, but if you want to call out a single option, you then use the className prop directly on that option.

It may seem redundant to have a className prop on each element, as well as the top level classNames prop. Actually, this is one of the most important parts, because the single className prop allows for quick small modifications to a component, or changes based on state.

For example if you have a render function, you can change the class based on the state:

<Listbox>
 <Listbox.Options>
  {({ isOpen, value }: { isOpen: boolean; value: string }) => (
   <>
    <Listbox.Option value="React" className={ value === 'React' ? 'some-selected-class' : '' }>React</Listbox.Option>
...

We have found allowing a className prop on all child components to be extremely useful.

Now do it with style

We have the same API for CSSproperties in a style prop in case someone wants to use Emotion.

<Listbox
  styles={{
    button: {
      backgroundColor: 'black',
      color: 'white',
    },
    options: {
      border: '1px solid black',
      backgroundColor: 'white',
    },
    option: {
      padding: '8px',
    },
  }}
>
  <Listbox.Button>Select..</Listbox.Button>
...

We are not using CSSProperties in the library, which means we don’t ship the default styles. That keeps the library lightweight, especially if you don’t use them.

Optional Default Styles

What if someone doesn’t want the default styling?

  • Offer an escape hatch to disable all default styles
  • Allow disabling styles on a per-component basis
  • Don’t force a style system to allow the consumer to style any way they want

We are using static CSS stylesheets, so to get our default styling you import the stylesheet at the root of your app:

// Include the styles
import '@namespace-tdb/kodiak-css/index.css'

If you don’t want the default styles, don’t import them.

If you want the default styling for everything, but want a single component to be unstyled, we provide an unstyled prop:

<Listbox unstyled>

Now you can style that component from scratch using the classNames or styles API.

Another reason to separate the styles from the components is to be able to have cross-platform logic for React Native. This requires putting all your logic in custom hooks, which I’ll write about in my next post.


Posted

in

by

Tags: