How to Create Tailwind Component Variants Using React

In this post I share practical insights gained from real-life commercial projects on how to reduce the boilerplate that comes with Tailwind so you can improve your own, or your own team’s DX, and repositories.

Alert component

It's quite typical when developers work to deliver features, but maybe don't have time to rework a component and end up with this kind of repetitive code:

src/components/atoms/Alert/Alert.tsx
import type { FC } from "react";
import clsx from "clsx";
import type { AlertProps } from "./Alert.types";
import {
  EmojiHappyIcon,
  EmojiSadIcon,
  InformationCircleIcon,
} from "@heroicons/react/outline";

const Alert: FC<AlertProps> = ({ message, variant, additionalClasses }) => {
  if (!variant) {
    console.error(
      "Please use a recognised Variant with <Alert/>, e.g. 'error, success, warning'"
    );
    return null;
  }
  const PickedAlert: FC = () => {
    switch (variant) {
      case "error":
        return (
          <div
            role="alert"
            className={clsx(
              "w-full rounded-lg bg-red-50 p-4 text-red-800",
              additionalClasses
            )}
          >
            <div className="grid grid-flow-col justify-start gap-x-3">
              <EmojiSadIcon
                className="h-6 w-6 text-red-400"
                aria-hidden="true"
              />
              {message && (
                <div className="text-md">
                  <p>{message}</p>
                </div>
              )}
            </div>
          </div>
        );
      case "success":
        return (
          <div
            className={clsx(
              "w-full rounded-lg bg-emerald-100 p-4 text-emerald-800",
              additionalClasses
            )}
          >
            <div className="grid grid-flow-col justify-start gap-x-3">
              <EmojiHappyIcon
                className="h-6 w-6 text-emerald-600"
                aria-hidden="true"
              />
              {message && (
                <div className="text-md">
                  <p>{message}</p>
                </div>
              )}
            </div>
          </div>
        );
      case "warning":
        return (
          <div
            className={clsx(
              "w-full rounded-lg bg-amber-100 p-4 text-amber-800",
              additionalClasses
            )}
          >
            <div className="grid grid-flow-col justify-start gap-x-3">
              <InformationCircleIcon
                className="h-6 w-6 text-amber-600"
                aria-hidden="true"
              />
              {message && (
                <div className="text-md">
                  <p>{message}</p>
                </div>
              )}
            </div>
          </div>
        );
      default:
        return null;
    }
  };
  return <PickedAlert />

The complete source code from an Alert component

Don't get me wrong, it works, but on a team of more than one, with minimal unit tests, it's only a matter of time before it degrades. Someone could add more functionality, maybe a few additional props that make it do even more things than it already does, which would make it even harder to understand.

There's a high probability of the classes either needing to be updated in more than one place, or some kind of regression due to a lack of familiarity with it.

I would always advocate for snapshot testing as a bare minimum, to try to avoid that kind of thing.

That's the bad news out of the way, now for some good news:

  • If someone misconfigures their IDE to ignore TypeScript errors, there's a console.error() message explaining why it isn't working as expected
  • The implementation is straight forward
src/pages/index.tsx
import Template from "@templates/DefaultTemplate";
import { FC } from "react";
import Alert from "@components/Alert";

const Home: FC = () => {
  return (
    <Template>
      <Alert variant="success" message="Processed successfully!" />
      <Alert variant="error" message="An error occurred!" />
      <Alert variant="warning" message="Not sure what happened there" />
    </Template>
  );
};

export default Home;
How the Alert component is implemented

Just think about how easy it is for another developer to use this component, they need only import it and add a few props, simple.

It works so why bother refactoring?

I intend to create more variants similar to <Alert/>, such as: <Modal/>, <Button/>, <Card/> and I will eventually add more variants to the existing <Alert/> component as and when they're needed. If the code is simplified, the process of updating it could potentially be a breeze. I can imagine it now ...less time, less refactoring ...it sounds too good to be true doesn't it?

Don't get me wrong, it's not a hard-and-fast rule. I'm all for not refactoring for the sake of it, there are trade-offs such as the time taken now might not be necessary for project X to hit the deadline etc. However, for the little time it takes to do it now I'll bet on it not being worth kicking the can down the road to the next person (or future you) in the long-run!

How to make it easier to manage

Here's the directory structure I'm using for this component:

README.md
Component/
├──hooks/
├──index.ts
├──Component.tsx
├──Component.test.tsx
└──Component.types.ts
Component directory structure

I'm storing the bulk of the code that applies the variations in a separate hook, located in the component's directory.

src/components/atoms/hooks/useAlertVariant.tsx
import { AlertColourObject, AlertData } from "@components/Alert/Alert.types";
import {
  EmojiHappyIcon,
  EmojiSadIcon,
  InformationCircleIcon,
} from "@heroicons/react/outline";

import { ALERT_ERROR_MESSAGE } from "@lib/errors";

const useAlertVariant = (variant: string): AlertData | null => {
  const variants: AlertColourObject = {
    error: {
      color: "red",
      container: "text-red-800 bg-red-50",
      icon: (
        <EmojiSadIcon className="h-6 w-6 text-red-400" aria-hidden="true" />
      ),
    },
    success: {
      color: "green",
      container: "text-emerald-800 bg-emerald-100",
      icon: (
        <EmojiHappyIcon
          className="h-6 w-6 text-emerald-600"
          aria-hidden="true"
        />
      ),
    },
    warning: {
      color: "yellow",
      container: "text-amber-800 bg-amber-100",
      icon: (
        <InformationCircleIcon
          className="h-6 w-6 text-amber-600"
          aria-hidden="true"
        />
      ),
    },
  };

  if (!variants[variant]) {
    console.error(ALERT_ERROR_MESSAGE);
    return null;
  }

  return variants[variant];
};

export default useAlertVariant;
The variants are abstracted into a useful hook

The switch statement is no longer being used, I changed to an object lookup to reduce the amount of lines, which in this case makes sense because of the way the properties are named. Conversely, if they were named in some inane manner I may have been tempted to leave the switch.

src/components/atoms/Alert/Alert.tsx
import type { FC } from "react";
import clsx from "clsx";

import type { AlertProps } from "./Alert.types";
import useAlertVariant from "./hooks/useAlertVariant";

const Alert: FC<AlertProps> = ({ message, variant, additionalClasses }) => {
  const alertVariant = useAlertVariant(variant);

  if (!alertVariant) {
    return null;
  }

  return (
    <div
      role="alert"
      className={clsx(
        "w-full rounded-lg p-4",
        alertVariant.container,
        additionalClasses
      )}
    >
      <div className="grid grid-flow-col justify-start gap-x-3">
        {alertVariant.icon}
        {message && (
          <div className="text-md">
            <p>{message}</p>
          </div>
        )}
      </div>
    </div>

Alert has been refactored and is looking tidy!

I've only left markup that is declared in one place, all variants are handled by the hook and referenced once.

If you have any suggestions as to how the examples can be made clearer, easier to understand, or how I can add any missing use-cases you feel would help others, I'd love nothing more than to hear from you! Reach out to me on Twitter, or shoot me an email.

Written by Morgan Feeney

I’ve been designing and developing for almost 2 decades.
Read about me, and my work.

For juicy tips and exclusive content, subscribe to my FREE newsletter.