Style objects

Introduction

As mentioned in the previous chapter, it's often a good idea to start a design with inline components (snippets of JSX code that have inline styles) and then move repeated code to custom components. Custom components work well when the code that appears in multiple places is highly similar. For example, dialog buttons usually look similar to each other, with some variation in sizes and colours that can be captured in the parameters of a custom component.

There are also cases where it's harder to use a custom component, such as forms. Different forms will share some features, such as labels, titles, and subtitles but there are usually major differences between them that make it hard to support all use-cases with a single component. In this case we can still reuse code and increase consistency by using a so-called style object, as we shall see below.

In this chapter, I will use the component snippet from chapter 2 as a starting point. I will discuss how the code can be decluttered by moving a subset of the styles to an .scss file, and explain how a style object can be used to capture common patterns in the remaining styles without introducing a full-blown custom component.

Breaking out intrinsic styles

To add a new component we can copy a TailwindUI snippet and adjust it to suit the local context. As discussed in the previous chapter, this approach has many benefits but having a long list of inline styles can make it harder to understand the code. We'll try to improve this by moving some of the styles (the least interesting ones) to an .scss file.

For the purpose of understanding the code, the styles that are most important are those that affect the layout. We can verify that a particular margin or padding is correct by relating it to the margins and paddings of neighouring components. I will use the term "ad-hoc styles" for these context-dependent styles and "intrinsic styles" for the context-independent ones. To reduce code clutter, intrinsic styles can be moved to an .scss file.

There is no formal rule for deciding which styles are intrinsic. From a design perspective, most styles - including colors and font-weights - are context dependent. However, when I'm verifying the code I'm usually interested in the layout, and therefore I will move colors and font-weights to the .scss file. In general, I trust that the reader will intuitively understand why certain styles are in the .scss file and others are not. In less obvious situations, such as when a margin appears in the .scss file, it's helpful to include a comment.

Let's look at an example of splitting instrinsic and ad-hoc styles:

  • the email-field code snippet from the previous chapter has been copied to the body of a SignInForm component;
  • the intrinsic styles are captured in a .scss file as SignInForm__EmailFieldLabel and SignInForm__EmailFieldInput;
SignInForm.tsx
SignInForm.scss
import { classnames as cn } from 'classnames';

type PropsT = {};

export const SignInForm = (props: PropsT) => {
  return (
    <div className={cn("SignInForm__EmailField", ["mb-6"])}>
      <label
        for="email"
        className={cn("SignInForm__EmailFieldLabel", ["mb-2"])}
      >
        Email address
      </label>
      <div>
        <input
          id="email" name="email" type="email"
          className={cn("SignInForm__EmailFieldInput", ["py-1.5"])}
        />
      </div>
    </div>
  );
};

Style objects

The ad-hoc styles typically involve the component's layout and geometry, such as flex-box properties, margins, heights, widths, and paddings. Because they are inlined, ad-hoc properties can easily be adjusted to fit a specific context. Still, it's good for consistency to limit the variation in these properties. For instance, if an email field has a vertical padding of 1.5, then it's likely that the password field has the same padding. To address this, I extract the ad-hoc properties and place them in a style object:

SignInForm.tsx
FormField.tsx
import { classnames as cn } from 'classnames';

export const SignInForm = () => {
  return (
    ...
    <div
      className={cn(
        "SignInForm__EmailField",
        FormFieldS.root.margin()
      )}
    >
      <label
        for="email"
        className={cn(
          "SignInForm__EmailFieldLabel",
          FormFieldS.Label.margin()
        )}
      >
        Email address
      </label>
      <div>
        <input
          id="email" name="email" type="email"
          className={cn(
            "SignInForm__EmailFieldInput",
            FormFieldS.Input()
          )}
        />
      </div>
    </div>
  );
};

I think of a style object as a loosely defined custom component that I can mix into my concrete components. For example, we can add a SignInForm__EmailFieldLabel with the FormFieldS.Label style by mixing in this part of the style object. As indicated by the comments, it's not mandatory to use all the keys in the layout object. For example, the Label key is only utilized if the form-field includes a label.

We can achieve the same result by mixing in CSS classes, but in my opinion that doesn't work quite as well:

  • since a CSS mixin class can be used in any CSS selector, it opens the door to more complex code;
  • we can easily jump to the style object in our IDE, but not so easily to a CSS mixin;
  • we can create hierarchies within a style object (as we shall see below), but not in SCSS;

In general, I find myself moving away from SCSS (though I still use it for intrinsic styles, where it's proving to be very useful) and towards styling with TailwindCSS and Typescript. The use of style objects instead of SCSS fits in that trend.

Adding choices to a style object

The logical next step is to have style objects that offer a choice of paddings, colours, title styles, subtitle styles, etc from which we can choose when we create a concrete component. This is reminiscent of the design systems that we may use in tools such as Figma. Here's an example:

SignInForm.tsx
FormField.tsx
import { classnames as cn } from 'classnames';

export const SignInForm = () => {
  return (
    <div className="SignInForm">
      <div className={cn(
        "SignInForm__EmailField",
        FormFieldS.root(),
        FormFieldS.margin.big()
      )}>
        <label
          for="email"
          className={cn(
            "SignInForm__EmailFieldLabel",
            FormFieldS.Label.margin(),
            FormFieldS.Label.color.blue()
          )}
        >
          Email address
        </label>
        <div>
          <input
            id="email" name="email" type="email"
            className={cn(
              "SignInForm__EmailFieldInput",
              FormFieldS.Input.padding()
            )}
          />
        </div>
      </div>
    </div>
  );
};

Adding intrinsic styles to a style object

If we always combine a style object with the same intrinsic styles then it makes sense to include these in the style object too. In the updated code below, the SignInForm__EmailFieldLabel and SignInForm__EmailFieldInput classes are replaced by FormField__Label and FormField__Input classes that come from the style object.

SignInForm.tsx
FormField.tsx
FormField.scss
import { classnames as cn } from 'classnames';

export const SignInForm = () => {
  return (
    <div className="SignInForm">
      <div className={cn(FormFieldS.root(), FormFieldS.margin.big())}>
        <label
          for="email"
          className={cn(FormFieldS.Label.root(), FormFieldS.Label.color.blue())}
        >
          Email address
        </label>
        <div>
          <input
            id="email" name="email" type="email"
            className={cn(FormFieldS.Input.padding())}
          />
        </div>
      </div>
    </div>
  );
};

What's next

Style objects are useful, but they don't offer many guarantees. For example, for every instance where a style object is used, we have to remember to include all the relevant parts. In the next chapter, we'll see how we can fix this by using style objects in so-called trim objects.