Get started

Utils

Designing with Variants

Button Component (Tailwind / Uno)

An example of an advanced Tailwind / Uno (Windi) button component that fully utilizes the features of variants and Tailwind classes.

This article is currently under construction. If you need more explanation how parts of this component works, see the well documented Headline Component.

Intro: It all started here

The task of blueprinting a redundantly placed button, fully utilizing its design through property-controlled utility classes. Writing this only as a React component is a nightmare. And even then, at the point when you think you finally have this one "perfect button," you are unable to properly maintain it through the variability of project requirements and the resulting "horizontal styling." My wish was to read those heavily styled components like a book, with all the information I need to know about its design and behavior. This is why I created react-classmate.

The main idea of this project is to engage myself in properly designing and managing class names and separating them from the application logic.

This example requires you to have a basic understanding of Tailwind and its utility classes. If you are not familiar with it, I recommend that you read the Tailwind "Utility-First Fundamentals" first.

Full Code

The code below features dark (:dark) mode, hover (:hover), and active (:active) states as utility classes. You can also control the noGutter, disabled,loading, noShadow, and type properties.

This introduces the usage of the utility type VariantsConfig and the utility function convertRcProps. More on both below.

import { LoaderCircle } from "lucide-react"
import type { HTMLAttributes, ReactNode } from "react"
import { type RcBaseComponent, type VariantsConfig, convertRcProps, createVariantMap } from "react-classmate"

import { APP_CONFIG } from "#lib/config"
import type { Colors } from "#lib/types"
import { isLinkExternal } from "#lib/utils"

// 1. types
interface ButtonBaseProps {
  $size?: "lg" | "md" | "sm" | "xs"
  $color?: Colors
  $disabled?: boolean
  $loading?: boolean
}

// 2. setup variants
const buttonVariants: VariantsConfig<ButtonBaseProps, ButtonBaseProps> = {
  base: ({ $disabled, $loading }) => `
    transition-colors
    inline-flex items-center justify-center gap-2 
    font-bold
    ${APP_CONFIG.transition.tw}
    ${$disabled ? "opacity-60 cursor-not-allowed" : ""}
    ${$loading ? "opacity-80 pointer-events-none" : ""}
  `,
  variants: {
    $size: {
      xs: "py-1 px-2 rounded text-xs shadow-sm",
      sm: "py-1.5 px-2.5 rounded text-sm shadow-sm",
      md: "py-1.5 px-3 rounded shadow-sm",
      lg: "py-3 px-4 rounded-lg shadow-md",
    },
    $color: {
      primary: ({ $disabled }) => `
        text-lightNeutral 
        bg-primaryDarkNeutral 
        ${!$disabled ? "hover:bg-primary" : ""}
      `,
      secondary: ({ $disabled }) => `
        text-lightNeutral 
        bg-secondaryDarkNeutral 
        ${!$disabled ? "hover:bg-secondary" : ""}
      `,
      success: ({ $disabled }) => `
        text-lightNeutral 
        bg-successDarkNeutral 
        ${!$disabled ? "hover:bg-success" : ""}
      `,
      warning: ({ $disabled }) => `
        text-lightNeutral 
        bg-warningDarkNeutral 
        ${!$disabled ? "hover:bg-warning" : ""}
      `,
      error: ({ $disabled }) => `
        text-lightNeutral 
        bg-errorDarkNeutral 
        ${!$disabled ? "hover:bg-error" : ""}
      `,
      neutral: ({ $disabled }) => `
        text-dark 
        bg-light dark:bg-grayLight 
        ${!$disabled ? "hover:bg-graySuperLight dark:hover:bg-gray" : ""}
      `,
    },
  },
  defaultVariants: {
    $size: "md",
    $color: "primary",
  },
}

// 3. create variant map
const button = createVariantMap({
  elements: ["button", "a"],
  variantsConfig: buttonVariants,
})

// 4 define the react component
interface ButtonProps extends HTMLAttributes<HTMLAnchorElement | HTMLButtonElement> {
  icon?: ReactNode
  link?: string
  type?: "button" | "submit" | "reset"

  // we don't want to expose the classmate props to the user (devs) -> redeclare them here
  size?: ButtonBaseProps["$size"]
  color?: ButtonBaseProps["$color"]
  disabled?: ButtonBaseProps["$disabled"]
  loading?: ButtonBaseProps["$loading"]
}

const Button = ({ children, icon, link, ...buttonProps }: ButtonProps) => {
  // cast types
  const Component = link
    ? (button.a as RcBaseComponent<ButtonBaseProps & HTMLAttributes<HTMLAnchorElement>>)
    : (button.button as RcBaseComponent<ButtonBaseProps & HTMLAttributes<HTMLButtonElement>>)
  const isExternal = isLinkExternal(link)

  const preparedProps = convertRcProps(buttonProps, {
    size: "$size",
    loading: "$loading",
    disabled: "$disabled",
    color: "$color",
  })

  return (
    <Component {...(link ? { href: link, target: isExternal ? "_blank" : "" } : {})} {...preparedProps}>
      {icon}
      {children}
      {buttonProps.loading && <LoaderCircle className="w-4 h-4 animate-spin" />}
    </Component>
  )
}

// 6. export(s)
export default Button

Usage of the Important ! Prefix in Tailwind

In general, I would not recommend using the ! override (which is similar to !important) too often in classmate components, since we should preserve its usage. In the case of this button, we only override the padding and the shadow, which can be set using explicit props.

Button Sizing

This is a basic button with the type="button" attribute.

import Button from "./Button.tsx"

const SomeComponent = () => (
  <>
    <Button type="button" size="lg">Button Big</Button>
    <Button type="button">Button Medium</Button>
    <Button type="button" size="sm">Button Small</Button>
    <Button type="button" size="xs">Button Extra Small</Button>
  </>
)
More examples coming soon!