✅ 본 글은 TailwindCSS, Tailwind Variants 라이브러리를 사용하여 작성되었습니다.
Tailwind Variants를 통해 재사용할 수 있는 상호작용 상태를 만들어보자
디자인 시스템을 만들면서 여러 가지를 시도해보고 있는데 그 중 하나로
컴포넌트의 상호작용 상태를 어떻게 쉽게 재사용할 수 있을까?
라는 부분이 있었다.
컴포넌트를 만들 때마다 일일이 hover, press에 대한 상태를 정의해주는 것도 힘들었고,
개발 과정에서 수정될 경우 각 컴포넌트에서 이를 일일이 처리해줘야하는 등 문제가 있었기 때문이다.물론 어느 정도 토큰화해서 이러한 점을 개선할 수 있지만, 결국 각 컴포넌트에서 개별로 정의해야하는 점이 불편하다고 생각했다.
이를 해결하기 위한 방법을 처음 본 곳은 구글의 Material 2 컴포넌트 코드를 분석하면서였다.
button 컴포넌트 내부에 ripple 애니메이션 역할을 담당하는 div가 들어있는 것을 보고 이게 뭘까 분석하면서 시작되었다.
그 결과 이 div에 컴포넌트의 여러 상호작용 상태를 지정해놓고 이를 재사용한다는 것을 깨닫게 되었고
이러한 방법을 사용한다면 컴포넌트 간에 상호작용 상태를 간편하게 통일할 수 있겠다는 생각이 들어 만들어보게 되었다.
이러한 부분은 컬러 체계 개편기에서도 잠깐 엿볼 수 있는데
상호작용 상태에 대한 색상을 Overlay Layer를 통해 구현하는 것을 통해 이를 실제 컴포넌트로 만들 기회를 엿보고 있긴 했다.
그러나 기존에 사용하던 cva를 통해 상호작용 상태를 컴포넌트화하는 것은 여러모로 어려움이 있어 뒤로 미루다가
Tailwind Variants를 도입하게 되면서 가능성을 봤고 시도해보게 되었다.
👆 상호작용 상태(Interaction State)란?
디자인 시스템들을 보면 Interaction State 또는 State라고 정의해놓은 문서를 볼 수 있다.
쉽게 말해서 컴포넌트가 사용자와 상호작용할 때 표시되는 상태를 의미하는데
대표적으로 enabled, disabled, active(press), hover, focused와 같은 상태를 들 수 있다.
이에 대해서 한국어로 이를 나타내는 말이 별도로 있나 찾아봤지만
FE의 변하는 데이터를 의미하는 State와 용어가 겹쳐서 그런건지
아니면 내가 그냥 못 찾은건지 어쨌든 찾을 수가 없어서 그냥 상호작용 상태 정도로 사용하고 있다.
🤷 어떤 상태를 어떻게 구현하는가?
1. 어떤 상태를
흔히 사용되는 disabled, hover, press, focus과 같은 상태에 대해서 만들어보기로 보는 것을 목표로 했다.
문제는 위의 상태가 모두 오버레이 레이어만을 통해 만들 수 있는 상태가 아니라는 점이다.
hover, pressed는 오버레이의 투명도를 조절하여 간단하게 만들 수 있지만,
disabled는 오버레이 레이어를 건드리는 것 아니라 실제 컴포넌트 컨테이너의 색이나 텍스트 색의 변경이 필요하고,
focus(focus-visible)은 컴포넌트 외부에 어느 정도 간격을 두고 선을 그려야했기 때문이다.
(물론 이는 자신이 정의한 상호작용 상태에 따라 다르다)
2. 어떻게
먼저, 위의 문제점인 CSS를 부모와 자식에 별도로 적용해야하는 문제점을 해결해보자.
이는 CSS의 자식 선택자 기능을 통해 해결할 수 있다.
& > child {
color: red;
}
위를 이용해 부모 컴포넌트에 자식을 선택하여 CSS를 적용하는 클래스를 추가하면,
부모 클래스에서 자식의 CSS까지 관리할 수 있기 때문에 부모에서 모든 상태 CSS를 관리할 수 있게 된다.
또, 이를 쉽게 이용하기 위해서 Tailwind의 addVariant 플러그인을 통해 선택자를 추가해주면 더욱 좋다.
다음으로, 각 컴포넌트가 이렇게 정의된 CSS 상태를 쉽게 재사용할 수 있는 방법이 필요한데,
이는 Tailwind Variants의 extend(상속) 기능을 활용하면 쉬워진다.
export const parent = tv({
base: "bg-red",
variants: {
parentAttribute: {
true: "bg-blue",
},
},
});
export const child = tv({
extend: parent, // 👈 parent tv를 상속 받음
variants: {
parentAttribute: {
true: "bg-green", // 👈 parentAttribute를 Overriding
},
childAttribute: {
true: 'bg-yellow'
}
},
});
상속 기능은 Tailwind Variants로 구현된 tv 객체가 다른 tv 객체를 상속받을 수 있는 기능이다.
상속받은 tv 객체는 부모 tv 객체의 클래스들을 그대로 물려받을 수도 있으며, 이를 Overriding하는 것 역시 가능하다.
🛠️ 구현하기
1. Overlay Layer Div 컴포넌트 만들기
이제 실제로 구현해보도록 하자.
먼저 오버레이 레이어 역할을 할 컴포넌트를 만든다.
/**
* 해당 컴포넌트를 대상 컴포넌트 Wapper의 바로 아래 최상단에 넣어주세요.
*/
const InteractionState = () => <div className="interactionState" />;
export default InteractionState;
이 컴포넌트는 `interactionState`라는 클래스를 가지고 있어서 이를 통해 쉽게 접근할 수 있다.
이 컴포넌트를 사용할 때 주의해야할 점이 있는데 컴포넌트 컨테이너의 코드 상에서 최상단에 위치해야한다.
HTML이 DOM을 그릴 때 동일 계층에서는 위에서 아래로 코드를 읽어가면서 요소를 쌓아가며 그리는데
최상단에 넣지 않으면 오버레이 레이어가 컴포넌트 내부 요소 위에 쌓여서 요소를 덮어버려 상호작용이 불가능해지기 때문이다.
2. Tailwind에 상태 선택자 추가
이제 interactionState에 쉽게 접근할 수 있도록 Tailwind 선택자를 만들어보자.
Tailwind는 기존에도 hover, focus와 같은 상태를 쉽게 정의할 수 있도록 `hover:`나 `focus:`와 같은 선택자를 제공한다.
이는 플러그인의 addVariant 기능을 이용해 만들 수 있는데
이 기능을 통해 interactionState에 쉽게 접근할 수 있도록 아래 커스텀 선택자를 정의해준다.
`interaction:` | interaction div에 접근할 수 있는 선택자 |
`interactionFocus:` | 부모가 focus(focus-within) 되었을 때의 interaction div에 접근할 수 있는 선택자 |
`interactionFocusVisible:` | 부모가 focus-visible 되었을 때의 interaction div에 접근할 수 있는 선택자 |
`interactionHover:` | 부모가 hover 되었을 때의 interaction div에 접근할 수 있는 선택자 |
`interactionPress:` | 부모가 press 되었을 때의 interaction div에 접근할 수 있는 선택자 |
// tailwind.config.ts
import type { Config } from "tailwindcss";
import plugin from "tailwindcss/plugin";
import { createThemes } from "tw-colors";
import color from "./src/constants/colorPalette";
export default {
...
plugins: [
plugin(({ addVariant }) => {
addVariant("interaction", "& > .interactionState");
addVariant("interactionFocus", "&:focus-within > .interactionState");
addVariant("interactionFocusVisible", "&:focus-visible > .interactionState");
addVariant("interactionHover", "&:hover > .interactionState");
addVariant("interactionPress", "&:active > .interactionState");
}),
],
} satisfies Config;
여기서 주의해야할 점은 interaction div 자체가 hover, press, focus 되는게 아니라
부모가 hover, press, focus 일 때, interaction div의 상태를 정의해주어야한다는 것이다.
interaction State는 브라우저에서 그려질 때 컴포넌트 내부 요소 중 가장 하단에 그려지므로,
그 자체에 상태 선택자를 추가할 경우 다른 요소들에 가려서 정상적으로 표시되지 않을 수 있기 때문이다.
3. interactionStateVariant 정의
이제 tailwind variants를 통해서 각 상호작용 상태에 대해서 어떤 클래스를 설정할지 작성한다.
tailwind variants를 cva와 달리 프로퍼티로 boolean 값을 사용할 수 있다는 특징이 있다.
이를 활용해서 아래와 같이 작성할 수 있다.
const interactionStateVariants = tv({
base: [
"relative", // 👈 interaction: 선택자가 없다면 컴포넌트 컨테이너에 적용
"overflow-hidden",
"interaction:absolute", // 👈 interaction: 선택자가 있다면 Overlay에 적용
"interaction:top-0",
"interaction:left-0",
"interaction:w-full",
"interaction:h-full",
"interaction:bg-current",
"interaction:opacity-0",
],
variants: {
disabled: {
true: ["cursor-not-allowed", "bg-[#c6c6c6]", "text-[#8c8c8c]"],
false: ["interactionHover:opacity-20", "interactionPress:opacity-30"],
},
},
defaultVariants: {
disabled: false,
},
});
위는 disabled가 되었을 때는 true를 아닐 경우 hover, press css를 설정하는 방식으로 간단히 구현했다.
4. 실제 버튼에 사용해보기
이제 완성된 상호작용 상태를 실제 컴포넌트에 사용해보자.
딱, 2가지 과정만 추가하면 된다.
export const buttonVariants = tv({
extend: interactionStateVariants, // 👈 buttonVatiants에서 interactionVariants를 상속
base: "relative flex justify-center items-center rounded-m py-xs cursor-pointer",
variants: {
...
},
defaultVariants: {
variant: "secondary",
size: "m",
},
});
먼저 interactionStateVariants를 컴포넌트의 Variants(여기서는 buttonVariants)에 상속받는다.
const Button = <
T extends ButtonDefault | ButtonAlterAs = ButtonDefault,
A extends ButtonAlterAs = ButtonAlterAs
>(
props: ButtonProps<T, A>
) => {
return (
<Slot className={buttonVariants(...)} {...props}>
<InteractionState /> // 👈 InteractionState를 컴포넌트 하위 최상단에 삽입
{children}
</Slot>
);
};
export default Button;
그 다음 컴포넌트의 최상단에 InteractionState 컴포넌트를 추가한다.
이를 통해 버튼 컴포넌트에 상호작용 상태가 잘 적용된 것을 볼 수 있다.
5. 컴포넌트에 따라 커스터마이징하기
그런데 여기서 문제가 하나 있다.
Text 버튼의 disabled에서는 배경을 보여주고 싶지 않은데
interactionStateVariants 상에서 배경이 설정되어있기 때문에 disabled일 경우 배경이 일괄적으로 적용된다.
이를 위해서는 text 버튼에 대해서 상속받은 스타일을 overriding해 줄 필요가 있다.
export const buttonVariants = tv({
extend: interactionStateVariants,
base: "relative flex justify-center items-center rounded-m py-xs cursor-pointer",
variants: {
...
},
compoundVariants: [ // 👈 상속받은 객체에서 disabled를 사용해 덮어쓰기
{
disabled: true,
variant: ["text"],
className: "bg-transparent",
},
],
defaultVariants: {
variant: "secondary",
size: "m",
},
});
위와 같이 disabled가 true인 텍스트 버튼에 대해서 스타일을 overriding 해주었다.
이제 다른 버튼과 다르게 text 버튼의 경우 배경색이 투명으로 설정되어있는 것이 보인다.
6. 상호작용 상태 On/Off 추가
지금은 기본적인 상태이기 때문에, disabled, hover, press, focus-visible에 대해서만 처리를 해주었다.
그런데 만약 dragged와 같은 상태가 추가된다면 상태를 필요에 따라 끄고 켤 필요가 생긴다.
이를 위해서 tv의 boolean 파라미터를 이용해 원하는 상태만 선택해서 적용할 수 있도록 리팩토링해보자.
import { tv } from "tailwind-variants";
export const interactionStateVariants = tv({
base: [
...
],
variants: {
disabled: {
true: [
"!cursor-not-allowed",
"!bg-transparent",
"!text-surface-on/40",
"!border-opacity-disabled",
"interaction:!opacity-disabled",
"interactionHover:!opacity-disabled",
"interactionPress:!opacity-disabled",
],
false: [],
},
hover: { // 👈 개별 on/off가 가능하도록 항목 분리
true: "interactionHover:opacity-hocus",
false: [],
},
press: { // 👈 개별 on/off가 가능하도록 항목 분리
true: "interactionPress:opacity-press",
false: [],
},
focusVisible: { // 👈 개별 on/off가 가능하도록 항목 분리
true: [
"focus-visible:interaction:opacity-20",
"focus-visible:outline",
"focus-visible:outline-4",
"focus-visible:outline-offset-4",
"focus-visible:outline-surface-on",
],
false: [],
},
},
compoundVariants: [ // 👈 disabled에서 예외 처리
{
disabled: true,
hover: true,
className: "interaction:hidden",
},
{ disabled: true, press: true, className: "interaction:hidden" },
],
defaultVariants: { // 👈 on/off 기본값 설정
disabled: false,
hover: true,
press: true,
focusVisible: true,
},
});
hover와 press, focusVisible 속성을 만들어서 true를 명시하고,
compoundVariants에서 disabled에 따라 적용이 되지 않도록 별도의 예외처리를 해주었다.
그리고 기본적으로 아무 것도 설정하지 않을 경우에 hover, press, focusVisible이 true로 설정되도록
defaultVariants를 설정해주었다.
이를 이용해 특정 상태를 끄고 싶다면 아래와 같이 할 수 있다.
const Button = <
T extends ButtonDefault | ButtonAlterAs = ButtonDefault,
A extends ButtonAlterAs = ButtonAlterAs
>(
props: ButtonProps<T, A>
) => {
return (
<Slot<ButtonDefault | ButtonAlterAs>
className={buttonVariants({
...
hover: false, // 👈 (기본적으로 활성화 되어있다면) 끄고 싶은 상태에 false
focusVisible: true, // 👈 (기본적으로 비활성화 되어있다면) 켜고 싶은 상태에 true
})}
{...otherProps}
>
<InteractionState />
{children}
</Slot>
);
};
🎉 마무리
이렇게 Tailwind Variants를 통해 상호작용 상태를 재사용 및 커스텀 할 수 있도록 만들어봤다.
물론 실제로 사용해보았을 때 여러가지 문제점들도 개선할 점도 아직 있었다.
extend를 2번 이상 할 경우 조부모 객체의 속성은 tailwind variants에서 아직 타입 지원이 안된다던가,
!important가 필요한 상황이 생긴다던가 등등...
하지만 확실한 것은 TextField 컴포넌트를 만들 때 사용해본 결과 굉장히 편했다는 사실이다.
컴포넌트가 많아질수록 이런 재사용성을 고려한 설계가 더욱 빛을 발하지 않을까?
Reference
Goldman Sachs Design System - Interaction States
'Frontend > React' 카테고리의 다른 글
태그에 따라 바뀌는 React 컴포넌트 만들기 3 - 컴포넌트 별칭 정하기 (0) | 2023.09.06 |
---|---|
태그에 따라 바뀌는 React 컴포넌트 만들기 2 - As 속성 제한하기 (0) | 2023.09.06 |
Icon 컴포넌트 만들기(with SVGR, 동적 import) (0) | 2023.08.08 |
Tailwind에서 재사용 가능한 컴포넌트로(with cva, tailwind-merge) (0) | 2023.08.05 |
태그에 따라 바뀌는 React 컴포넌트 만들기(with TypeScript) (0) | 2023.08.03 |