컴포넌트

Button

클릭하거나 키보드로 작업을 실행하는 버튼입니다.

설치

pnpm dlx ply-ui add button

공통 설치 설정을 먼저 적용합니다.

사용법

Button 안에 버튼 이름을 넣습니다. 별도의 자식 컴포넌트는 필요하지 않습니다. variant로 종류를, size로 크기를 지정합니다. 기본값은 emphasis와 lg입니다.

클릭 동작은 onClick으로 연결합니다. 폼을 제출할 때는 type="submit"을 지정합니다.

종류

variant는 버튼의 배경과 글자 색상을 바꿉니다. 동작과 크기는 바뀌지 않습니다.

Emphasis

variant="emphasis"는 강조 색상으로 주요 작업을 표시합니다. variant를 생략하면 적용됩니다.

Standard

variant="standard"는 중립 색상의 배경을 사용합니다. 강조 버튼과 함께 배치하는 일반 작업에 사용합니다.

Soft emphasis

variant="soft-emphasis"는 옅은 강조 색상의 배경을 사용합니다.

Sub emphasis

variant="sub-emphasis"는 현재 구현에서 over-media와 같은 색상 토큰을 사용합니다. 두 종류의 기본 외형은 같습니다.

Subtle

variant="subtle"는 기본 배경을 투명하게 표시합니다. 마우스를 올리거나 누르면 상태에 따른 배경이 나타납니다.

Utility

variant="utility"는 투명한 배경과 일반 작업의 글자 색상을 사용합니다. 현재 구현에서는 subtle과 배경을 공유하고 글자 색상 토큰을 따로 지정합니다.

Over media

variant="over-media"는 이미지나 영상 위에 배치할 때 사용합니다. 예제에서는 배경 위의 버튼을 확인할 수 있습니다.

Alert

variant="alert"는 삭제와 같은 위험한 작업을 경고 색상으로 표시합니다. 기본 글자색은 원본 Roblox 빨강입니다. 이 색은 작은 크기(sm·xs)에서 WCAG AA 대비(4.5:1)에 조금 못 미칩니다. 대비 기준을 지켜야 하면 html에 data-contrast="enhanced"를 넣어 더 짙은 빨강으로 바꿉니다. 오류 문구(Field.Error)도 함께 바뀝니다. 확인 대화상자는 별도로 구성해야 합니다.

variant="link"는 링크 색상의 글자를 사용합니다. 이 속성은 외형만 바꾸며, 버튼에 페이지 이동 기능을 추가하지 않습니다.

링크로 쓰기

다른 페이지로 이동하는 작업은 버튼 모양이어도 링크여야 합니다. render에 href가 있는 요소를 넘기면 Button은 버튼 동작(role="button", Space 키 실행)을 붙이지 않고 링크로 둡니다. nativeButton={false}는 지정하지 않아도 됩니다. Next.js의 Link처럼 href를 받는 컴포넌트도 같습니다.

링크는 비활성 상태가 없으므로 disabled는 적용되지 않습니다. 갈 수 없는 상태라면 링크 대신 비활성 버튼을 보여 줍니다.

크기

size로 높이를 지정합니다. 글자 크기와 좌우 여백도 함께 바뀝니다.

값높이
xs24px
sm32px
md40px
lg (기본값)48px

비활성

disabled를 지정하면 버튼을 누를 수 없고, 키보드의 탭 이동 대상에서도 제외됩니다.

로딩

loading을 지정하면 로딩 표시와 aria-busy를 적용하고 재실행을 막습니다. 버튼의 원래 작업 이름은 유지됩니다. 처리 상태는 사용하는 앱에서 관리합니다.

이 예제는 600ms 후 완료되는 동작을 사용합니다. 실제 사용 시 코드의 지연 처리를 API 호출로 교체합니다.

속성

이 컴포넌트에서 추가하거나 제한한 속성입니다. 공통 속성과 이벤트는 Base UI Button 문서를 참고하세요.

속성

타입

좁은 영역에서의 동작

로딩 중에는 읽기 이름과 내용의 공간을 유지하고 중앙에 Spinner를 표시합니다. 기본적으로 키보드 포커스는 유지하면서 재실행을 막습니다. 긴 이름은 주어진 너비 안에서 줄바꿈합니다.

구현 코드

