컴포넌트에 인터렉션 레이어 이쁘게 씌우기

조회수 ...

프롤로그

디자인 시스템에서 중요한 목표 중 하나는 **일관된 사용자 경험(UX)**을 제공하는 것입니다. UI 컴포넌트(버튼, 카드 등)마다 hover, focus, press와 같은 다양한 인터랙션 상태가 있고, 각 상태에서 색상이 어떻게 변화하는지가 사용성에 큰 영향을 미칩니다. 이번 글에서는 디자인 요구사항에 맞춰, **가상 요소(::after)**를 활용해 인터랙션 상태를 관리하는 방법을 소개합니다. 특히 클릭 이벤트가 손상되지 않으면서도 원하는 컬러 오버레이를 깔끔히 적용하는 과정을 정리해보았습니다.

구현된 컴포넌트를 볼 수 있는 곳


문제 상황

디자인 시스템을 구현하던 도중, 다음과 같은 점이 문제로 떠올랐습니다:

1. 버튼 및 기타 UI 요소의 상태별 색상을 세밀하게 조정해야 한다.

  • 예시: hover 시 5% 밝아진 색상, focus 시 진한 색상, press 시 그보다 더 진한 색상 등.

2. 초기 시도

  • opacity를 활용한 색상 변경<span style="color:red;">(실패)</span>
    • 배경 위에 불투명도만 조절하면, 원하는 컬러가 “투명하게” 섞이면서 의도한 정확한 컬러가 표현되지 않았습니다.
  • div를 씌워 색상 변경<span style="color:red;">(실패)</span>
    • 클릭 이벤트가 상위로 전파되지 않는 등, 상호작용 측면에서 예상치 못한 버그가 발생했습니다.
    • pointer-events 등을 조절할 수는 있었으나, 구조가 복잡해져서 유지보수하기 어렵다고 판단했습니다.

3. 최종 해결 - 가상 요소 ::after를 이용한 오버레이

UI 이벤트가 원본 엘리먼트(버튼 등)에 정상적으로 전달되면서도, 원하는 컬러 레이어를 간단히 덧씌울 수 있었습니다. 디자인 요구사항(hover, focus, press 등)도 정확히 반영할 수 있었고, 재사용하기도 좋아 최종 방식으로 채택하였습니다.


해결 방법 : 인터렉션 컴포넌트 설계

이 문제를 해결하기 위해 재사용 가능한 인터랙션 컴포넌트를 설계하였습니다. 이를 통해 버튼뿐만 아니라 다양한 컴포넌트에서도 인터랙션 상태를 적용할 수 있도록 확장성을 고려하였습니다.

설계 목표

  1. 상태별(hover, focus, press) 색상 효과가 명확히 적용되어야 한다.
  2. 재사용성: 버튼뿐만 아니라 다양한 UI 컴포넌트에 손쉽게 도입 가능해야 한다.
  3. 유연한 API 제공: 색상이나 강도 등을 변경하기 쉽게 만들 것.
  4. 디자인 시스템과 통합: tokens(예: ColorToken)를 활용하여 일관성 유지.

컴포넌트 구현

아래 코드는 인터랙션 상태를 관리하고, 다양한 컴포넌트에서 재사용할 수 있도록 만든 Interaction 컴포넌트입니다.

  1. 인터렉션 로직 분리
// Interaction.tsx
import type { CSSProperties, ReactNode } from 'react';
import InteractionRecipe, {
  type InteractionRecipeVariantProps,
  overlayStyle,
} from './interaction.recipe';
import { css, cx } from '@styles/css';
import { type ColorToken, token } from '@styles/tokens';

type InteractionProps = InteractionRecipeVariantProps & {
  children: (overlayStyle: {
    overlay: string;
    overlayColor: CSSProperties & {
      '--overlay-color': string;
    };
  }) => ReactNode;
  color?: ColorToken;
};

