Bloom Logo

Button

A button is an interactive UI element used to trigger actions such as navigation, form submissions, or contextual commands.

button.tsx

Main implementation of the Button component, handling all visual variants, interactive states, and user interactions.

ReactTailwindUI ComponentAccessibilityButton
"use client";

import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { Ripple } from "@/lib/ripple/ripple";
import { useRipples } from "@/lib/ripple/useRipple";
import { cn } from "@/lib/utils";
import { designColors, designSizes, designRadius } from "@/lib/design-system";

type ButtonSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl";
type ButtonRadius =
  | "none"
  | "xs"
  | "sm"
  | "md"
  | "lg"
  | "xl"
  | "2xl"
  | "3xl"
  | "full";
type ButtonColor =
  | "accent"
  | "primary"
  | "secondary"
  | "success"
  | "warning"
  | "danger"
  | "default";

type ButtonHover = "scale" | "lift";

type ButtonBaseProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
  VariantProps<typeof buttonBaseVariants> & {
    asChild?: boolean;
    isLoading?: boolean;
    loadingText?: string;
    loadingIcon?: React.ReactNode;
    isDisabled?: boolean;
    startContent?: React.ReactNode;
    endContent?: React.ReactNode;
    badgeContent?: string;
    badgePosition?: "start" | "end";
    badgeCustomClassname?: string;
    hover?: ButtonHover;
    size?: ButtonSize;
    color?: ButtonColor;
    radius?: ButtonRadius;
    disableRipple?: boolean;
  };

type IconOnlyProps = {
  isIconOnly: true;
  ariaLabel: string;
};

type NormalButtonProps = {
  isIconOnly?: false;
  ariaLabel?: string;
};

export type ButtonProps = ButtonBaseProps & (IconOnlyProps | NormalButtonProps);

const buttonBaseVariants = cva(
  "relative inline-flex items-center justify-center gap-1.5 font-medium transition-all duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 rounded-xl",
  {
    variants: {
      size: designSizes,
      radius: designRadius,
      variant: {
        default: "shadow-md",
        bordered: "bg-transparent border-2 border-teal-300 shadow-sm",
        light: "bg-transparent shadow-none border border-transparent",
        flat: "bg-transparent shadow-none border border-transparent",
        ghost: "bg-transparent border-2 border-teal-300 shadow-sm",
        shadow: "shadow-lg",
        link: "bg-transparent underline text-sky-600 hover:text-sky-500 shadow-none border-none",
      },
      hover: {
        scale: "hover:scale-[1.03] active:scale-[0.97] will-change-transform",
        lift: "hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm will-change-transform",
      },
    },
    defaultVariants: {
      size: "md",
      variant: "default",
    },
  }
);

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      asChild = false,
      variant,
      isLoading = false,
      loadingText,
      loadingIcon,
      isDisabled = false,
      startContent,
      endContent,
      badgeContent,
      badgePosition = "end",
      badgeCustomClassname,
      color = "default",
      radius = "xl",
      size = "md",
      hover = "scale",
      disableRipple = false,
      disabled,
      children,
      className,
      onClick,
      type,
      isIconOnly = false,
      ariaLabel,
      ...props
    },
    ref
  ) => {
    const Comp = asChild ? Slot : "button";
    const { ripples, addRipple, removeRipple } = useRipples();

    const handleClick = React.useCallback(
      (e: React.MouseEvent<HTMLButtonElement>) => {
        if (isDisabled || isLoading) return;

        if (!disableRipple) {
          const rect = e.currentTarget.getBoundingClientRect();
          const size = Math.max(rect.width, rect.height);
          addRipple(e.clientX - rect.left, e.clientY - rect.top, size);
        }

        onClick?.(e);
      },
      [isDisabled, isLoading, disableRipple, addRipple, onClick]
    );

    const activeVariant = variant || "default";

    return (
      <Comp
        ref={ref}
        type={type ?? "button"}
        disabled={isDisabled}
        aria-disabled={isDisabled}
        aria-busy={isLoading}
        aria-label={ariaLabel}
        onClick={handleClick}
        className={cn(
          buttonBaseVariants({ size, variant, radius, hover }),
          designColors[color][activeVariant],
          className,
          "cursor-pointer relative overflow-hidden",
          isLoading && "cursor-wait opacity-50",
          isDisabled && "cursor-not-allowed opacity-50",
          isIconOnly && "aspect-square"
        )}
        {...props}
      >
        {isLoading ? (
          <div className="flex items-center gap-2">
            {loadingIcon || (
              <span
                className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
                aria-hidden
              />
            )}
            <span>{loadingText || children}</span>
          </div>
        ) : (
          <div className="flex items-center gap-1">
            {badgeContent && badgePosition === "start" && (
              <span
                className={cn(
                  "inline-flex items-center justify-center px-2 py-0.5 text-xs font-semibold rounded-full bg-primary text-white mr-2",
                  badgeCustomClassname
                )}
              >
                {badgeContent}
              </span>
            )}
            {startContent && (
              <span className={cn(!isIconOnly && "mr-2")}>{startContent}</span>
            )}
            {children && <span>{children}</span>}
            {endContent && (
              <span className={cn(!isIconOnly && "ml-2")}>{endContent}</span>
            )}
            {badgeContent && badgePosition === "end" && (
              <span
                className={cn(
                  "inline-flex items-center justify-center px-2 py-0.5 text-xs font-semibold rounded-full bg-primary text-white ml-2",
                  badgeCustomClassname
                )}
              >
                {badgeContent}
              </span>
            )}
          </div>
        )}

        {ripples.map((r) => (
          <Ripple
            key={r.id}
            x={r.x}
            y={r.y}
            size={r.size}
            onComplete={() => removeRipple(r.id)}
          />
        ))}
      </Comp>
    );
  }
);

