Handling spacing in a UI component library

Building a highly consumable UI component library is no easy feat and this article will focus on one particular tricky aspect of it: outer component spacing. By outer I mean spacing that is not internal to a component, highlighted in red here:

Two dummy UI components showing some spacing between them.

And the type of spacing I’ll focus on is vertical spacing, handling horizontal spacing could be a whole article in itself.

What type of UI component library?

The type of UI component library I’m thinking about is applicable to an application UI and one where the consumer, lets say a software engineer, isn’t expected to do any UI development work. By that I mean writing any HTML, CSS, and JavaScript, that is concerned with the creation of presentational UI components. Instead the software engineer can use the library to import the components into her application views to then compose them into the UI she needs.

What’s left for a consumer to do is hook up data and build up the rest of the application. Using a library like React this would follow the pattern of Container components where all of the UI components are Presentational components. The concept of Container and Presentational components is explained really well in this Medium article titled “Container Components”.

Why spacing isn’t straightforward

Adding spacing between elements within a component: internal component spacing is generally easier as you can build it right into the component as it is the component, like:

.c-search-results__item {
margin-bottom: 1.5rem;
}

c stands for “component”.

When doing this though, it’s important to remove spacing that protrudes from the component, like:

.c-search-results__item:not(:last-child) {
margin-bottom: 1.5rem;
}

Adding spacing between components: outer component spacing isn’t so easy because components need to be highly self-contained so that they have zero dependency on any particular UI context or other components. Each component should be able to be dropped anywhere in the UI and it’ll just work.

Let’s look at an example of a Search Results component where it has outer spacing applied by default, that is, spacing that comes directly after itself, like:

.c-search-results {
margin-bottom: 1.5rem;
}

This may have happened because in its most common application, or its only application at this point in time, it’s sitting above a Pagination component where some space is needed between them.

The problem with this though, is that the Search Results component is now styled to a particular UI context, if it needs to be dropped into another context where it doesn’t come before a Pagination component, or any other component, it’ll have unnecessary space following it.

Components that have outer spacing applied by default can be problematic.

An exception

You’ll notice I said “can be problematic”, as there are exceptions to most rules.

An example is when components are part of a larger whole, that is, when components aren’t used in isolation instead they are always part of a particular UI composition or context but they still require to be broken down into their own individual bits (components).

Take a standard form used again and again in an application UI where the breakdown of the form components, using React, might look like this:

<FormRoot>
  <FormGroupRelatedControls name="Add a new list">
    <FormControlTextInput label="List name" />
    <FormControlSelect
label="List type"
options={options}
/>
    <FormSubmit />

</FormGroupRelatedControls>

</FormRoot>

The UI design always requires a set spacing amount between each form control (typically made up of a label and a form field), in the example above that would be the Form Control Text Input, Form Control Select, and the Form Submit components. It makes sense to have these components apply outer component spacing out of the box, why? Because we know these components always exist in a set composition of other form components and by not having these components apply this spacing by default would mean a consumer has to remember to manually apply the spacing, which is more work than necessary when composing a form.

Spacing scale and direction

It really helps to store all of your spacing units are in one place within your CSS architecture, and to have a solid spacing scale set up. The scale I have used on a few recent projects looks a bit like this:

/**
* The scale based on `$g-font-size` being 16.
*
* Decrease: 12, 8, 4
* Increase: 24, 32, 40, 48, 56, 72, 96
*/
$g-spacing: $g-font-size !default;
// Decrease
$g-spacing-small: floor($g-spacing - 4) !default;
$g-spacing-x-small: floor($g-spacing / 2) !default;
$g-spacing-2x-small: floor($g-spacing / 4) !default;
// Increase
$g-spacing-large: ceil($g-spacing / 2 * 3) !default;
$g-spacing-x-large: ceil($g-spacing / 2 * 4) !default;
$g-spacing-2x-large: ceil($g-spacing / 2 * 5) !default;
$g-spacing-3x-large: ceil($g-spacing / 2 * 6) !default;
$g-spacing-4x-large: ceil($g-spacing / 2 * 7) !default;
$g-spacing-5x-large: ceil($g-spacing / 2 * 9) !default;
$g-spacing-6x-large: ceil($g-spacing / 2 * 12) !default;

