Button

Trigger an action with multiple variants and sizes.

Installation

$npx @309-thingspire/ui@latest add button

Usage

import { Button } from "@/components/button/button"
<Button />

Examples

Live preview rendered from Button.preview.tsx. Switch to the Code tab to view the underlying component source.

Loading preview…
import React, { useMemo, useState } from 'react';

import { border, colors, radius, shadows, spacing, typography } from '../../style-tokens';

import type { ButtonProps, ButtonShape, ButtonSize, ButtonVariant } from './Button.types';

type InteractionState = 'default' | 'hover' | 'focus' | 'disabled';

type StyleState = {
  backgroundColor: string;
  borderColor: string;
  textColor: string;
  boxShadow: string;
};

const buttonBackgroundTokens = colors.semantic.theme.background.button;
const buttonBorderTokens = border.color.theme.action;
const textBaseTokens = colors.semantic.theme.text.base;
const textStatusTokens = colors.semantic.theme.text.status;

const SIZE_CONFIG: Record<
  ButtonSize,
  {
    minHeight: number;
    defaultPaddingX: number;
    defaultPaddingY: number;
    defaultGap: number;
    iconOnlyPadding: number;
    iconOnlyGap: number;
    iconSize: number;
    roundedRadius: number;
    typographyStyle: {
      fontFamily: string;
      fontSize: number;
      fontWeight: number;
      lineHeight: number;
      letterSpacing: number;
    };
  }
> = {
  xs: {
    minHeight: spacing.scale['24'],
    defaultPaddingX: spacing.scale['8'],
    defaultPaddingY: spacing.scale['4'],
    defaultGap: spacing.scale['4'],
    iconOnlyPadding: spacing.scale['5'],
    iconOnlyGap: spacing.scale['0'],
    iconSize: spacing.scale['24'],
    roundedRadius: radius.scale.md,
    typographyStyle: typography.scale.captionM.medium,
  },
  sm: {
    minHeight: spacing.scale['32'],
    defaultPaddingX: spacing.scale['10'],
    defaultPaddingY: spacing.scale['6'],
    defaultGap: spacing.scale['2'],
    iconOnlyPadding: spacing.scale['8'],
    iconOnlyGap: spacing.scale['2'],
    iconSize: spacing.scale['24'],
    roundedRadius: radius.scale.lg,
    typographyStyle: typography.scale.captionL.medium,
  },
  md: {
    minHeight: spacing.scale['40'],
    defaultPaddingX: spacing.scale['12'],
    defaultPaddingY: spacing.scale['10'],
    defaultGap: spacing.scale['4'],
    iconOnlyPadding: spacing.scale['10'],
    iconOnlyGap: spacing.scale['4'],
    iconSize: spacing.scale['24'],
    roundedRadius: radius.scale.xl,
    typographyStyle: typography.scale.captionL.medium,
  },
  lg: {
    minHeight: spacing.scale['48'],
    defaultPaddingX: spacing.scale['16'],
    defaultPaddingY: spacing.scale['12'],
    defaultGap: spacing.scale['4'],
    iconOnlyPadding: spacing.scale['14'],
    iconOnlyGap: spacing.scale['4'],
    iconSize: spacing.scale['24'],
    roundedRadius: radius.scale.xl,
    typographyStyle: typography.scale.bodyL.medium,
  },
};

const DESTRUCTIVE_VARIANTS: ReadonlySet<ButtonVariant> = new Set([
  'destructive',
  'destructiveSecondary',
  'destructiveTertiary',
  'destructiveGhost',
]);

// Ghost variants render without a visible resting border per the Figma
// carbonscope spec — only the focus ring color is allowed to surface so
// keyboard focus stays accessible.
const GHOST_VARIANTS: ReadonlySet<ButtonVariant> = new Set([
  'ghost',
  'destructiveGhost',
]);

const STATE_COLOR_MAP: Record<ButtonVariant, { default: string; hover: string; disabled: string }> = {
  primary: {
    default: buttonBackgroundTokens.primary,
    hover: buttonBackgroundTokens.primaryHover,
    disabled: buttonBackgroundTokens.primaryDisabled,
  },
  secondary: {
    default: buttonBackgroundTokens.secondary,
    hover: buttonBackgroundTokens.secondaryHover,
    disabled: buttonBackgroundTokens.secondaryDisabled,
  },
  tertiary: {
    default: buttonBackgroundTokens.tertiary,
    hover: buttonBackgroundTokens.tertiaryHover,
    disabled: buttonBackgroundTokens.tertiaryDisabled,
  },
  ghost: {
    default: buttonBackgroundTokens.ghost,
    hover: buttonBackgroundTokens.ghostHover,
    disabled: buttonBackgroundTokens.ghostDisabled,
  },
  destructive: {
    default: buttonBackgroundTokens.destructive,
    hover: buttonBackgroundTokens.destructiveHover,
    disabled: buttonBackgroundTokens.destructiveDisabled,
  },
  destructiveSecondary: {
    default: buttonBackgroundTokens.destructiveSecondary,
    hover: buttonBackgroundTokens.destructiveSecondaryHover,
    disabled: buttonBackgroundTokens.destructiveSecondaryDisabled,
  },
  destructiveTertiary: {
    default: buttonBackgroundTokens.destructiveTertiary,
    hover: buttonBackgroundTokens.destructiveTertiaryHover,
    disabled: buttonBackgroundTokens.destructiveTertiaryDisabled,
  },
  destructiveGhost: {
    default: buttonBackgroundTokens.destructiveGhost,
    hover: buttonBackgroundTokens.destructiveGhostHover,
    disabled: buttonBackgroundTokens.destructiveGhostDisabled,
  },
};

