[CSS] 재사용 가능한 컴포넌트 작성하기(shadcn/ui, radix/ui)

Jay ·2023년 12월 3일
1

서론

개인 프로젝트를 사용할 때, 생산성과 DX를 고려하여 주로 tailwind로 프로젝트를 진행하곤 한다.

하지만 tailwind와 같은 atomic CSS를 사용해 컴포넌트를 작성하면, 이를 재사용하기는 쉽지 않다.

그러던 와중 headless componenet가 컴포넌트 재사용 문제를 해결할 수 있단 정보를 찾아볼 수 있었다.

그 중에서도 radix ui(headless) 기반 shadncn/ui라는 오픈소스에 관심을 갖게 되어, 이를 기반으로 재사용 가능한 컴포넌트를 작성해보게 되었다.

1. Shadcnui? Radix ui ?

headless ui

Headless UI is a term for libraries and utilities that provide the logic, state, processing and API for UI elements and interactions, but do not provide markup, styles, or pre-built implementations

Headless 컴포넌트, 또는 'presentational-less' 컴포넌트라고도 불리는 이 개념은
일종의 디자인 패턴으로, UI 로직과 표현(presentation)을 분리함으로써 컴포넌트의 재사용성, 유지보수성, 그리고 테스트 용이성을 크게 향상시킬 수 있다.

Headless ui란 이러한 이러한 디자인패턴을 차용한 ui 라이브러리를 지칭한다.

Radix ui

An open source component library optimized for fast development, easy maintenance, and accessibility. Just import and go—no configuration required.

프론트엔드 개발자로써 개발을 진행해야 할 때, 고려해야 할 사항은 디자인과 기능뿐만이 아니다.

재사용성과 접근성과 같은 부분도 고려해야 하는데, 일일이 컴포넌트를 생성할 때마다, 접근성을 고려하여 aria tag만 다는 것만 하여도 너무 많은 시간이 소모된다.

이를 위해서 만들어진 라이브러리가 radix ui이며, 이를 통해 개발자는 핵심 요소인 디자인과 비즈니스 로직 구현에 집중할 수 있다.

여러 headless ui 라이브러리 중 Radix ui가 가장 생태계가 풍부하고, 최신 접근성을 지원하고 있다고 한다.

shadcn ui

Re-usable components built using Radix UI and Tailwind CSS.
....
This is NOT a component library. It's a collection of re-usable components that you can copy and paste into your apps.

radix ui는 css가 전혀 포함되지 않은 headless ui이므로 디자인 부분은 온전히 개발자의 몫으로 남는다.

그렇기에 tailwindCSS와 Radix Ui로 컴포넌트를 작성하는 개발자를 위한 모범적인 컴포넌트 작성법에 대한 수요가 생길 수 밖에 없었고, best practice에 가까운 방식으로 컴포넌트를 미리 구현해놓은 것이 shadcn/ui 이다.

import * as React from "react";
import Link from "next/link";
import { VariantProps, cva } from "class-variance-authority";

import { cn } from "@libs/util";

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-slate-100 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900 dark:data-[state=open]:bg-slate-800 data-[checked=true]:bg-blue-600 data-[checked=true]:text-white data-[checked=true]:hover:bg-blue-500",
  {
    variants: {
      variant: {
        default:
          " bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900",
        destructive:
          "bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600",
        outline:
          "border border-slate-200 bg-transparent hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100",
        subtle:
          "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-100",
        ghost:
          "bg-transparent hover:bg-slate-100 data-[state=open]:bg-transparent dark:bg-transparent dark:text-slate-100 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:data-[state=open]:bg-transparent",
        link: "bg-transparent text-slate-900 underline-offset-4 hover:bg-transparent hover:underline dark:bg-transparent dark:text-slate-300 dark:hover:bg-transparent",
      },
      size: {
        default: "h-10 px-4 py-2",
        xxs: "h-5 px-1",
        xs: "h-7 px-1",
        sm: "h-9 rounded-md px-2",
        lg: "h-11 rounded-md px-8",
        icon: "h-9 w-9",
      },
      padding: {
        default: "px-4 py-2",
        xs: "px-1",
        sm: "px-2",
        lg: "px-8",
        icon: "px-0",
        none: "p-0",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  href?: string;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, children, href, variant, size, ...props }, ref) => {
    if (href) {
      return (
        <Link
          href={href}
          className={cn(buttonVariants({ variant, size, className }))}
        >
          {children}
        </Link>
      );
    }
    return (
      <button
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      >
        {children}
      </button>
    );
  }
);
Button.displayName = "Button";