const Interaction = ({
  children,
  color = 'primaryNormal',
  ...restProps
}: InteractionProps) => {
  const [variantProps] = InteractionRecipe.splitVariantProps(restProps);
  const colorToken = token(`colors.${color}`);

  return children({
    overlay: cx(InteractionRecipe(variantProps), overlayStyle),
    overlayColor: {
      '--overlay-color': colorToken,
    },
  });
};

export default Interaction;
  • color 인터랙션 색상을 동적으로 조정할 수 있도록 ColorToken 활용
  • 내부에서는 hover/focus/press 상태를 일괄 관리하며, 외부에선 어떤 요소에 적용할지만 결정
  • children을 함수로 받는 Render Props 패턴으로, 필요한 DOM에 overlay 클래스를 부여할 수 있게 함

  1. 상태별 스타일 정의
// interaction.css.ts
import type { RecipeVariantProps } from '@styles/css';
import { css, cva } from '@styles/css';

const interactionRecipe = cva({
  base: {
    outline: 'none',
  },
  variants: {
    type: {
      normal: {
        _hover: { _after: { bg: 'var(--overlay-color)/4', opacity: 1 } },
        _focusVisible: { _after: { bg: 'var(--overlay-color)/8', opacity: 1 } },
        _active: { _after: { bg: 'var(--overlay-color)/12', opacity: 1 } },
      },
      light: {
        _hover: { _after: { bg: 'var(--overlay-color)/3', opacity: 1 } },
        _focusVisible: { _after: { bg: 'var(--overlay-color)/6', opacity: 1 } },
        _active: { _after: { bg: 'var(--overlay-color)/9', opacity: 1 } },
      },
      strong: {
        _hover: { _after: { bg: 'var(--overlay-color)/6', opacity: 1 } },
        _focusVisible: {
          _after: { bg: 'var(--overlay-color)/12', opacity: 1 },
        },
        _active: { _after: { bg: 'var(--overlay-color)/18', opacity: 1 } },
      },
    },
  },
});

export const overlayStyle = css({
  position: 'relative',
  _after: {
    content: '""',
    position: 'absolute',
    top: 0,
    left: 0,
    width: '100%',
    height: '100%',
    opacity: 0,
    borderRadius: 'inherit',
    outline: 'none',
  },
});

export type InteractionRecipeVariantProps = RecipeVariantProps<
  typeof interactionRecipe
>;

export default interactionRecipe;
  • variants: normal, light, strong 세 가지 강도를 제공
  • _after 가상 요소를 활용하여 인터랙션 레이어를 적용
  • overlayStyle: 모든 컴포넌트에서 인터랙션을 유지하기 위한 기본 스타일

사용예시

  1. 버튼에 적용
import Interaction from './Interaction';

const Button = () => (
  <Interaction color="secondaryNormal" type="strong">
    {({ overlay, overlayColor }) => (
      <button className={overlay} style={overlayColor}>
        Click Me
      </button>
    )}
  </Interaction>
);

export default Button;
  1. 다른 컴포넌트에 적용
<Interaction color="primaryLight" type="light">
  {({ overlay, overlayColor }) => (
    <div
      className={overlay}
      style={{ ...overlayColor, padding: '20px', borderRadius: '8px' }}
    >
      <h3>Interactive Card</h3>
      <p>Hover over me!</p>
    </div>
  )}
</Interaction>

결론

가상 요소(::after)를 활용해 hover, focus, press 같은 인터랙션 상태를 깔끔하게 분리하고, 재사용 가능한 Interaction 컴포넌트를 만들면 다음과 같은 이점이 있습니다:

  1. 재사용성: 버튼, 카드, 기타 커스텀 컴포넌트 등 어디든 손쉽게 적용 가능.
  2. 유지보수성: 인터랙션 스타일을 한 곳(Recipe)에서 관리하기 때문에, 디자인이 바뀌어도 일괄적으로 업데이트 가능.
  3. 확장성: 색상 토큰, pointerEvents, transition 등 다양한 설정을 props나 CSS 변수로 유연하게 제어할 수 있음.

실무 적용 시에는 Storybook 등에서 팀원들과 미리 피드백을 주고받으면 더욱 완성도 높은 디자인 시스템을 만들 수 있을 것입니다.