글 · 시리즈 TODAYIT 프론트엔드 개발 · 1번째 글 / 총 1편

버튼 모양의 링크를 ButtonLink 컴포넌트로 분리한 이유

2026.09.28

프로젝트를 하며 공용 버튼을 만들면서 화면 이동용 링크에도 같은 디자인이 필요해졌다. 예를 들면 저장 버튼은 현재 화면에서 작업을 실행하고 설정 페이지로 이동하는 링크는 주소를 바꾼다.

같은 디자인을 유지하면서 두 역할을 사용 코드에서도 구분하려면 어떻게 구성하는 게 좋을까. 이 프로젝트에서는 작업 실행에 Button을, 화면 이동에 ButtonLink를 사용하기로 했다. 두 컴포넌트가 외형을 공유하는 방법과 링크의 비활성 상태를 처리하며 정한 기준을 정리했다.

같은 디자인을 두 컴포넌트로 나눈 이유

공용 Button에는 Base UI의 Button을 사용했다. ButtonLink에는 Next.js의 Link를 적용했다. Next.js의 Link는 실제 HTML 앵커인 <a>를 렌더링한다. 링크의 href와 탐색 의미를 유지하면서 버튼의 시각적 스타일을 적용할 수 있다. (Next.js Link 문서)

import { Button as ButtonPrimitive } from '@base-ui/react/button';
import Link from 'next/link';

// 두 컴포넌트의 스타일과 나머지 props는 생략했다.
function Button() {
  return <ButtonPrimitive>저장</ButtonPrimitive>;
}

function ButtonLink() {
  return <Link href="/settings">설정으로 이동</Link>;
}

Base UI의 Button은 render로 다른 태그를 렌더링할 수 있지만, 태그가 바뀌어도 버튼 의미와 키보드 동작을 유지한다. nativeButton={false}를 주면 <div> 같은 요소를 버튼처럼 쓸 수 있다. 따라서 링크(<a>)를 렌더링하는 용도로 Button을 쓰지는 않는다. Base UI도 링크에는 <a> 자체에 스타일을 적용하라고 안내한다. (Button 문서, 조합 가이드)

<Button render={<div />} nativeButton={false}>
  복합 콘텐츠를 담는 버튼
</Button>

버튼과 링크를 한 컴포넌트로 만들 수도 있다. 이번에는 공용 Button을 버튼 동작에 한정하고 화면 이동에는 ButtonLink를 뒀다. 컴포넌트 이름과 API에서 역할이 드러나도록 Button의 공개 타입에서 Base UI의 render와 nativeButton을 제외했다. 아래는 이 두 prop을 제외하는 타입 정의 부분이다.

import { Button as ButtonPrimitive } from '@base-ui/react/button';
import type { VariantProps } from 'class-variance-authority';

import { type ButtonSize, buttonVariants } from './button-variants';

export interface ButtonProps
  extends
    Omit<ButtonPrimitive.Props, 'render' | 'nativeButton'>,
    VariantProps<typeof buttonVariants> {
  size?: ButtonSize;
}

사용하는 쪽은 요소를 바꾸는 옵션을 알 필요가 없다. 화면 이동에는 href를 받는 ButtonLink, 버튼 동작에는 Button을 고르면 된다.

대신 공용 컴포넌트의 API가 하나 더 늘었다. 디자인 규격을 바꿀 때도 두 컴포넌트를 함께 확인해야 한다. 이번에는 버튼과 이동 링크의 역할을 사용 코드에서 구분하는 쪽을 택하고 외형이 어긋나지 않도록 기본 스타일과 크기를 공유했다.

disabled를 받는 링크는 무엇을 막아야 할까

HTML의 disabled 속성은 버튼에 적용된다. 링크를 비활성화하려면 상태 전달과 기본 이동 취소를 직접 처리해야 한다. 그래서 ButtonLink의 disabled 옵션에 어떤 동작을 담을지 정해야 했다.

현재 구현은 비활성 링크에 aria-disabled를 전달하고 클릭 이벤트에서 preventDefault()로 기본 동작을 취소한다. tabIndex={-1}와 CSS의 pointer-events-none은 Tab 순서와 포인터 입력을 막는다. Next.js Link의 자동 미리 가져오기도 prefetch={false}로 끈다.

아래는 ButtonLink에서 비활성 동작을 처리하는 부분이다. 스타일, 크기, 아이콘 렌더링은 생략했다.

'use client';

import Link from 'next/link';
import type { ComponentProps, MouseEvent } from 'react';

export interface ButtonLinkProps extends ComponentProps<typeof Link> {
  disabled?: boolean;
}

export function ButtonLink({
  disabled = false,
  onClick,
  prefetch,
  tabIndex,
  ...props
}: ButtonLinkProps) {
  function handleClick(event: MouseEvent<HTMLAnchorElement>) {
    if (disabled) {
      event.preventDefault();
      return;
    }

    onClick?.(event);
  }

  return (
    <Link
      aria-disabled={disabled || undefined}
      onClick={handleClick}
      prefetch={disabled ? false : prefetch}
      tabIndex={disabled ? -1 : tabIndex}
      {...props}
    />
  );
}

aria-disabled는 보조 기술에 비활성 상태를 전달한다. 실제 링크의 기본 이동은 클릭 이벤트에서 취소해야 한다.

