Complex components such as dialogs and menus can be challenging to style because they are made up of many parts. For example, a dialog might include a header, body, and footer.
Applying base styles is straightforward, but how can one create an error variant with a red header? Or override the body padding for a specific instance?
Let's explore a pattern that addresses these questions and improves the developer experience at the same time.
Separation of style and state
Before we get started, let’s agree on a few basic principles:
Components should expose only one prop for style overrides. Avoid creating separate props for each part, such as bodyStyles or hasPadding. This practice adds unnecessary complexity and makes it difficult to iterate on components.
Customizable parts should be easy to find and consistent. Consumers should be able to style your component without analyzing source code. In addition to using a single prop, make sure it is typed to provide autocompletion support.
Avoid using conditional logic for managing variants. When dealing with multiple style overrides, inline conditional styling can get messy quickly. It's cleaner to define styles as configuration and use an abstraction to handle the logic.
Following these principles, we can:
Expose a typed classNames prop with Tailwind completion.
Manage multiple levels of style overrides without conditional logic.
Separate markup and styles, resulting in more readable components.
Implementing this pattern
Let’s apply this pattern in practice! This guide assumes you’re using React and Tailwind.
Install Tailwind Variants
Tailwind Variants is an excellent variant API with support for slots.
npm i tailwind-variants
Define utility types and functions
Add the following wherever you keep your utility functions.
import { type ClassProp, type VariantProps as VProps } from 'tailwind-variants';
export { tv } from 'tailwind-variants';
type TVProps = (...args: any) => any;
type SlotFn = (config: ClassProp) => string;
type SlotClassNames<T extends TVProps> = { [key in keyof ReturnType<T>]: string };
export type VariantProps<T extends TVProps> = VProps<T> & {
classNames?: Partial<SlotClassNames<T>>;
};
export function getClassNames<T extends TVProps>(tv: T, props: VariantProps<T>) {
const { classNames, ...rest } = props;
// Apply base + variant override
const slots = tv(rest) as Record<string, SlotFn>;
// Apply instance override
return Object.fromEntries(
Object.entries(slots).map(([key, slot]) => [
key,
slot({ className: classNames?.[key] })
])
) as SlotClassNames<T>;
}
Set up Tailwind VSCode Intellisense
Add the following to your workspace settings file (*.code-workspace).
Extend your component props with VariantProps, using typeof the object created in the previous step. This will add any variants as props, as well as the classNames prop.
It only takes a small amount of setup to combine TypeScript and Tailwind to style complex components.
Here at Polytomic we’ve found it beneficial to use Tailwind Variants even for smaller components. It’s a simple pattern that promotes a clear separation of concerns.