Instead of implementing from scratch the most common web components, I usually recommend using an unstyled library (headless components).
Some common libraries are React Aria, Radix UI, and Headless UI.
In this project we are going with react-aria-components
:
pnpm add react-aria-components
Components scaffolding
My usual approach is to extend each component by wrapping it inside a custom implementation.
The default scaffolding just applies all the params
from the React Aria and exports it:
import * as Aria from "react-aria-components";
const Dialog = ({ ...props }: Aria.DialogProps) => {
return <Aria.Dialog {...props} />;
};
const DialogTrigger = ({ ...props }: Aria.DialogTriggerProps) => {
return <Aria.DialogTrigger {...props} />;
};
export { Dialog, DialogTrigger };
We can then extend each component with custom features and styles.
What I add most often is a className
to apply custom styles. Each component has some default styles, and className
can be used to add more:
import * as Aria from "react-aria-components";
import { cn } from "~/utils";
const Dialog = ({ className, ...props }: Aria.DialogProps) => {
return (
<Aria.Dialog
className={cn(
"w-screen max-h-[calc(100dvh-6rem)] overflow-y-auto p-6",
className
)}
{...props}
/>
);
};
const DialogTrigger = ({ ...props }: Aria.DialogTriggerProps) => {
return <Aria.DialogTrigger {...props} />;
};
export { Dialog, DialogTrigger };
cn
is a utility function to concatenate class names that combines clsx
and tailwind-merge
:
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
With this configuration is possible to copy-paste the default React Aria markup and all the custom styles are applied everywhere in the app:
// 👇 All custom components built on top of React Aria
import { Button } from "./Button";
import { Dialog, DialogTrigger } from "./Dialog";
import { Modal, ModalOverlay } from "./Modal";
export default function CreateFood() {
return (
<DialogTrigger>
<Button className="w-full">Create food</Button>
<ModalOverlay isDismissable>
<Modal>
<Dialog>
{/* ... */}
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
);
}
With this configuration you can also use some other unstyled components from
react-aria-components
if necessary (Group
,Button
,Text
, etc.).
Class Variance Authority
I also suggest to use class-variance-authority
to defined variants for each component.
pnpm add class-variance-authority
It works great with tailwindcss
and react-aria-components
:
import { cva, type VariantProps } from "class-variance-authority";
import * as Aria from "react-aria-components";
import { cn } from "~/utils";
const button = cva("rounded-md border px-4 py-2 text-sm", {
variants: {
action: {
default: "border-slate-600/30 bg-white text-slate-600",
update: "border-update/30 bg-white text-update",
remove: "border-remove/30 bg-white text-remove",
},
},
defaultVariants: {
action: "default",
},
});
const Button = ({
className,
action, // 👈 Extract variant (`action`) from `VariantProps` type
...props
}: Aria.ButtonProps & VariantProps<typeof button>) => {
return (
<Aria.Button className={cn(button({ action }), className)} {...props} />
);
};
export { Button };