const STATE_TEXT_MAP: Record<ButtonVariant, { default: string; hover: string; disabled: string }> = {
  primary: {
    default: textBaseTokens.inverted,
    hover: textBaseTokens.inverted,
    disabled: textBaseTokens.invertedSecondary,
  },
  secondary: {
    default: textBaseTokens.primary,
    hover: textBaseTokens.primary,
    disabled: textBaseTokens.secondary,
  },
  tertiary: {
    default: textBaseTokens.primary,
    hover: textBaseTokens.primary,
    disabled: textBaseTokens.secondary,
  },
  ghost: {
    default: textBaseTokens.primary,
    hover: textBaseTokens.primary,
    disabled: textBaseTokens.secondary,
  },
  destructive: {
    default: textBaseTokens.staticWhite,
    hover: textBaseTokens.staticWhite,
    disabled: textBaseTokens.staticWhiteSecondary,
  },
  destructiveSecondary: {
    default: textStatusTokens.destructive,
    hover: textStatusTokens.destructive,
    disabled: textStatusTokens.destructiveSecondary,
  },
  destructiveTertiary: {
    default: textStatusTokens.destructive,
    hover: textStatusTokens.destructive,
    disabled: textStatusTokens.destructiveSecondary,
  },
  destructiveGhost: {
    default: textStatusTokens.destructive,
    hover: textStatusTokens.destructive,
    disabled: textStatusTokens.destructiveSecondary,
  },
};

function getBorderColor(variant: ButtonVariant, interactionState: InteractionState): string {
  const destructive = DESTRUCTIVE_VARIANTS.has(variant);

  if (interactionState === 'focus') {
    return destructive ? buttonBorderTokens.focusDestructive : buttonBorderTokens.focus;
  }

  // Ghost variants are intentionally borderless in default/hover/disabled
  // states. Focus ring above already short-circuited so a11y is preserved.
  if (GHOST_VARIANTS.has(variant)) {
    return 'transparent';
  }

  if (interactionState === 'hover') {
    return destructive ? buttonBorderTokens.destructiveHover : buttonBorderTokens.hover;
  }

  if (interactionState === 'disabled') {
    return destructive ? buttonBorderTokens.destructiveDisabled : buttonBorderTokens.disabled;
  }

  return destructive ? buttonBorderTokens.destructive : buttonBorderTokens.normal;
}

function getFocusRing(variant: ButtonVariant, interactionState: InteractionState): string {
  if (interactionState !== 'focus') {
    return 'none';
  }

  return DESTRUCTIVE_VARIANTS.has(variant) ? shadows.focusRing.lightDestructive.css : shadows.focusRing.light.css;
}

function getResolvedInteractionState(
  disabled: boolean,
  forceState: ButtonProps['forceState'] | undefined,
  hovered: boolean,
  focused: boolean,
): InteractionState {
  if (disabled) {
    return 'disabled';
  }

  if (forceState === 'focus') {
    return 'focus';
  }

  if (forceState === 'hover') {
    return 'hover';
  }

  if (focused) {
    return 'focus';
  }

  if (hovered) {
    return 'hover';
  }

  return 'default';
}

function getVisualState(variant: ButtonVariant, interactionState: InteractionState): StyleState {
  const background = STATE_COLOR_MAP[variant];
  const text = STATE_TEXT_MAP[variant];

  if (interactionState === 'disabled') {
    return {
      backgroundColor: background.disabled,
      borderColor: getBorderColor(variant, interactionState),
      textColor: text.disabled,
      boxShadow: getFocusRing(variant, interactionState),
    };
  }

  if (interactionState === 'hover') {
    return {
      backgroundColor: background.hover,
      borderColor: getBorderColor(variant, interactionState),
      textColor: text.hover,
      boxShadow: getFocusRing(variant, interactionState),
    };
  }

  if (interactionState === 'focus') {
    return {
      backgroundColor: background.default,
      borderColor: getBorderColor(variant, interactionState),
      textColor: text.default,
      boxShadow: getFocusRing(variant, interactionState),
    };
  }

  return {
    backgroundColor: background.default,
    borderColor: getBorderColor(variant, interactionState),
    textColor: text.default,
    boxShadow: getFocusRing(variant, interactionState),
  };
}

