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!