Bootstrapping a UI component library

November 15, 2021

At a small startup, the timing is never quite right to start a component library. Extremely quick iteration is not usually associated with putting together a standardized set of components and conventions to use across your app, as your components and conventions change all the time in the frenzy of finding product-market fit!

Nevertheless, component libraries can be tremendously valuable in separating design concerns from feature development and business logic, eventually leading to faster-than-ever iteration. In this article we'll explore what went into our component library — we called it Ryu — and what we left out, the tradeoffs we made along the way, and the vision for its future. Let's go!

What does a component library consist of?

In essence, our component library consists of colors, sizes, fonts, line heights, and even media breakpoints and z-indices. When building a component library you don't generally come up with these values, but rather you take them from what Design has already fleshed out — probably in Figma. The role of our component library is to mirror Figma as closely as possible, so that developers need not worry about getting the design aspect of their feature work, and can instead focus on getting business logic and its edge cases right. In this sense, the component library can and should be the bridge that melds together Design, Product, and Engineering, effectively acting as the source of truth for our design system is.

To that end, we need an understanding across the organization that, while Design has the last word on how the experience we offer looks and feels, Engineering requires it be standardized into a core set of functionality like inputs, buttons, tooltips, menus, tables, drawers, and anything else the designs require. This distinction essentially buckets design work into two categories: new core components or modification to existing ones, and feature work that relies exclusively on core components and their many variations. Product's role in this collaboration is keeping both sides accountable and ensuring new designs are smoothly implemented so that feature work can leverage the latest. Aligning on this across the organization is key in ensuring we offer a consistent experience, where tweaks to components are leveraged across the product instead of making a change that only applies to one particular project or feature.

This strict separation between larger feature work and the components that make up those features affords your team the opportunity to stop and think about how changing the design system fits with the rest of the design and the app. It also forces engineering to be more involved in the design process, offering its perspectives on UX and feasibility.

So we have variables, tighter collaboration and feedback loops, and a strict separation of design concerns and feature work. An important point to make next is building a hard boundary between the main codebase and your component library.

Isolation

You might be tempted to argue in favor of keeping the component library in the main codebase — just put the components in their own directory but keep them in the main repo. This is a good first step, but it's not good enough. Having a separate repository is beneficial in many ways:

  • Feature work doesn't get to make drive-by "small tweaks" to the component library as a side effect
  • Designers can interact with the component library in isolation without having to worry about business logic, in a codebase that's typically a lot easier to digest
  • Satellite efforts like internal tools, an engineering blog, or documentation sites can easily leverage the component library if it's isolated
  • The component library can't be tainted by the main application, and isn't constrained to it, meaning there's no intermingling of business logic and design concerns
  • It's a forcing function to properly document your component interfaces, which self-reinforces as better interfaces for its consumers
  • In a similar vein, you're also forced to think about the design system in the larger scheme of things. It's not just meant for your core application anymore, so this forces us to think about the essence of what a component is trying to achieve, and to create simpler, more general purpose interfaces

Once you decide to isolate the component library and can leverage it across the company, it's important to draw lines. The component library interface is to be fiercely protected, lest it degrade as a result of trying to appease every potential use case. When designing its interfaces, always take a step back and think about the use cases. What purpose does this new setting serve? Is there a more general way of achieving the same functionality that doesn't back us into a corner? Do we even need to make a change here, or is there a solution that doesn't involve any changes to the component library? And of course, the cardinal sin of component design — would we end up with multiple ways of achieving the same outcome?

Good component libraries are like a good wine, they need time to breathe. It's important to recognize that you don't need to support every use case. Think of the context your component library is born in. It is unlikely the component library will be the first project the company embarks on. By the time you start thinking about developing a component library, a vibrant product development team is churning away features and likely already has an existing set of components they've outgrown. These components were iterated on heavily, the experience they provide is likely really good, and if you're thinking about starting a component library afresh, chances are their interfaces have become less than ideal due to a combination of small tweaks done as part of feature work and a proclivity to favor speed of iteration above nearly all else. This is not necessarily a bad thing, priorities change, and you now have a deep well of insight to draw from.

