Bloom Logo

Button

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

importimport { Button } from "@/components/ui/button/button";
$npx @bloomui-react/cli add button
button.tsx
"use client";

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

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;
    isFullWidth?: boolean;
    startContent?: React.ReactNode;
    endContent?: React.ReactNode;
    badgeContent?: string;
    badgePosition?: "start" | "end";
    badgeCustomClassname?: string;
    hover?: ButtonHover;
    size?: ButtonSize;
    color?: ButtonColor;
    radius?: ButtonRadius;
    disableRipple?: boolean;
    isCopy?: boolean;
    copyText?: string;
  };

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 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background 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,
      isFullWidth = 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,
      isCopy = false,
      copyText,
      ...props
    },
    ref,
  ) => {
    const Comp = asChild ? Slot : "button";
    const { ripples, addRipple, removeRipple } = useRipples();

    const isEffectivelyDisabled = isDisabled || disabled;
    const nativeDisabled = !asChild ? isEffectivelyDisabled : undefined;
    const ariaDisabled = isEffectivelyDisabled || isLoading || undefined;

    const [copied, setCopied] = React.useState(false);

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

        if (isCopy) {
          const textToCopy =
            copyText || (typeof children === "string" ? children : "");
          if (textToCopy) {
            navigator.clipboard.writeText(textToCopy);
            setCopied(true);
            setTimeout(() => setCopied(false), 2000);
          }
        }

        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);
      },
      [
        isEffectivelyDisabled,
        isLoading,
        isCopy,
        copyText,
        children,
        disableRipple,
        addRipple,
        onClick,
      ],
    );

    const activeVariant = variant || "default";

    const [drawCheckmark, setDrawCheckmark] = React.useState(false);

    React.useEffect(() => {
      if (copied) {
        const timeout = setTimeout(() => setDrawCheckmark(true), 50);
        return () => clearTimeout(timeout);
      }
      setDrawCheckmark(false);
    }, [copied]);

    const displayedStartContent = isCopy ? (
      copied ? (
        <svg
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="3.5"
          strokeLinecap="round"
          strokeLinejoin="round"
          className="size-3.5 text-emerald-400"
        >
          <path
            d="M20 6L9 17L4 12"
            style={{
              strokeDasharray: 22,
              strokeDashoffset: drawCheckmark ? 0 : 22,
              transition: "stroke-dashoffset 250ms ease-out",
            }}
          />
        </svg>
      ) : (
        startContent || <Icon icon="hugeicons:copy-01" className="size-3.5" />
      )
    ) : (
      startContent
    );

    const displayedChildren = isCopy
      ? copied
        ? "Copied"
        : children || "Copy"
      : children;

    return (
      <Comp
        ref={ref}
        type={type ?? "button"}
        disabled={nativeDisabled}
        aria-disabled={ariaDisabled}
        aria-busy={isLoading || undefined}
        aria-label={ariaLabel || undefined}
        tabIndex={asChild && isEffectivelyDisabled ? -1 : undefined}
        onClick={handleClick}
        className={cn(
          buttonBaseVariants({ size, variant, radius, hover }),
          designColors[color][activeVariant],
          className,
          "cursor-pointer relative overflow-hidden",
          isFullWidth && "w-full flex flex-1 justify-center",
          isLoading && "cursor-wait opacity-50",
          isEffectivelyDisabled && "cursor-not-allowed opacity-50",
          asChild && isEffectivelyDisabled && "pointer-events-none",
          isIconOnly && "aspect-square",
        )}
        {...props}
      >
        {isLoading ? (
          <div className="flex items-center gap-2">
            {loadingIcon ? (
              <span aria-hidden="true">{loadingIcon}</span>
            ) : (
              <span
                role="status"
                className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
                aria-label="Loading"
              />
            )}
            <span>{loadingText || displayedChildren}</span>
            <span className="sr-only" aria-live="polite" aria-atomic="true">
              {loadingText ?? "Loading, please wait"}
            </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,
                )}
                aria-hidden="true"
              >
                {badgeContent}
              </span>
            )}
            {displayedStartContent && (
              <span className={cn(!isIconOnly && "mr-2")} aria-hidden="true">
                {displayedStartContent}
              </span>
            )}
            {displayedChildren && <span>{displayedChildren}</span>}
            {endContent && (
              <span className={cn(!isIconOnly && "ml-2")} aria-hidden="true">
                {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,
                )}
                aria-hidden="true"
              >
                {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. Each color is shown across all seven variants for a complete reference.

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

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

Full Width

Expands the button to span 100% of its parent container width.

isFullWidth: boolean

Disable Ripple

Set disableRipple to remove the click ripple effect. Useful for icon-only buttons or when integrating with custom interaction feedback.

disableRipple: boolean

Copy Button

A specialized button type configured by passing the isCopy prop. When clicked, it copies the string text (either from its copyText property or directly from its text children) and transitions to showing a smooth animated checkmark and a Copied state.

isCopy: booleancopyText: string

Disabled State

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

isDisabled: boolean

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.
isCopybooleanfalseEnables copying functionality and animation.
copyTextstringThe specific text to be copied to the clipboard. Defaults to children.