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.
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:
SignInForm__EmailFieldLabel
and SignInForm__EmailFieldInput
;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>
);
};
.SignInForm__EmailField {}
.SignInForm__EmailFieldLabel {
@apply block text-sm font-medium leading-6 text-gray-900;
}
.SignInForm__EmailFieldInput {
@apply block rounded-md border-0 shadow-sm;
@apply text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6;
@apply ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600;
}
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:
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>
);
};
export const FormFieldS = {
// The "root" style adds the styles for the FormField container
root: {
margin: () => 'mb-6',
},
// Use this style if the form-field contains a label
Label: {
margin: () => 'mb-2',
},
// Use this style if the form-field contains an input
Input: {
padding: () => 'py-1.5',
}
};
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:
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.
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:
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>
);
};
import "./FormField.scss";
export const FormFieldS = {
root: {
// Use one of the gap styles to create space below the form-field
margin: {
big: () => 'mb-6',
small: () => 'mb-2',
},
}
Label: {
margin: () => 'mb-2',
// Pick one color for the label
color: {
blue: () => 'text-blue-500',
green: () => 'text-green-400',
}
}
Input: {
padding: () => 'py-1.5',
}
};
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.
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>
);
};
import "./FormField.scss";
export const FormFieldS = {
root: () => 'FormField',
margin: {
big: () => 'mb-6',
small: () => 'mb-2',
},
Label: {
root: () => cn('FormField__Label'),
margin: () => 'mb-2',
color: {
blue: () => 'text-blue-500',
green: () => 'text-green-400',
}
}
Input: {
root: () => cn('FormField__Input'),
padding: () => cn('py-1.5'),
}
};
.FormField {
.FormField__Label {
@apply block text-sm font-medium leading-6 text-gray-900;
}
.FormField__Input {
@apply block rounded-md border-0 shadow-sm;
@apply text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6;
@apply ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600;
}
}
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.