Sass is being used here and g stands for “global”.

To keep things highly consistent and maintainable, spacing should, for the most part, be applied in one direction—I find using a downwards direction works best. And spacing should always be applied with the margin property—padding is for internal element spacing.

Different approaches

Using a section of GitHub’s UI and the React library I’ll go through three different approaches outer component spacing can be applied. These approaches are not exclusive to React and can be applied to any system that makes consuming UI components easy, but to keep things simple all my examples will be in React.

GitHub profile sidebar.

Here are all of the React UI components that make up the above UI:

<UserImage
alt={data.userName}
src={data.userImage}
url={data.userUrl}
/>
<UserNameHeading 
heading={data.userName}
subHeading={data.userNameSub}
/>
<TextLink
text="Add a bio"
url="/settings/profile"
/>
<Button
text="Edit profile"
url="/account"
/>
<Divider />
<UserDetails data={data.userDetails} />
<Divider />
<Heading
text="Organizations"
rank={2}
/>
<Organizations data={data.organizations} />

For brevity I’ve simplified the User Details and Organizations components and your breakdown might be different but let’s not focus on that, what’s important here is that each piece of UI is broken down into highly self-contained components that are easy to consume, and of course, develop and maintain.

Between each of these components some space is required, highlighted in red below. This is the outer component spacing. In addition, each component is highlighted in green, including the inner component spacing, highlighted in blue.

GitHub profile sidebar highlighting all outer component spacing, inner component spacing, and each component.

Approach 1: Components handle outer spacing

The first approach is to have the components themselves handle outer spacing, applied via a component property, like:

<Breadcrumbs spacing="2x-large" […] />

This doesn’t mean components have outer spacing applied out of the box—refer to the Why spacing isn’t straightforward section to see why this isn’t optimal—instead, it should be “opt-in”.

A big concern with this approach is that we could end up with a lot of repeated styles as the components are including the CSS for the spacing scale every time. This is something we definitely want to avoid in a highly modular UI component library like this, or any large CSS code base. Things will soon become a maintenance nightmare and code bloat will become a thing.

To avoid the above issues we can apply the outer spacing via helper or utility classes (let’s go with “helper”) which we define once in the CSS architecture, like:

/**
* Base.
*/
.h-spacing {
margin-bottom: rem($g-spacing);
}
/**
* Decrease from base.
*/
.h-spacing-small {
margin-bottom: rem($g-spacing-small);
}
.h-spacing-x-small {
margin-bottom: rem($g-spacing-x-small);
}
.h-spacing-2x-small {
margin-bottom: rem($g-spacing-2x-small);
}
/**
* Increase from base.
*/
.h-spacing-large {
margin-bottom: rem($g-spacing-large);
}
.h-spacing-x-large {
margin-bottom: rem($g-spacing-x-large);
}
.h-spacing-2x-large {
margin-bottom: rem($g-spacing-2x-large);
}
.h-spacing-3x-large {
margin-bottom: rem($g-spacing-3x-large);
}
.h-spacing-4x-large {
margin-bottom: rem($g-spacing-4x-large);
}
.h-spacing-5x-large {
margin-bottom: rem($g-spacing-5x-large);
}
.h-spacing-6x-large {
margin-bottom: rem($g-spacing-6x-large);
}

h stands for “helper” and each helper class gets its value from the spacing scale shown in the Spacing scale and direction section. And the rem() function is a simple Sass function that converts a number to a rem unit.

And applied to a component like this:

<Breadcrumbs className="h-spacing-2x-large" […] />

But this doesn’t feel very consumer friendly. A component’s API needs to be as simple as possible especially when it comes to strings that are concerned with applying styles like these. Additionally, helper classes should only be the concern of the UI component library team, not a consumer of the library.

Another issue with this approach is a consumer can now easily apply custom styles by appending more classes to the className property (which compiles to the HTML class attribute), like:

<Breadcrumbs className="h-spacing-2x-large foo" […] />

Then in the consuming application’s style sheet:

.foo { /* bespoke styles here */ }

The Breadcrumb component has now been forked ☹️, not what we want.

Of course a consumer can apply custom styles other ways, for example, getting creative with element/attribute CSS selectors. But doing it via the className property is going be the easiest and most tempting.

We really want all components locked down so that any custom styles are hard to apply giving us peace of mind that all of the libraries styles are maintained in one place and owned by the component library team. Not to mention preventing the main problem a UI component library like this attempts to solve: inconsistent UI’s.

There’s a better way to handle this which is to go back to using a specific spacing property where the component takes care of applying the helper class which means a consumer gets to apply a simple string to the property, like:

<Breadcrumbs spacing="2x-large" […] />

Handled in the component like this:

const classList = objectKeysToString({
'c-breadcrumbs': true,
[`h-spacing-${spacing}`]: spacing
});

objectKeysToString is a JavaScript utility for conditionally joining strings together typically used to generate className's.

Here’s how we’d apply this approach to the GitHub profile sidebar paying attention to code that is in bold:

<UserImage
alt={data.userName}
spacing="base"
src={data.userImage}
url={data.userUrl}
/>
<UserNameHeading 
heading={data.userName}
spacing="base"
subHeading={data.userNameSub}
/>
<TextLink
text="Add a bio"
spacing="base"
url="/settings/profile"
/>
<Button
text="Edit profile"
spacing="base"
url="/account"
/>
<Divider spacing="base" />
<UserDetails 
data={data.userDetails}
spacing="base"
/>
<Divider spacing="base" />
<Heading
text="Organizations"
rank={2}
spacing="small"
/>
<Organizations data={data.organizations} />

Approach 2: A spacing component

The second approach is to create a spacing component that is only concerned with applying outer component spacing, like:

import React, {PropTypes} from 'react';
import {SIZE_SCALE} from './../constants';

const VerticalSpacing = ({
children,
size
}) => (
<div className={
size ?
`h-spacing-${size}` :
`h-spacing`
}
{children}
</div>
);
VerticalSpacing.propTypes = {
children: PropTypes.node,
size: PropTypes.oneOf([
SIZE_SCALE.small2x,
SIZE_SCALE.smallx,
SIZE_SCALE.small,
SIZE_SCALE.large,
SIZE_SCALE.largex,
SIZE_SCALE.large2x,
SIZE_SCALE.large3x,
SIZE_SCALE.large4x,
SIZE_SCALE.large5x,
SIZE_SCALE.large6x
])
};
export default VerticalSpacing;

The spacing gets applied to this component via the aforementioned helper spacing classes which are generated by the value a consumer enters into the size property, if a size property isn’t assigned then the base spacing unit is applied by default.

As a side note, we can use React.PropTypes (React’s built-in typechecking capability) to make sure a consumer only enters the correct values for the size property, like:

size: PropTypes.oneOf([
SIZE_SCALE.small2x,
SIZE_SCALE.smallx,
SIZE_SCALE.small,
SIZE_SCALE.large,
SIZE_SCALE.largex,
SIZE_SCALE.large2x,
SIZE_SCALE.large3x,
SIZE_SCALE.large4x,
SIZE_SCALE.large5x,
SIZE_SCALE.large6x
])

And to avoid repeating really common styles like these throughout the library we can store all of the spacing values in a global constants file, like:

export const SIZE_SCALE = Object.freeze({
large: 'large',
large2x: '2x-large',
large3x: '3x-large',
large4x: '4x-large',
large5x: '5x-large',
large6x: '6x-large',
largex: 'x-large',
small: 'small',
small2x: '2x-small',
smallx: 'x-small'
});

Here’s how we’d apply this approach to the GitHub profile sidebar paying attention to code that is in bold:

<VerticalSpacing>
<UserImage
alt={data.userName}
src={data.userImage}
url={data.userUrl}
/>
</VerticalSpacing>
<VerticalSpacing>
<UserNameHeading
heading={data.userName}
subHeading={data.userNameSub}
/>
</VerticalSpacing>
<VerticalSpacing>
<TextLink
text="Add a bio"
url="/settings/profile"
/>
</VerticalSpacing>
<VerticalSpacing>
<Button
text="Edit profile"
url="/account"
/>
</VerticalSpacing>
<VerticalSpacing>
<Divider />
</VerticalSpacing>
<VerticalSpacing>
<UserDetails data={data.userDetails} />
</VerticalSpacing>
<VerticalSpacing>
<Divider />
</VerticalSpacing>
<VerticalSpacing size="small">
<Heading
text="Organizations"
rank={2}
/>
</VerticalSpacing>
<Organizations data={data.organizations} />

Approach 3: Global spacing rules

The final approach is applying spacing at a more global level, defined outside of any components CSS.

The “lobotomized owl selector” is one such way, defined like this:

* + * {
margin-top: rem($g-spacing);
}

This selector is basically saying: “Every element that comes directly after an element is to have a top margin”.

In the example above, the top margin is set to equal the base unit of our spacing scale.

You could apply something that is less “global”, for example, target every root element of every component, if you use BEM and namespaces then a selector like this could do the trick:

[class^='c-']:not(:last-child):not([class*='__']) {
margin-bottom: rem($g-spacing);
}

This selector is applying a bottom margin to the root element of every component except if a component is the last child of the element it resides in, which is usually what we want. It keeps the bottom margin only applied to the root element, for example: c-menu-button, and not any child elements, for example: c-menu-button__trigger, by excluding any classes that contain a double underscore as they represent an Element in the BEM methodology, which means a child element of a component.

Here’s how we’d apply this approach to the GitHub profile sidebar paying attention to code that is in bold:

<UserImage
alt={data.userName}
src={data.userImage}
url={data.userUrl}
/>
<UserNameHeading 
heading={data.userName}
subHeading={data.userNameSub}
/>
<TextLink
text="Add a bio"
url="/settings/profile"
/>
<Button
text="Edit profile"
url="/account"
/>
<Divider />
<UserDetails data={data.userDetails} />
<Divider />
<Heading
text="Organizations"
rank={2}
/>
<Organizations data={data.organizations} />

Note that nothing changes from the original set up because every component requires the base spacing unit which they’re getting from our globally defined spacing selector. The problem, however, is with the Heading component, why? Because it requires a spacing unit of small.

Which approach is best?

Each approach can get the job done—well the third approach only somewhat but read on for more on that. But in this particular sort of UI component library it comes down to these two main concerns:

  1. How easy it is for a consumer of the library to apply outer spacing to a component including how it’s explained in the library’s documentation.
  2. How good it is for the library’s codebase and developers, for example, the approach needs to ensure that the codebase stays maintainable, scalable, and robust, all the while keeping the development experience pleasant and easy to understand.

Approach 1 and Approach 2 can both be good options but I feel Approach 2 better satisfies the main concerns listed above. It nicely packages up all outer component spacing into its own component and seeing that it’s spacing concerned with what comes after or outside a component, it fits the model of a component being a highly self-contained unit—Approach 1 can start to feel like a violation of that. There are cases where Approach 1 does make sense which is explained in the An exception part of the Why spacing isn’t straightforward section.

Approach 3 is the least favourite approach for me and one that I haven’t been able to successfully implement on a UI component library like this at scale. Saying that though, I like how super contained it is, nicely capturing a large amount of a UI’s spacing concerns in one fell swoop. That “one fell swoop” though, can be a little bit too aggressive resulting in writing awkward CSS to fix up cases where spacing has been incorrectly applied. Also there are other spacing units from the spacing scale that need to be catered for, so it only gets you so far.

That’s a wrap

You may completely disagree with the approaches I’ve covered and that’s OK, do keep in mind though that I am concerned with a very specific use case covered in the What type of UI component library? section.

There are plenty of other tricky things to solve in a UI component library like this, HTML headings is one, which I might cover next? Until then happy spacing.

🙂
 ↕️ 
🙂