Button

Action button with solid, outlined, ghost, and minimal variants.

API Reference

PropTypeDefault
variant"solid" | "outlined" | "ghost" | "minimal""solid"
color"root" | "primary" | "secondary" | "accent" | "danger""root"
size"sm" | "md" | "lg""md"
iconReactNode
iconPosition"start" | "end""end"
loadingbooleanfalse
hrefstring
fullWidthbooleanfalse
disabledbooleanfalse

Variants & Colors

Solid

default
<Button variant="solid" color="root">Root</Button>
<Button variant="solid" color="primary">Primary</Button>
<Button variant="solid" color="secondary">Secondary</Button>
<Button variant="solid" color="accent">Accent</Button>
<Button variant="solid" color="danger">Danger</Button>

Outlined

<Button variant="outlined" color="root">Root</Button>
<Button variant="outlined" color="primary">Primary</Button>
<Button variant="outlined" color="secondary">Secondary</Button>
<Button variant="outlined" color="accent">Accent</Button>
<Button variant="outlined" color="danger">Danger</Button>

Ghost

<Button variant="ghost" color="root">Root</Button>
<Button variant="ghost" color="primary">Primary</Button>
<Button variant="ghost" color="secondary">Secondary</Button>
<Button variant="ghost" color="accent">Accent</Button>
<Button variant="ghost" color="danger">Danger</Button>

Minimal

<Button variant="minimal" color="root">Root</Button>
<Button variant="minimal" color="primary">Primary</Button>
<Button variant="minimal" color="secondary">Secondary</Button>
<Button variant="minimal" color="accent">Accent</Button>
<Button variant="minimal" color="danger">Danger</Button>

Sizes

<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>

Icons

End

default
<Button icon={<ArrowForwardIcon />}>Next</Button>
<Button variant="outlined" icon={<ArrowForwardIcon />}>Learn More</Button>

Start

<Button icon={<ArrowBackIcon />} iconPosition="start">Back</Button>
<Button variant="outlined" icon={<ArrowBackIcon />} iconPosition="start">Previous</Button>

Loading

<Button loading>Saving</Button>
<Button variant="outlined" loading>Loading</Button>
<Button variant="ghost" loading>Processing</Button>

Full Width

<div className="max-w-[400px]">
  <Button fullWidth>Full Width</Button>
</div>
<div className="max-w-[400px]">
  <Button fullWidth variant="outlined" icon={<ArrowForwardIcon />}>Continue</Button>
</div>
href
<Button href="/">Home</Button>
<Button variant="outlined" href="/" icon={<ArrowForwardIcon />}>Learn More</Button>

Basic Usage

import { Button } from "@/ui";

<Button>Click me</Button>

Source

src/ui/Button.tsx
import { cva, type VariantProps } from "class-variance-authority";
import NextLink from "next/link";
import type { ComponentProps } from "react";
import { Loader } from "./Loader";
import React from "react";
import { cn } from "@/utils";