export { Button, buttonVariants };

Button 컴포넌트에 대한 예제 코드를 확인하면,

클래스 네임을 조건문을 사용해 쉽게 작성할 수 있게 하는 Clsx,
type-safety를 보장하는 CVA(class-variance-authority),
class 충돌문제를 방지하는 tailwind-merge가 포함된 것을 확인할 수 있다.

해당 코드를 컴포넌트 폴더에 복사하여 가져오는 것만으로 컴포넌트를 바로 사용할 수 있으며, variant에 기반하여 자유롭게 수정하는 것도 가능하다.

2. 사용 예시

<Button className=" m-3" variant="ghost" size="xs">
</Button>

이렇게 props 형태로 컴포넌트를 스타일링 할 수 있으며, className을 통해 클래스 이름을 덮어씌우는 것 또한 가능하다.

tailwind를 사용하면서 가장 아쉬웠던 점이, 조건문을 처리하는 코드가 지나치게 길어져 가독성을 해치고, Type-safety가 보장되지 않으며 코드 리팩토링이 힘들다는 점이었는데

shadcn/ui를 통해 이 부분을 크게 보완할 수 있었으며, 추후 다른 프로젝트에서도 그대로 재사용 가능하다는 점이 큰 특징이다.

3. 고려해야할 부분

그렇다면 shadcn이 모든 프로젝트에 적용가능한 silver bullet이 될 수 있을까?

1) 유지 보수

shadcn의 최고의 장점이자 단점인 부분은 이것이 라이브러리가 아니라는 점이다.

패키지로 설치되어 캡슐화 된 컴포넌트로 사용되는 다른 ui 라이브러리와 달리, shadcn 컴포넌트들은 프로젝트의 소스코드로 포함된다. shadcn은 외부 라이브러리가 아니라 일종의 best practice에 대한 규범이기 때문이다.

따라서 이러한 코드들을 유지하고 보수하는 것은 전적으로 개발자의 책임으로 남는다.

2) 생태계

MUI, Bootstrap등과 같은 라이브러리는 충분히 큰 생태계를 가지고 있으며, 많은 실전적인 구현 케이스를 큰 어려움없이 바로 구현할 수 있다. 하지만 shadcn의 뿌리는 headless ui에 있기에, 앞선 라이브러리들에 비해 생태계가 아직은 성숙한 단계에 이르지는 못하였다.

4. 결론

하지만 곰곰이 생각하였을 때, 이러한 점들은 단점이라기보다 오히려 장점들로 다가왔다.

결국 엔터프라이즈 레벨에서 다양한 요구에 부응하는 복잡한 컴포넌트를 구현하기 위해서는, 결국 컴포넌트를 구성하는 대부분의 코드부분에서 프론트엔드 개발자의 손길이 필요하다.

따라서 코드의 수정 및 개선에 많은 책임이 따를지언정, 높은 자유도를 보장하며, 접근성과 같은 부분에 대한 수고를 크게 덜어주는 shadcn/ui는 기존의 디자인 시스템이 이미 존재하는 프로젝트의 경우가 아니라면 충분히 사용해 볼 만한 가치가 있다고 본다.

profile
Jay입니다.

0개의 댓글