Accessible components with React Aria

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 };