03-3. tailwind-merge + clsx + class-variance-authority

재사용의 이유

Tailwind CSS는 uility class를 html에 markup 형태로 쓰는 방식이다.

그러나 보통 좀 제대로 된 간단한 버튼 하나를 구현하려고 해도 코드과 다음과 같아 길어진다.

<button className="mx-3 mt-3 flex h-16 w-32 items-center justify-center rounded-xl border border-black bg-blue-600 text-2xl font-semibold text-blue-50 shadow-md shadow-blue-950 hover:bg-blue-200 hover:text-blue-800 active:translate-y-1 active:shadow-sm active:shadow-blue-950 sm:w-48">
	Hello 
</button>

 

위 코드를 반복해서 쓰고 싶다면 가독성이 더욱 더 떨어질 것이다.

 

이 코드를 

<button className="btn">My Button</button>

 

의 형태로 재정의해서 사용하면 .jsx (.tsx) 파일의 가독성이 훨씬 올라갈 것이다.

 

재사용 방법

Utility class를 재사용하는 방법은 기본적으로 3가지가 있다.

 

1. tailwind.config.js
이미 정의되어있는 utility class를 재정의하거나, 새로운 utility class를 정의할 때 사용

예시:

theme: {
  extend: {
    colors: {
      primary: '#F9482D',
      secondary: '#DB3939',
      ...


2. globals.css 
base는 주로 reset CSS 관련 코드
components는 재사용할 className 정의
utilities도 재사용할 className을 정의하지만 components보다 우선 순위를 가지고, 보통 작은 코드나 미디어 쿼리, pseudo-class를 정의할 때 사용

   
3. Typescript/Javascript로 Component화하기 (shadcn/ui library 방식)

하나의 HTML element를 Javascript component로 묶어서 재사용하기.

  

2. global.css

2번째 방법 globals.css에 정의하는 방법부터 보자.

 

globals.css에

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  .btn-main {
    @apply mx-3 mt-3 flex h-16 w-32 items-center justify-center rounded-xl border border-black bg-blue-600 text-2xl font-semibold text-blue-50 shadow-md shadow-blue-950 hover:bg-blue-200 hover:text-blue-800 active:translate-y-1 active:shadow-sm active:shadow-blue-950 sm:w-48;
  }
}

 

그리고 component에서

<button className="btn-main"> My Button </button>

 

으로 사용하면 된다.

 

 

그러나 globals.css에 정의하자니 다음과 같은 문제가 생긴다.

 

 

결론부터 얘기하자면 variants 때문에 생기는 문제다.

 

SCSS 코드

다음은 SCSS로 짠 _button.scss 파일이다. 

.btn {
  min-width: fit-content;
  display: inline-block;
  text-align: center;

  // variant - text, contain, outline
  &.contain {
    color: $white;
    border-radius: $radius-round;
    border: 1px solid transparent;

    &.radius {
      border-radius: $radius-rg;

      &.xs {
        border-radius: $radius-sm;
      }
    }

    ...

 

그리고 component내에서 버튼 사용시 

<button type="button" class="btn contain radius xs">
  button
</button>

 

첫번째 문제점은 Tailwind CSS는 nested CSS selector를 사용하지 않는다는 점이다.

억지로 넣을 수 있는 방법은 있지만 nested selector에 대해 Tailwind CSS는

  • utility-first 철학에 맞지 않고 (복잡한 디자인을 직접 markup으로 작성)
  • nested selector는 실제로는 코드가 더 길어져, 운영과 확장성에서 오히려 더 유지하기 어려운 면도 있고
  • separation of concerns : Tailwind는 HTML과 Javascript와 HTML내의 utilty classes를 분리해서 생각한다. (SCSS의 BEM 방식은 애초에 HTML의 nested 구조와 분리를 하지 않게 하기 위함이었는데 다시 트렌드는 역행하는 분위기다.)

요즘은 모든 것이 역행하는 분위기. (원래 3~4년전까지만해도 SPA이 빠르고 좋다하더니 이제는 왠만하면 SSR로 가자하더니, 사실 nested selector가 생겨난 이유가 HTML 구조가 nested인데 CSS class selector는 단순 나열이라 찾기 힘들어서 생겼는데 이제는 또 써보니 이게 더 복잡한 것 같다는 분위기)

 

아무튼 nested를 지원하지 않으니 모든 variant 조합에 대해서 다 만들어줘야하는데 다음과 같이 효율적이지 못한다.

 

globals.css를 계속 이용한다면

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  .btn {
    @apply inline-block text-center;
  }
  .contain {
    @apply rounded-full border border-transparent;
  }
  .btn-contain-rounded {
    @apply btn contain rounded;
  }
 }
 
 @layer utilities {
  .btn-xs {
    @apply h-8 px-2 text-sm font-medium;
  }
}

 

각각의 variant에 대해 이렇게 정의하고, 실제 component에서 적용할 때

<button className="btn-contain-rounded btn-xs">My Button</button>

 

이렇게 짤 수도 있겠다. 그러나 굉장히 비효율적이다.

조금 더 가독성이 좋은 방법은 없을까?

 

3. Component로 button 코딩

만약 tailwind로 javascript(typescript) component를 만들어보자.

src/components/ui/button.tsx 생성

interface ButtonProps {
  className: string;
}

const Button = ({ className }: ButtonProps) => {
  return <button className="bg-blue-200">Submit</button>;
};

export { Button };

 

src/page.tsx

import { Button } from '@/components/ui/button';

export default function Home() {
  return <Button className="bg-red-200" />;
}

 

만약 재사용하고 싶으면 utility function을 다음과 같이 override 시키고 싶을 것이다.

 

그러나 이제 npm run dev를 해보면 우리의 의도대로 button이  붉은색 색상으로 바뀌지 않는다.

globals.css에 @layer를 줄 때는 잘 되지만, 이렇게 html에 직접 넣어주면

CSS의 cascading 우선 순위에서 props로 넣어준 className이 우선 순위가 더 높으라는 보장이 없기 때문에 적용이 안 된 것이다.

 

이를 위한 패키지가 존재한다.

 

Tailwind-merge

이를 위해 우선 tailwind-merge를 설치해준다.

pnpm add tailwind-merge

 

이제 button.tsx에서

import { twMerge } from 'tailwind-merge';

interface ButtonProps {
  className: string;
}

const Button = ({ className }: ButtonProps) => {
  return <button className={twMerge('bg-blue-200', className)}>Submit</button>;
};

export { Button };

 

twMerge의 두번째 인자로 className prop을 넣어주면 이제 의도하던대로 버튼이 붉은색이 된다.

twMerge가 두번째 인자의 더 높은 우선순위를 보장해주기 때문이다.

 

Conditionals with Tailwind-merge

다음과 같이 button이 pending 상태일 때 disabled 옵션을 주고 싶을 때가 있다.

 

button.tsx

'use client';
import { useState } from 'react';
import { twMerge } from 'tailwind-merge';

interface ButtonProps {
  className: string;
}

const Button = ({ className }: ButtonProps) => {
  const [pending, setPending] = useState(false);

  const handleClick = () => {
    setPending(true);

    setTimeout(() => {
      // 2초후에 pending 상태 해제
      setPending(false);
    }, 2000);
  };

  return (
    <button
      onClick={handleClick}
      disabled={pending}
      className={twMerge(
        'm-5 w-32 bg-blue-200 p-4',
        className,
        pending ? 'bg-gray-300' : ''
      )}
    >
      Submit
    </button>
  );
};

export { Button };

 

 

위의 조건문을 javascript object의 형태로 넣고 싶은 개발자를 위해 clsx라는 패키지도 있다.

 

clsx

install

pnpm add clsx

 

tailwind-merge와 javascript object 형태로 사용할 수 있는 clsx를 

작업하던 button.tsx 하나의 파일에 계속 코딩할 수 있지만 자주 사용할 것이니 따로 파일로 분리한다.

 

src 폴더를 사용한다면 src/lib/utils.ts 경로에 파일을 생성한다.

src 폴더를 사용하지 않는다면 lib/utils.ts 경로에 파일을 생성한다.

 

utils.ts

import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
 
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

 

이제 cn을 import하면 조건문 className을 object의 형식으로 사용할 수 있다.

이제 위의 button.tsx를 다음과 같이 바꿀 수 있다.

'use client';
import { useState } from 'react';
import { cn } from '@/lib/utils';

type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;

export const Button = ({ className }: ButtonProps) => {
  const [pending, setPending] = useState(false);

  const handleClick = () => {
    setPending(true);

    setTimeout(() => {
      // 2초후에 pending 상태 해제
      setPending(false);
    }, 2000);
  };

  return (
    <button
      onClick={handleClick}
      className={cn(`m-5 w-32 bg-green-500 p-4`, className, {
        'bg-gray-400': pending,
      })}
    >
      Submit
    </button>
  );
};

 

위에 보면 template에 Submit이라는 단어가 있는데,

당연히 이것은 내가 import로 가져오는 component에서 집어넣고 싶은 문구이다.

 

또한 위 button을 재사용시 button 태그에 type=’submit’같은 것을 추가로 넣어줄 수 있는데 이것은 proto-type based javascript의 button의 모든 prop을 destructuring 형태로 받을 수도 있다. button.tsx는 다음 코드로 props를 추가해 줄 수 있다.

위에서 우선은

- 세번째 인자를 빼고, (단지 cn을 위한 설명을 위해 추가된 코드 전부 삭제)

- 버튼 글귀를 props로 받고

- 글귀없으니 button을 self-closing tag로 바꾸자.

 

button.tsx

import React from 'react';
import { cn } from '@/lib/utils';

type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;

const Button = ({ className, ...props }: ButtonProps) => {
  return <button className={cn('bg-blue-200', className)} {...props} />;
};

export { Button };

 

pages.tsx

import { Button } from '@/components/ui/button';

export default function Home() {
  return <Button className="">Click Me</Button>;
}

 

잘 적용되었지만 이제 variants를 구현해보자.

 

 

이 형태를 nested selector를 쓰지 않고 구현해보자.

 

class-variance-authority

variant를 설정할 수 있는 패키지다. 설치한다.

 

pnpm add class-variance-authority

 

아래와 같이 코드를 입력한다.

button.tsx

import { VariantProps, cva } from 'class-variance-authority';
import { ComponentProps } from 'react';
import { cn } from '@/lib/utils';

const buttonVariants = cva(
  'inline-flex h-10 items-center justify-center rounded-md border border-transparent bg-slate-900 px-4 py-2 text-sm font-medium text-slate-50 transition-colors hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-slate-100',
  {
    variants: {
      bgcolor: {
        pink: 'bg-pink-300 text-pink-900 hover:bg-pink-500',
      },
      outline: {
        black: 'border-4 border-black',
      },
      size: {
        sm: 'h-9 rounded-md px-2 text-xs',
        lg: 'h-11 rounded-md px-8 text-base',
      },
    },
  }
);

interface ButtonProps
  extends ComponentProps<'button'>,
    VariantProps<typeof buttonVariants> {}

const Button = ({
  className,
  bgcolor,
  outline,
  size,
  ...props
}: ButtonProps) => {
  return (
    <button
      className={cn(
        buttonVariants({
          bgcolor,
          outline,
          size,
          className,
        })
      )}
      {...props}
    />
  );
};

export { Button };

 

사용예시:

모든 props를 전달 안해도 된다. 전달하지 않을 시에는 default variant로 구현된다.

import { Button } from '@/components/ui/button';

export default function Home() {
  return (
    <>
      <Button>My Button</Button>
      <Button bgcolor="pink">My Button</Button>
      <Button bgcolor="pink" outline="black">
        My Button
      </Button>
      <Button bgcolor="pink" outline="black" size="lg">
        My Button
      </Button>
    </>
  );
}

 

위 형태 모두 가능

 

React.forwardRef

import를 하는 component의 코드에서는 종종 useRef를 사용하는데

우선 어떤 상황에서 useRef hook을 사용하는지 살펴보자.

 

useRef 사용 예시

useRef라는 기본 리액트 hook은 여러 용도로 쓰인다.

한 가지 예로 만약 화면이 처음 로드할 때 <textarea>에 focus를 맞추고 싶다.

그러면 

import { useRef } from 'react';

const Page = () => {
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  useEffect(() => {
    textareaRef.current?.focus();
  }, []);
  
  return (
    <Textarea
      ref={textareRef}
    />
      
    ...

 

위의 경우 화면이 로딩되자마자 cursor가 textarea에 활성화 시키게된다.

이런 식으로 특정 DOM element를 선택할 때 사용한다. 이런 useRef를 생성한 UI element에 사용하기 위해서는 ref 특성을 넣어줘야한다.

 

최종 코드

import * as React from 'react';
import { VariantProps, cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { ComponentProps } from 'react';

const buttonVariants = cva(
  'inline-flex h-10 items-center justify-center rounded-md border border-transparent bg-slate-900 px-4 py-2 text-sm font-medium text-slate-50 transition-colors hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-slate-100',
  {
    variants: {
      bgcolor: {
        pink: 'bg-pink-300 text-pink-950 hover:bg-pink-500',
      },
      outline: {
        black: 'border-4 border-black',
      },
      size: {
        sm: 'h-9 rounded-md px-2 text-xs',
        lg: 'h-11 rounded-md px-8 text-base',
      },
    },
  }
);

interface ButtonProps
  extends ComponentProps<'button'>,
    VariantProps<typeof buttonVariants> {}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, bgcolor, outline, size, ...props }, ref) => {
    return (
      <button
        className={cn(
          buttonVariants({
            bgcolor,
            outline,
            size,
            className,
          })
        )}
        ref={ref}
        {...props}
      />
    );
  }
);
Button.displayName = 'Button';

export { Button };

 

 

추상화의 정의

Abstraction is used to hide background details or any unnecessary implementation about the data so that users only see the required information.

=> 코딩 세계에서 "추상화"란 표면에 필요한 데이터만 보이고 나머지 로직은 숨기는 과정

 

좋은 추상화란?

숨긴 로직을 우리가 이해하고 필요할 때 수정할 수 있는 추상화. 

좋지 않은 추상화란?

완벽한 커스터마이징이 되지 않고, 내부 로직을 알 수 없는 library -> Ant Design, Material UI, Chakra UI 등

 

shadcn/ui 설치

shadcn/ui를 사용하려면 우선 설치를 해야하는데

참고: https://ui.shadcn.com/docs/installation/next

설치라 함은 내 프로젝트의 각종 환경 정보에 대해 components.json이라는 파일을 저장하는 절차다.

오래 걸리니 root 폴더에 components.json을 만들고 다음을 복사 붙이기하면 설치 안해도 된다.

 

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "default",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.ts",
    "css": "@/app/globals.css",
    "baseColor": "neutral",
    "cssVariables": false,
    "prefix": ""
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils"
  }
}

 

npx shadcn-ui@latest add button

으로 버튼을 설치하면 components/ui 폴더에 button.tsx 파일이 생겼다.

 

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유