function getBorderRadius(shape: ButtonShape, roundedRadius: number): number {
  if (shape === 'pill') {
    return radius.scale.full;
  }

  return roundedRadius;
}

export function Button({
  variant = 'primary',
  size = 'md',
  type = 'default',
  shape = 'rounded',
  htmlType = 'button',
  forceState,
  leftIcon,
  rightIcon,
  badge,
  fullWidth = false,
  disabled = false,
  style,
  children,
  onMouseEnter,
  onMouseLeave,
  onFocus,
  onBlur,
  ...props
}: ButtonProps) {
  const [hovered, setHovered] = useState(false);
  const [focused, setFocused] = useState(false);

  const sizeConfig = SIZE_CONFIG[size];
  const iconOnly = type === 'iconOnly';
  const lead = leftIcon ?? rightIcon;
  const gap = iconOnly ? sizeConfig.iconOnlyGap : sizeConfig.defaultGap;
  const paddingInline = iconOnly ? sizeConfig.iconOnlyPadding : sizeConfig.defaultPaddingX;
  const paddingBlock = iconOnly ? sizeConfig.iconOnlyPadding : sizeConfig.defaultPaddingY;
  const borderRadius = getBorderRadius(shape, sizeConfig.roundedRadius);

  const interactionState = getResolvedInteractionState(disabled, forceState, hovered, focused);
  const visualState = useMemo(() => getVisualState(variant, interactionState), [variant, interactionState]);

  return (
    <button
      type={htmlType}
      disabled={disabled}
      onMouseEnter={(event) => {
        if (!disabled) {
          setHovered(true);
        }
        onMouseEnter?.(event);
      }}
      onMouseLeave={(event) => {
        if (!disabled) {
          setHovered(false);
        }
        onMouseLeave?.(event);
      }}
      onFocus={(event) => {
        if (!disabled) {
          setFocused(true);
        }
        onFocus?.(event);
      }}
      onBlur={(event) => {
        if (!disabled) {
          setFocused(false);
        }
        onBlur?.(event);
      }}
      style={{
        display: 'inline-flex',
        alignItems: 'center',
        justifyContent: 'center',
        gap,
        minHeight: sizeConfig.minHeight,
        width: fullWidth ? '100%' : 'fit-content',
        paddingInline,
        paddingBlock,
        borderStyle: 'solid',
        borderWidth: border.width['1'],
        borderRadius,
        backgroundColor: visualState.backgroundColor,
        borderColor: visualState.borderColor,
        color: visualState.textColor,
        boxShadow: visualState.boxShadow,
        fontFamily: sizeConfig.typographyStyle.fontFamily,
        fontWeight: sizeConfig.typographyStyle.fontWeight,
        fontSize: sizeConfig.typographyStyle.fontSize,
        lineHeight: `${sizeConfig.typographyStyle.lineHeight}px`,
        letterSpacing: `${sizeConfig.typographyStyle.letterSpacing}px`,
        textDecoration: 'none',
        whiteSpace: 'nowrap',
        cursor: disabled ? 'not-allowed' : 'pointer',
        userSelect: 'none',
        outline: 'none',
        appearance: 'none',
        WebkitTapHighlightColor: 'transparent',
        ...style,
      }}
      {...props}
    >
      {!iconOnly && leftIcon ? (
        <span
          aria-hidden="true"
          style={{
            width: sizeConfig.iconSize,
            height: sizeConfig.iconSize,
            display: 'inline-flex',
            alignItems: 'center',
            justifyContent: 'center',
            flexShrink: 0,
          }}
        >
          {leftIcon}
        </span>
      ) : null}

      {!iconOnly ? <span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>{children}</span> : null}

      {!iconOnly && badge ? (
        <span
          style={{
            display: 'inline-flex',
            alignItems: 'center',
            justifyContent: 'center',
            flexShrink: 0,
          }}
        >
          {badge}
        </span>
      ) : null}

      {!iconOnly && rightIcon ? (
        <span
          aria-hidden="true"
          style={{
            width: sizeConfig.iconSize,
            height: sizeConfig.iconSize,
            display: 'inline-flex',
            alignItems: 'center',
            justifyContent: 'center',
            flexShrink: 0,
          }}
        >
          {rightIcon}
        </span>
      ) : null}

      {iconOnly && lead ? (
        <span
          aria-hidden="true"
          style={{
            width: sizeConfig.iconSize,
            height: sizeConfig.iconSize,
            display: 'inline-flex',
            alignItems: 'center',
            justifyContent: 'center',
            flexShrink: 0,
          }}
        >
          {lead}
        </span>
      ) : null}
    </button>
  );
}

export default Button;

API Reference

Props 문서 준비 중입니다. (component-spec.json 추가 시 표시됩니다.)