So when bootstrapping a component library, we can start with the obvious. Tackle the aspects of existing components that have a clearly superior solution, and ignore others for a while. As the library builds out and time lapses, patterns emerge, become obvious, and then you can incorporate them. Punt on problems you don't have a good solution for. Rather than implementing a bad interface, do nothing. It's not like the component library will suffer from it: you're preserving the status quo, consumers can always go use the old components that support every use case under the sun. Your goal isn't to support everything yet, but to end up with a package that eventually can support almost every use case, and in order to do that you need a solid foundation that doesn't overextend itself.

Consumption

Let's say you expose your component library as an npm package (private or otherwise). What else should you have? Even when the library is internal to your company, it's always a good idea to treat the project as if it were open-source.

Yes, this means maintaining a hand-crafted changelog, so that developers can keep appraised about what's happening in the UI library and how it evolved over time, and proper versioning, cutting releases the team can adopt at their own pace.

As with any open-source project, you should take great care of building an approachable interface. Extensively using TypeScript could also be a great way of nudging consumers towards addressing breaking changes.

One of the ultimate goals of a component library is lowering the bar for UI contributions, so that — for example — folks working on the backend can contribute bits and pieces of UI work without having to know much about design or the patterns we use, since all of that is encapsulated in the library.

It also enables for faster iteration by virtue of being unable to make tweaks to the components as you go, and instead forcing the organization to make changes to the component library separately (which lets us weigh those changes on their own merit). This helps streamline processes and funnels all design changes through a dedicated channel.

Having clear documentation that covers all major use cases (and then some more) is crucial in getting adoption. Documentation is the opportunity to abstract developers away from Figma and focus them towards feature work. A lack of proper documentation can be catastrophic for adoption, given folks would not know how to use the library or what the preferred style or patterns are, resulting in them rightfully defaulting to whatever alternative existed before the component library came to life.

Documentation is also an excellent way of talking with Design. Designers who can code make non-trivial contributions to the component library and tighten the link between Figma and code. Without a component library, designers will rarely feel adequate contributing style changes, fearing they might cause some undesirable side effect. The component library, being more isolated, makes contribution feel safer. The more examples you have for consumers, the better the experience a designer has contributing can be.

There are well known tools for writing documentation sites, but when it comes to a component library, we argue that nothing beats using the component library itself as the building blocks for its own documentation site. Right off the bat this gives you a second consumer, with its own set of needs. It also forces you to write an application from scratch using the component library. At first it might look underwhelming, but you can use those rough edges to your advantage, sharpening the component library itself.

Architecture

To reiterate, you probably aren't going to build a component library in a vacuum. Ramp was 799 days old when we started building ours. We are building it for the long term, and it's equally important to build on the context we already have. To that end, we picked technologies we are already using in the main application, and which we're already comfortable with, such as React, TypeScript, Babel, styled-components, and downshift. By leaning on these technologies, we were able to iterate quickly.

We already had a color palette, and standardized sizing units, breakpoints, and so on. When it comes to the library, we made the conscious choice to add a layer of indirection here. Instead of naming colors, for instance, we assigned semantic meaning to them. We assigned a color to signify destructive outcomes, a color to signify constructive outcomes, and so on. Having this layer of indirection decouples design from implementation, accomplishing two things. First, it lets us change implementation details — such as the exact hue we use for a "destructive" button — without changing the interface consumers interact with. Second, it means consumers don't have to worry about it. Third, semantic meaning and indirection are crucial to make theming implementations feel effortless. (Fine, three things).

We can take it a bit further and have a "destructive" background which translates as a light red tint, or a "destructive" button that's bright red. Consumers don't care about any of this. All they care about is having the component library render the correct interface for whatever Design decreed to be the correct "destructive" aesthetic for any given component. We can apply this concept across the board when it comes to magic numbers. Instead of knowing magic numbers, consumers talk in terms of whether something is large or small, so it makes sense for that to be the interface.

For example, whereas before you might've written code like:

<div style={{ color: 'red' }}>Deleted lines</div>
<div style={{ height: '1rem' }} />
<div style={{ color: 'green' }}>Added lines</div>

Now you might do:

<RyuText color='destructive'>Deleted lines</RyuText>
<RyuBreak size='m' />
<RyuText color='constructive'>Added lines</RyuText>

At this point you might argue: "but what if consumers want to display something slightly differently", and that hits a big aspect of having a well-crafted component library: it constrains consumers' choices, in such a way that there's very few ways of accomplishing exactly what they want — so that everyone's code looks the same — and so that there's as little customization going on as possible — so that everyone's feature looks and feels the same.