Button.displayName = "Button";

export { Button, buttonBaseVariants };

Default

A standard button component displaying a neutral primary action state.

Variants

Defines the visual appearance of buttons through the variant prop.

variant: default | bordered | light | flat | ghost | shadow | link

Colors

Defines the button color scheme through the color prop. Stacked vertically for clear visual comparison.

color: default | primary | secondary | accent | success | warning | danger
color="default"
color="primary"
color="secondary"
color="accent"
color="success"
color="warning"
color="danger"

Sizes

Adjusts the visual scale of buttons through the size prop.

size: xs | sm | md | lg | xl | 2xl | 3xl

Radius

Adjusts the border radius of buttons through the radius prop.

radius: none | xs | sm | md | lg | xl | 2xl | 3xl | full

Hovers

Controls micro-interaction motion on user hover using the hover prop (scale or lift).

hover: scale | lift

Icons

Adds icons to the button at the start or end position to enhance visual recognition.

startContent: ReactNodeendContent: ReactNode

Icon Only (isIconOnly)

Displays a compact button with only an icon. Mandatory ariaLabel ensures full accessibility.

isIconOnly: booleanariaLabel: string

Loading state

Displays an active loading spinner and disables user interaction during async processes.

isLoading: booleanloadingText: string

Disabled state

Disables the button, preventing interaction and applying muted opacity styling.

isDisabled: boolean
API Reference

Props — Button

Core properties for configuring the Button component.

PropTypeDefaultDescription
variant'default' | 'bordered' | 'light' | 'flat' | 'ghost' | 'shadow' | 'link''default'Visual style variant of the button.
color'default' | 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'danger''default'Color theme of the button.
size'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl''md'Controls size scale and density.
radius'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full''xl'Border radius scale.
hover'scale' | 'lift''scale'Micro-animation hover behavior.
isIconOnlybooleanfalseCompact icon-only button mode. Requires 'ariaLabel'.
isLoadingbooleanfalseShows loading spinner and disables interactions.
isDisabledbooleanfalseDisables user interaction.
disableRipplebooleanfalseDisables click ripple effect.