components/ui/button.tsx
button.tsx
"use client";
import {
  cloneElement,
  isValidElement,
  type ComponentProps,
  type ReactNode,
} from "react";
import { useRender } from "@base-ui/react/use-render";
import { Button as Primitive } from "@base-ui/react/button";
import { withClassName } from "../../lib/cx";
import { Spinner } from "./spinner";
import "./button.css";

export type ButtonVariant =
  | "emphasis"
  | "standard"
  | "soft-emphasis"
  | "sub-emphasis"
  | "subtle"
  | "utility"
  | "over-media"
  | "alert"
  | "link";
export type ButtonProps = ComponentProps<typeof Primitive> & {
  /** 처리 중 표시를 켜고 재실행을 막습니다. @defaultValue false */
  loading?: boolean;
  /** 버튼의 색상과 강조 수준입니다. @defaultValue "emphasis" */
  variant?: ButtonVariant;
  /** 버튼 높이입니다. @defaultValue "lg" */
  size?: "xs" | "sm" | "md" | "lg";
};
/** variant는 색상 역할, size는 높이를 고릅니다. 원본 Base UI props/ref도 전달합니다. */
export function Button({
  variant = "emphasis",
  size = "lg",
  loading = false,
  disabled,
  children,
  className,
  render,
  focusableWhenDisabled,
  ...props
}: ButtonProps) {
  // href가 있는 요소는 버튼 동작(role="button", Space 실행)을 붙이지 않고 링크로 남깁니다.
  if (isValidElement<{ href?: unknown }>(render) && render.props.href != null) {
    const { nativeButton: _native, ...linkProps } = props;
    return (
      <ButtonLink
        props={{
          ...(linkProps as ComponentProps<"a">),
          "aria-busy": loading || props["aria-busy"] || undefined,
          "data-loading": loading || undefined,
          "data-variant": variant,
          "data-size": size,
          className: withClassName("rbx-button", className)({} as never),
          children,
        }}
        render={render}
        loading={loading}
      />
    );
  }
  return (
    <Primitive
      {...props}
      disabled={disabled || loading}
      focusableWhenDisabled={focusableWhenDisabled ?? loading}
      aria-busy={loading || props["aria-busy"]}
      data-loading={loading || undefined}
      data-variant={variant}
      data-size={size}
      className={withClassName("rbx-button", className)}
      render={(elementProps, state) => (
        <ButtonSurface
          elementProps={elementProps}
          state={state}
          render={render}
          loading={loading}
        />
      )}
    >
      {children}
    </Primitive>
  );
}

/** 합성한 링크의 children·ref도 최종 요소에서 보존하며, 로딩 표시가 버튼 폭을 바꾸지 않게 합니다. */
function ButtonSurface({
  elementProps,
  state,
  render,
  loading,
}: {
  elementProps: ComponentProps<"button">;
  state: Primitive.State;
  render: ButtonProps["render"];
  loading: boolean;
}) {
  const { ref, ...props } = elementProps;
  const element = useRender({
    defaultTagName: "button",
    render,
    ref,
    props,
    state: { ...state },
  });
  const children = (element.props as { children?: ReactNode }).children;
  return cloneElement(
    element,
    {},
    <>
      <span className="rbx-button-label">{children}</span>
      {loading && (
        <span className="rbx-button-loader" aria-hidden="true">
          <Spinner size="sm" aria-hidden="true" />
        </span>
      )}
    </>,
  );
}

function ButtonLink({
  props,
  render,
  loading,
}: {
  props: ComponentProps<"a"> & Record<`data-${string}`, unknown>;
  render: ButtonProps["render"];
  loading: boolean;
}) {
  const { children, ref, ...rest } = props;
  return useRender({
    defaultTagName: "a",
    render: render as Parameters<typeof useRender>[0]["render"],
    ref,
    props: {
      ...rest,
      children: (
        <>
          <span className="rbx-button-label">{children}</span>
          {loading && (
            <span className="rbx-button-loader" aria-hidden="true">
              <Spinner size="sm" aria-hidden="true" />
            </span>
          )}
        </>
      ),
    },
  });
}
components/ui/button.css
button.css
@layer theme, base, components, utilities;
@layer components.ply-base, components.ply;