export const buttonVariants = cva(
  "inline-flex cursor-pointer items-center justify-center gap-2 font-semibold transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        solid: "rounded-md border border-transparent",
        outlined: "rounded-md border bg-transparent",
        ghost: "rounded-md border border-transparent",
        minimal: "group rounded-md border border-transparent",
      },
      color: {
        root: "",
        primary: "",
        secondary: "",
        accent: "",
        danger: "",
      },
      size: {
        sm: "h-9 px-3 text-sm",
        md: "h-10 px-4 text-sm",
        lg: "h-11 px-8 text-sm",
      },
    },
    compoundVariants: [
      // solid
      { variant: "solid", color: "root", className: "bg-root-contrast text-root hover:bg-root-hover" },
      { variant: "solid", color: "primary", className: "bg-primary text-primary-contrast hover:bg-primary-hover" },
      { variant: "solid", color: "secondary", className: "bg-secondary text-secondary-contrast hover:bg-secondary-hover" },
      { variant: "solid", color: "accent", className: "bg-accent text-accent-contrast hover:bg-accent-hover" },
      { variant: "solid", color: "danger", className: "bg-danger text-danger-contrast hover:bg-danger-hover" },
      // outlined
      { variant: "outlined", color: "root", className: "border-root-contrast text-root-contrast hover:bg-root-contrast hover:text-root" },
      { variant: "outlined", color: "primary", className: "border-primary text-primary hover:bg-primary hover:text-primary-contrast" },
      { variant: "outlined", color: "secondary", className: "border-secondary text-secondary hover:bg-secondary hover:text-secondary-contrast" },
      { variant: "outlined", color: "accent", className: "border-accent text-accent hover:bg-accent hover:text-accent-contrast" },
      { variant: "outlined", color: "danger", className: "border-danger-contrast text-danger-contrast hover:bg-danger hover:text-danger-contrast" },
      // ghost
      { variant: "ghost", color: "root", className: "bg-root-ghost text-root-contrast hover:bg-root-ghost-hover" },
      { variant: "ghost", color: "primary", className: "bg-primary-ghost text-primary hover:bg-primary-ghost-hover" },
      { variant: "ghost", color: "secondary", className: "bg-secondary-ghost text-secondary hover:bg-secondary-ghost-hover" },
      { variant: "ghost", color: "accent", className: "bg-accent-ghost text-accent hover:bg-accent-ghost-hover" },
      { variant: "ghost", color: "danger", className: "bg-danger-ghost text-danger-contrast hover:bg-danger-ghost-hover" },
      // minimal
      { variant: "minimal", color: "root", className: "text-root-contrast hover:bg-root-ghost" },
      { variant: "minimal", color: "primary", className: "text-primary hover:bg-primary-ghost" },
      { variant: "minimal", color: "secondary", className: "text-secondary hover:bg-secondary-ghost" },
      { variant: "minimal", color: "accent", className: "text-accent hover:bg-accent-ghost" },
      { variant: "minimal", color: "danger", className: "text-danger-contrast hover:bg-danger-ghost" },
    ],
    defaultVariants: { variant: "solid", color: "root", size: "md" },
  },
);

interface ButtonSharedProps extends VariantProps<typeof buttonVariants> {
  children: React.ReactNode;
  className?: string;
  icon?: React.ReactNode;
  iconPosition?: "start" | "end";
  loading?: boolean;
  fullWidth?: boolean;
}

type ButtonAsLinkProps = ButtonSharedProps &
  Omit<ComponentProps<typeof NextLink>, "color" | "href" | "children"> & {
    href: string;
  };

type ButtonAsButtonProps = ButtonSharedProps &
  Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color" | "children"> & {
    href?: undefined;
  };

export type ButtonProps = ButtonAsLinkProps | ButtonAsButtonProps;

function buildContent({
  icon,
  iconPosition,
  loading,
  variant,
}: {
  icon?: React.ReactNode;
  iconPosition: "start" | "end";
  loading: boolean;
  variant: ButtonSharedProps["variant"];
}) {
  const iconEl = icon ? (
    <span
      className={cn(
        "inline-flex shrink-0",
        variant === "minimal" && iconPosition === "end" && "transition-transform group-hover:translate-x-1",
      )}
    >
      {icon}
    </span>
  ) : null;

  const startEl = loading ? (
    <Loader className="size-[1em] text-current!" aria-hidden="true" />
  ) : iconPosition === "start" ? (
    iconEl
  ) : null;
  const endEl = !loading && iconPosition === "end" ? iconEl : null;

  return { startEl, endEl };
}

export function Button(props: ButtonProps) {
  if (props.href !== undefined) {
    const {
      variant,
      color,
      size,
      children,
      className,
      icon,
      iconPosition = "end",
      loading = false,
      fullWidth = false,
      href,
      ...linkProps
    } = props;

    const { startEl, endEl } = buildContent({ icon, iconPosition, loading, variant });
    const classes = cn(buttonVariants({ variant, color, size }), fullWidth && "w-full", className);
    const isDisabled = loading;

    return (
      <NextLink
        href={isDisabled ? "#" : href}
        className={cn(classes, isDisabled && "pointer-events-none opacity-50")}
        aria-disabled={isDisabled || undefined}
        tabIndex={isDisabled ? -1 : undefined}
        {...linkProps}
      >
        {startEl}
        {children}
        {endEl}
      </NextLink>
    );
  }

  const {
    variant,
    color,
    size,
    children,
    className,
    icon,
    iconPosition = "end",
    loading = false,
    fullWidth = false,
    disabled = false,
    ...buttonProps
  } = props;

  const { startEl, endEl } = buildContent({ icon, iconPosition, loading, variant });
  const classes = cn(buttonVariants({ variant, color, size }), fullWidth && "w-full", className);
  const isDisabled = disabled || loading;

  return (
    <button className={classes} disabled={isDisabled} aria-busy={loading} {...buttonProps}>
      {startEl}
      {children}
      {endEl}
    </button>
  );
}