Actions

Button

Action button with variants and sizes

Preview

size
variant
children
loading

Code

import css from "./Button.module.css";

import { SpinnerIcon } from "@phosphor-icons/react/dist/csr/Spinner";

type Size = "xs" | "sm" | "md" | "lg";

interface ResponsiveSize {
  base: Size;
  mobile?: Size;
  tablet?: Size;
  desktop?: Size;
  wide?: Size;
}

interface ButtonProps {
  size: Size | ResponsiveSize;
  variant: "solid" | "soft" | "outline" | "ghost";
  loading?: boolean;
  type?: "button" | "submit" | "reset";
  "aria-expanded"?: boolean;
  "aria-controls"?: string;
  children: React.ReactNode;
  ref?: React.Ref<HTMLButtonElement>;
  onClick?: (ev: React.MouseEvent<HTMLButtonElement>) => void;
}

function getSizeClasses(size: Size | ResponsiveSize): string {
  if (typeof size === "string") {
    return css[size];
  }

  const classes = [css[`${size.base}_base`]];

  if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
  if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
  if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
  if (size.wide) classes.push(css[`${size.wide}_wide`]);

  return classes.join(" ");
}

export default function Button({
  size,
  variant,
  loading,
  type,
  "aria-expanded": ariaExpanded,
  "aria-controls": ariaControls,
  children,
  ref,
  onClick,
}: ButtonProps) {
  // Enter and Space still click through pointer-events: none, and a submit would go twice.
  const handleClick = (ev: React.MouseEvent<HTMLButtonElement>) => {
    if (loading) {
      ev.preventDefault();
      return;
    }
    onClick?.(ev);
  };

  // aria-disabled, not disabled: the native one drops the focus mid-load.
  return (
    <button
      ref={ref}
      type={type}
      className={`
        ${css.button}
        ${getSizeClasses(size)}
        ${css[variant]}
        ${loading ? css.loading : ""}
      `}
      aria-busy={loading || undefined}
      aria-disabled={loading || undefined}
      aria-expanded={ariaExpanded}
      aria-controls={ariaControls}
      onClick={handleClick}
    >
      {loading && (
        <SpinnerIcon
          className={css.button_spinner}
          weight="bold"
          aria-hidden="true"
        />
      )}
      <span className={css.button_content}>{children}</span>
    </button>
  );
}

Props

PropTypeRequiredDescription
size"xs" | "sm" | "md" | "lg" | ResponsiveSizerequiredButton size (height, padding and type). Fixed or responsive.
variant"solid" | "soft" | "outline" | "ghost"requiredHeaviest to lightest. solid is filled, soft has a soft accent background, outline is a border that fills on hover, and ghost has no border or background and tints on hover.
loadingboolean—Waiting: a spinner before the content and the click blocked, without dropping focus.
type"button" | "submit" | "reset"—Native button type. Without it the browser decides, and inside a form it acts as submit.
aria-expandedboolean—Whether the panel it controls is open. Popover injects it when Button is its trigger.
aria-controlsstring—Id of the panel the button opens and closes. Popover injects it when Button is its trigger.
childrenReact.ReactNoderequiredButton content (text, or icon + text).
refReact.Ref<HTMLButtonElement>—Ref to the native <button>; it lets Button act as a Popover trigger.
onClick(ev: React.MouseEvent<HTMLButtonElement>) => void—Click callback; not called while loading.