@layer components.ply {
  /* Roblox의 action 토큰을 variant에 직접 연결합니다. */
  .rbx-button[data-variant="emphasis"] {
    background-color: var(--rbx-color-action-emphasis-background);
    color: var(--rbx-color-action-emphasis-foreground);
    border-color: var(--rbx-color-action-emphasis-border);
  }
  .rbx-button[data-variant="standard"] {
    background-color: var(--rbx-color-action-standard-background);
    color: var(--rbx-color-action-standard-foreground);
    border-color: var(--rbx-color-action-standard-border);
  }
  .rbx-button[data-variant="soft-emphasis"] {
    background-color: var(--rbx-color-action-soft-emphasis-background);
    color: var(--rbx-color-action-soft-emphasis-foreground);
    border-color: var(--rbx-color-action-soft-emphasis-border);
  }
  .rbx-button[data-variant="sub-emphasis"] {
    background-color: var(--rbx-color-action-sub-emphasis-background);
    color: var(--rbx-color-action-sub-emphasis-foreground);
    border-color: var(--rbx-color-action-sub-emphasis-border);
  }
  .rbx-button[data-variant="subtle"] {
    background-color: var(--rbx-color-action-subtle-background);
    color: var(--rbx-color-action-subtle-foreground);
    border-color: var(--rbx-color-action-subtle-border);
  }
  .rbx-button[data-variant="utility"] {
    background-color: var(--rbx-color-action-utility-background);
    color: var(--rbx-color-action-utility-foreground);
    border-color: var(--rbx-color-action-utility-border);
  }
  .rbx-button[data-variant="over-media"] {
    background-color: var(--rbx-color-action-over-media-background);
    color: var(--rbx-color-action-over-media-foreground);
    border-color: var(--rbx-color-action-over-media-border);
  }
  .rbx-button[data-variant="alert"] {
    background-color: var(--rbx-color-action-alert-background);
    color: var(--rbx-color-action-alert-foreground);
    border-color: var(--rbx-color-action-alert-border);
  }
  .rbx-button[data-variant="link"] {
    background-color: var(--rbx-color-action-link-background);
    color: var(--rbx-color-action-link-foreground);
    border-color: var(--rbx-color-action-link-border);
  }
  /* Foundation Web size map: label line-height is 100%, not body text's 140%. */
  .rbx-button {
    height: auto;
    min-height: 48px;
    padding: 0 12px;
    font: var(--rbx-typography-label-large-font);
    white-space: normal;
    overflow-wrap: anywhere;
    word-break: keep-all;
    min-width: 0;
    max-width: 100%;
    position: relative;
    padding-block: 8px;
  }
  .rbx-button[data-size="md"] {
    height: auto;
    min-height: 40px;
    font: var(--rbx-typography-label-medium-font);
  }
  .rbx-button[data-size="sm"] {
    height: auto;
    min-height: 32px;
    padding: 0 8px;
    gap: 4px;
    padding-block: 4px;
    font: var(--rbx-typography-label-small-font);
  }
  .rbx-button[data-size="xs"] {
    height: auto;
    min-height: 24px;
    padding: 0 8px;
    gap: 4px;
    font: var(--rbx-typography-label-small-font);
    border-radius: 4px;
  }
  .rbx-button[data-variant="utility"] {
    background-color: var(--rbx-color-action-subtle-background);
    color: var(--rbx-color-action-standard-foreground);
  }
  .rbx-button[data-variant="link"] {
    text-decoration: none;
    color: var(--rbx-color-content-link-action);
  }
  /* 원본은 비활성 강조 버튼을 standard 역할로 바꾼 뒤 opacity 0.5를 적용합니다. */
  .rbx-button:is(:disabled, [data-disabled]):is(
      [data-variant="emphasis"],
      [data-variant="standard"],
      [data-variant="soft-emphasis"],
      [data-variant="alert"]
    ) {
    background-color: var(--rbx-color-action-standard-background);
    color: var(--rbx-color-action-standard-foreground);
  }

  /* 내용의 공간과 읽기 이름은 남기고 로딩 표시만 중앙에 겹칩니다. */
  .rbx-button-label {
    display: inline-flex;
    align-items: center;
    justify-content: inherit;
    gap: inherit;
    min-width: 0;
    overflow-wrap: anywhere;
  }
  .rbx-button[data-loading] > .rbx-button-label {
    opacity: 0;
  }
  .rbx-button-loader {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    pointer-events: none;
  }

  .rbx-button {
    inline-size: fit-content;
    flex-shrink: 0;
  }
}

목차