이 구현은 비활성 링크를 Tab 순서에서 제외한다. 키보드 사용자가 비활성 항목도 발견해야 하는 화면이라면 포커스에 남길지 따로 판단해야 한다. 클릭 취소와 별개로 목적지 자체가 없는 상태라면 링크를 렌더링할지부터 정해야 한다. href는 링크에 그대로 남는다.

네이티브 버튼의 disabled처럼 브라우저가 처리해 주는 범위를 기대해서는 안 된다. 버튼과 같은 비활성 디자인을 ButtonLink에서도 제공하려고 disabled prop을 별도로 정의했다. 그 결과 링크의 비활성 동작과 포커스 정책도 공용 컴포넌트에서 관리하게 됐다.

외형은 공유하고 상태 스타일은 나누기

스타일을 분리하기 전 ButtonLink는 Button의 buttonVariants에 링크용 상태 클래스를 덧붙였다. 아래는 해당 구현의 className 부분이다.

className={cn(
  buttonVariants({ size }),
  'hover:bg-state-inverse-hover active:bg-state-inverse-pressed aria-disabled:pointer-events-none aria-disabled:cursor-default aria-disabled:bg-state-inverse-disabled aria-disabled:[color:var(--td-color-text-muted)]',
  className
)}

buttonVariants에는 버튼의 enabled:와 disabled: 선택자가 들어 있었다. 링크는 aria-disabled를 기준으로 비활성 상태를 표현하므로 링크용 클래스를 별도로 추가해야 했다. 이 두 규칙을 같은 className에서 조합하는 대신 요소별 variant로 나눴다.

정렬, 모서리, 포커스 표시와 크기는 계속 공유했다. 배경색과 hover, active, disabled 선택자는 각 요소의 variant에 뒀다.

아래는 현재 button-variants.ts 구현에서 상태 스타일을 나눈 부분이다. 기본 스타일과 크기별 클래스 정의는 생략했다. Button의 색상 variant는 버튼 쪽에 두고, 두 컴포넌트가 크기 설정을 공유한다.

const buttonVariantConfig = {
  variants: {
    size: buttonSizeVariants,
  },
  defaultVariants: {
    size: 'lg' as const,
  },
};

export const buttonVariants = cva(
  `${buttonBaseStyles} disabled:pointer-events-none disabled:bg-state-inverse-disabled disabled:[color:var(--td-color-text-muted)]`,
  {
    variants: {
      size: buttonSizeVariants,
      variant: {
        primary:
          'bg-surface-inverse [color:var(--td-color-text-inverse)] enabled:hover:bg-state-inverse-hover enabled:active:bg-state-inverse-pressed',
        cta: 'bg-surface-cta [color:var(--td-color-text-primary)] enabled:hover:bg-state-cta-hover enabled:active:bg-state-cta-pressed',
      },
    },
    defaultVariants: { size: 'lg', variant: 'primary' },
  },
);

export const buttonLinkVariants = cva(
  `${buttonBaseStyles} bg-surface-inverse [color:var(--td-color-text-inverse)] hover:bg-state-inverse-hover active:bg-state-inverse-pressed aria-disabled:pointer-events-none aria-disabled:cursor-default aria-disabled:bg-state-inverse-disabled aria-disabled:[color:var(--td-color-text-muted)]`,
  buttonVariantConfig,
);

공통 기본 스타일과 크기는 한곳에서 관리했다. 상태 규칙은 각 variant에 뒀다. 두 컴포넌트는 같은 크기를 쓰면서도 요소별 상태를 유지한다.

상태표로 외형을 비교할 때 확인할 범위

공통 스타일을 바꾸면 버튼과 링크가 함께 영향을 받는다. 크기를 공유하더라도 비활성 선택자가 다르므로 각 요소의 상태를 함께 확인할 수 있어야 했다.

button-link.stories.tsx의 StateMatrix는 lg, md, sm별로 기본, hover, pressed, disabled 외형을 나란히 보여준다. hover와 pressed 칸은 배경색 클래스를 고정해서 표시한다. 이 표에서 비교하는 것은 상태별 외형이다. 아이콘 사용과 비활성 상태도 별도 스토리로 확인한다.

lg, md, sm 크기의 ButtonLink 기본, hover, pressed, disabled 상태 비교 표
실제 Storybook의 StateMatrix 스토리. hover와 pressed는 상태 클래스를 적용해 외형을 비교한다.

disabled일 때 이동이 취소되는지, 활성 링크가 정상적으로 이동하는지, 선택한 포커스 정책이 화면에 맞는지는 실제 조작으로 따로 확인해야 한다. 실제 포인터로 hover와 클릭을 일으키거나 Tab으로 탐색하는 동작은 이 상태표만으로 검증되지 않는다.

역할을 나누며 함께 정한 기준

이제 사용하는 쪽에서는 작업 실행에 Button을, 화면 이동에 ButtonLink를 고른다. 기본 스타일과 크기를 한곳에서 관리하면서 요소별 상태 선택자와 동작은 각 컴포넌트에 뒀다.

ButtonLink를 별도로 두더라도 링크의 비활성 동작과 키보드 포커스 정책은 화면의 요구에 맞게 정해야 한다. 이 프로젝트에서는 두 역할을 컴포넌트 이름으로 구분하는 편이 사용 코드에 잘 맞았다. 하나의 컴포넌트에서 렌더링 요소를 바꿔야 하는 요구가 있다면 Base UI의 조합 API도 선택지가 된다.

시리즈 · TODAYIT 프론트엔드 개발 · 1편

목록으로 돌아가기