That is to say: "they can't, and that's a feature not a bug".

We also already had many components that had been heavily iterated on, and this is a trickier but crucial decision point: do you port these over or take the opportunity — and spend the time — to rework them? Both options have some merit, let's explore.

Porting over existing components has the advantage of being quick. Once migrated to the new repo, you can easily port over the usage of existing components to the new ones. That said, existing components may be intertwined with business logic or deeply ingrained in the core codebase, complicating the extraction. At the same time, at the end of this exercise, you might not have a lot to show for. Sure, components are now isolated, but they still carry the technical debt that accumulated over years of quick iteration, and it'll be just as hard to clean up their interface now as it was before the migration. At least you've stopped the bleeding: contributors are no longer able to make changes to core components as part of their normal workflow, they still should be able to change the core components, but they're forced to go through a dedicated process: contribute to a different repository, triggering a discussion with the maintainers of core components in Engineering, Design, and so on.

Reworking components as you port them into the component library has its own set of considerations. For one, it's a markedly slower process. You're taking something that was iterated on for years and condensing its interface down to the essence while being careful not to permanently close the door on any legitimate use cases. It's also an opportunity and a license to rewrite the codebase over time and in a way that makes business sense.

(Will it even be the same codebase once you've fulfilled your vision, though!?)

Rewiring the interfaces can give you an edge: with the beenfit of hindsight, its a lot easier to craft a consistent set of interfaces. You might make it so that — for instance — all components used to render lists of actions a user can perform take them in a standardized way, such as an array of button props. Such unified vision makes the library a breeze to use: consumers know to expect that virtually every component taking a list of actions expects button props. Consumers can thus write functions wiring business logic into the format your component library expects, and reuse them across different components and use cases.

Synergy isn't something we could obtain by copying existing components and refactoring a bit. It's going to be made a whole lot better by starting again from scratch. Adoption of these new components gets trickier, but we'll get to that.

Synergy

One concrete example of synergy within the confines of the library we're building, can be told starting with our file upload button RyuButtonFiles. This is a button users can click to upload files. It takes all the same props as a regular RyuButton, plus a few more props that are specific to file upload buttons.

<RyuButtonFiles
  onChange={async (files: File[]) => {
    const form = new FormData()

    for (const file of files) {
      form.append(file.name, file)
    }

    await fetch('/upload', { method: 'POST', body: form })
  }}
>
  Upload stuff
</RyuButtonFiles>

Ryu also has a menu component. It renders a button and, when clicked, some menu items in a dropdown list. The button has a certain default look and feel, but you can customize the button props for it. Menu items also take an object that's quite similar to the props buttons take. Consuming RyuMenu might look something like this:

<RyuMenu
  items={[
    {
      label: 'Avocado toast',
      onClick: () => showToast({ title: '🥑' }),
    }, {
      label: 'Literal toast',
      onClick: () => showToast({ title: '🍞' }),
    }
  ]}
/>

Now, suppose we want to mix the two. We want to render an upload button as a menu item. This is not a very common use case, and most component libraries I can think of don't have that use case in mind. Generally menu items can handle a click event and little else. Some are lucky to be able to handle links, but most try and get away with history.pushState instead.

Taking advantage of the as prop — which we heavily constrain usage of, through TypeScript — from styled-components, we can render a file upload button among our menu items. This prop essentially lets us render a component as something else, and even though Ryu doesn't expect you to use it often, we recognize it as a good way of supporting use cases that might otherwise go ignored.

The code would look like the following. This renders a button that, when clicked shows a couple menu items, one of which is a label styled as a button styled as a menu item, that when clicked shows a system dialog for file uploads. 🤯

<RyuMenu
  items={[
    {
      label: 'Avocado toast',
      onClick: () => showToast({ title: '🥑' }),
    }, {
      label: 'Upload',
      buttonProps: {
        as: RyuButtonFiles,
        asProps: {
          onChange: async (files: File[]) => {
            const form = new FormData()

            for (const file of files) {
              form.append(file.name, file)
            }

            await fetch('/upload', { method: 'POST', body: form })
          },
        },
      }
    }
  ]}
/>

In a similar vein, buttons as well as many other components in Ryu take an iconType. Typically, libraries might take a string and render an icon represented by a certain identifier. Ryu takes the iconType a little bit further, by allowing certain icon types to be animated. One such icon is a loading indicator. Instead of taking another prop for loading indicators, you'd set iconType='loading'.

The following piece of code lets you imagine how consumers could potentially come up with some sort of function to convert a RyuMenu item to an upload button that behaves in the precise way they want, with loading and error state handling, and without compromising the core Ryu interface.

const fileUploadItem = useFileUploadMenuItem({
  endpoint: '/upload',
  label: 'Upload',
})

<RyuMenu
  items={[
    {
      label: 'Avocado toast',
      onClick: () => showToast({ title: '🥑' }),
    },
    fileUploadItem,
  ]}
/>

type UseFileUploadMenuItemProps = Partial<RyuMenuItem> & {
  endpoint: string
  method?: 'POST' | 'PUT'
}

function useFileUploadMenuItem({
  endpoint,
  method = 'POST',
  ...menuItemProps = {}
}): RyuMenuItem {
  const [loading, setLoading] = useState()
  const showToast = useRyuToast()
  const { buttonProps = {} } = menuItemProps

  return {
    ...menuItemProps,
    buttonProps: {
      ...buttonProps,
      iconType: loading ? 'loading' : buttonProps.iconType,
      as: RyuButtonFiles,
      asProps: {
        onChange: async (files: File[]) => {
          const form = new FormData()

          for (const file of files) {
            form.append(file.name, file)
          }

          setLoading(true)

          try {
            await fetch(endpoint, { method, body: form })
          } catch (error) {
            showToast('Upload failed')
            logError(error)
          } finally {
            setLoading(false)
          }
        },
      }
    }
  }
}

It's hard to imagine how we'd build a synergistic interface like this had we not decided to build the component library from scratch. Further, we think having a deep well of existing and maturely iterated components to draw inspiration from actually led to a far stronger interface than what we could've built if we had the component library from day one. As I write these words, it is day 973 and we're just getting started! 🚀

Adoption

Getting a core product to start using a component library built from scratch is no easy feat. Folks have an existing workflow, they probably know the components they usually work with intimately, and change is hard! When we add that the new library is a hard fork of existing components, that means every instance where we consume the old components needs to be ported over, and that may take a long time. You risk a split where neither approach fully wins, and you end up in a worse situation than what your starting point was. To avoid this scenario you need to constantly reinforce the advantages, and the urgency, of adopting the new solution.

The advantages should be manifold. This is why we've decided to do a component library in the first place. Maintainability improvements, separation of concerns, more Pull Requests from infrequent contributors who aren't steeped in day-to-day front-end work, quicker iteration, more consistency, streamlined process, easier theming and accessibility, less code duplication, tighter coordination between Product, Design, and Engineering, and any other lofty goals we may have.

There's many strategies we can pursue to drive adoption of a component library. We can pick a component and replace all instances with the newer one, eliminating the need for the older version altogether. We can choose a part of the application and refactor it to use the new stuff, and then it can serve as a point of reference. The most important aspect of adoption is that we don't keep on adding new debt, that is, new code shouldn't be using the old components unless there's a good reason to do so. The default mode should be using components in the new library, and over time we can remove references to old components.

At the initial stages, adoption is far more important than completeness. That is to say: it is better at this stage to only have 5 components that can only accomplish a subset of the use cases the original components supported, but have fully adopted them; than to have 25 components that aren't in use at all. Aligning the team behind adoption goals, rather than any one person taking it on on their own, is the best way to set the library up for success. To this end, complete and precise documentation is a must.

Closing

We started this article talking about the timing to get started with a component library, talked about the trade-offs in creating one from scratch, and discussed the challenges along the way. As we continue to grow at an accelerating speed, we'll continue improving productivity by driving adoption of the component library across our applications (core app, our blogs, internal tools, etc.), and incorporating new components and designs along the way.

Our small but burgeoning engineering team is always on the lookout for passionate folks. If you made it this far and are considering new roles, you can reach out to me on Twitter (feel free to DM me @nzgb) or apply to our Frontend Software Engineer position directly. We have lots of interesting challenges and great chances for having a significant impact in the future of Ramp!

© 2021 Ramp Business Corporation. “Ramp,” "Ramp Financial" and the Ramp logo are trademarks of the company. The Ramp Visa Commercial Card and the Ramp Visa Corporate Card are issued by Sutton Bank and Celtic Bank (Members FDIC), respectively. Please visit our Terms of Service for more details.