Creating Robust UI Components
Creating user interfaces is an art that requires attention to detail and a deep understanding of how users interact with applications. In this article, I’ll share some essential guidelines that I’ve developed over the years for building robust UI components. These principles help ensure that components are not only visually appealing but also maintainable and scalable, providing a solid foundation for any UI project.
No className exposing
I avoid exposing the className
property in components! It makes them harder to refactor and less robust. Instead, I let the component control its own styles for flexibility and easier maintenance. If you want to refactor a component that uses className override, try using a different layout system or wrapping it with an element. If that’s not enough, expose a variant prop for the specific styles you use. The key is to let the component control its styling!
Same for style
property
No margin or spacing in components
I don’t put any margins and transparent padding in my components. Even if it works for your use case - it does not relate to the component. Margin and spacing related to the layout element. I also don’t use margins at all. Margins are also Margin considered harmful because they have The Rules of Margin Collapse and their are not controlled by their parent. I use flexbox or grid gap instead, or padding to layout the element.
Hard-coded size values - only when you need them
Generally, I try to avoid hard-coded sizes for components. Instead, I determine the size by the text or children of the component with some padding. But sometimes it’s required by the design system. In those cases where the component should have a fixed size, it’s important to make sure that the text inside is formatted properly when it’s underflow or overflow. You cannot assume that the text in the component is fixed most of the time. It can be changed in the future and it can change size when using i18n. So I use truncate
or min-width
to handle those cases.
Also, when dealing with images (especially avatars), make sure that the component is not shrinking with flexbox. It’s better to have a hard-coded size or min-width
.
Read more about it in Defensive CSS
Props and Variants
If I want to have multiple style variants for the same component I sometimes use multiple variants. I build it in a way that each variant controls different aspects of the component. For example
// A component can have a lot of variants. It's flexible and hard to use.
className={clsx([
"whitespace-nowrap font-medium",
isFullWidth ? "w-full" : "w-48",
hasSpecialBorder ? "border-4 border-blue" : "border border-primary",
isLargeText ? "text-xl" : "text-sm",
{
"text-primary bg-primary" : color === "primary",
"text-secondary bg-secondary" : color === "secondary",
"text-special bg-special" : color === "cta",
}
])}
The number of modes for the component equals to multiplication of the number of options for each variance together. Now the component has 2 * 2 * 2 * 3 = 24
states. It’s a lot of options and sometimes a lot of the variants are not in use. Also, each time a component can have 4 properties that we have to remember just to set it up. So we can improve it and expose variants. Which means grouping related styles into a single variant prop.
// Extracting variants
className={clsx([
"whitespace-nowrap font-medium",
isFullWidth ? "w-full" : "w-48",
hasSpecialBorder ? "border-4 border-blue" : "border border-primary",
{
"text-primary bg-primary text-xl" : variant === "primary",
"text-secondary bg-secondary text-sm" : variant === "secondary",
"text-special bg-special text-xl" : variant === "cta",
}
])}
This way we have 2 * 2 * 3 = 12
states instead.
If the hasSpecialBorder
is not related to the secondary button - we can improve it
// Duplicate css variant values are fine
className={clsx([
"whitespace-nowrap font-medium",
isFullWidth ? "w-full" : "w-48",
{
"text-primary bg-primary text-xl border border-primary" : variant === "primary",
"text-secondary bg-secondary text-sm border border-primary" : variant === "secondary",
"text-special bg-special text-xl border border-primary" : variant === "cta",
"text-special bg-special text-xl border-4 border-blue" : variant === "cta-border",
}
])}
and now we have 2 * 4 = 8
options.
Notice that the styles of the component are not collapsing anymore - so the debugging is easy. Creating the cta-border
variant has some code duplication - but it’s manageable.
Notice that I avoid overriding css border props like:
// ⛔ - overriding border styles in "cta-border" variant
className={clsx([
"whitespace-nowrap font-medium border border-primary",
isFullWidth ? "w-full" : "w-48",
{
"text-primary bg-primary text-xl" : variant === "primary",
"text-secondary bg-secondary text-sm" : variant === "secondary",
"text-special bg-special text-xl" : variant === "cta",
"text-special bg-special text-xl border-4 border-blue" : variant === "cta-border",
}
])}
It can lead to errors. cta-border
variant has both border-primary
and border-blue
and the actual border color is determined by the order of css classes generated by TailwindCSS, instead of the order we declare it in the HTML. We can use tailwind-merge to mitigate this risk. tailwind-merge
helps remove conflicting TailwindCSS classes from strings and array - so the latter the className
declared in the array the latter class declared in the function.
But I prefer to avoid the collision at all. It’s easier for me to see the actual CSS classes that applied for an element instead of calculating in the head the result of the final css output. Also avoiding collisions makes refactoring easier - because we do not expect components to have variants so changes are not affected. more-difficult-to-refactor-highly-reusable-components
Decreasing component available state count
Tip: you can use TypeScript types to remove the number of modes your component has. For example, you can specify that the component can specify the glow effect only when in a special variant with MergeExclusive
from type-fest
import type { MergeExclusive } from "type-fest";
type Props = MergeExclusive<
{ variant: "special"; isGlowing: boolean },
{ variant: "primary" | "secondary" }
>;
Or you can use cva for easy TypeScript interface generation (but notice it exposes className
and I think it’s an anti-pattern).
Conclusion
In conclusion, creating robust UI components is a process that requires careful consideration and planning. By avoiding the exposure of className
and style
properties, not including margins or spacing within components, avoiding hard-coded sizes unless necessary, and effectively using props and variants, we can create components that are not only visually appealing but also maintainable and scalable. Remember, the goal is to create components that can stand the test of time and adapt to the ever-changing requirements of the design system. Happy coding!