Designing with Variants
An example of an advanced Tailwind / Uno (Windi) button component that fully utilizes the features of variants
and Tailwind classes.
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.
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
!
Prefix in TailwindIn 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.
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>
</>
)