shadcn/ui 컴포넌트를 사용해 확장/축소 가능한 테이블 만들기

·2025년 1월 19일
1
post-thumbnail

개요

사내에서 처음에는 단순 테이블로 데이터 노출을 요청하여 간단하게 구현할 수 있었습니다.
하지만 추후 확장/축소 기능에 대한 요청을 주셔서 아주 당황스러웠더랬죠.

예상치 못한 추가 요청도 해결해 나가는 것이 우리 아니겠습니까. 살짝 당황했지만 검색해 보니 자료가 있어 간단하게 구현해낼 수 있었습니다(휴~~) 구현 과정을 글로 공유드려봅니다.

shadcn/ui

Material UI, Ant Design 등... 많은 종류의 React UI 라이브러리가 있습니다. 하지만 shadcn/ui는 자신을 UI 라이브러리가 아니고, 복사하여 붙여 넣을 수 있는 컴포넌트 모음이라고 소개합니다. 아래는 shadcn/ui의 소개 문구입니다.

This is NOT a component library. It's a collection of re-usable components that you can copy and paste into your apps.

shadcn/ui는 의존성으로 추가하지 않고 그대로 코드를 복사해 와서 사용합니다. 따라서 코드에 대한 소유권과 제어권을 사용자에게 부여하기 때문에 필요한 컴포넌트만 복사하여 자유롭게 커스텀 및 사용할 수 있습니다.
또한 Radix UI를 기반으로 컴포넌트가 구성되어 최신 접근성도 지원하고 있으며, CSS 변수Tailwind CSS와 같은 유틸리티 기반 프레임워크를 활용하여, 추가적인 인라인 스타일이나 과도한 CSS 파일 없이도 손쉽게 컴포넌트를 스타일링할 수 있습니다.

이러한 특징을 갖고 있는 shadcn/ui을 프로젝트에 설정하고, 확장/축소 가능한 테이블 컴포넌트를 만드는 방법까지 알아보도록 하겠습니다.

shadcn/ui Installation

설치는 프로젝트 환경에 따라 달라지므로 공식문서를 참고하여 설치를 진행합니다.
Next.js 환경일 경우에는 npx shadcn@latest init 명령어를 사용하며, 아래와 같은 질문들을 선택하면 답변을 기반으로 components.json 파일이 구성됩니다.

Which style would you like to use? › (New York/Default)
Which color would you like to use as base color? › (Neutral/Gray/Zinc/Stone/Slate)
Do you want to use CSS variables for colors? › (no/yes)

style 선택

style에 선택의 차이는 링크에서 확인할 수 있습니다.
요약하자면 뉴욕스타일이 좀 더 사이즈가 작고 현대적인 스타일로 링크에서 디자인을 확인해 보고 프로젝트에 맞게 선택할 수 있습니다.

base color 선택

base color 또한 공식문서 Themes, Colors 페이지에서 색상 확인 후 베이스가 될 색상을 지정할 수 있습니다.

프로젝트 구조

app
├── layout.tsx
└── page.tsx
└── globals.css ✅
components
├── ui ✅
│   └── ...
lib
└── utils.ts ✅
components.json ✅
tailwind.config.js ✅
...

설치 후에는 ✅ 체크한 파일들이 추가, 수정됩니다.
앞서 선택한 설정을 기반으로 components.json 파일이 생성되며 복사하는 컴포넌트들은 components/ui 경로에 추가됩니다.

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

위는 lib 폴더에 추가되는 utils.ts 파일의 코드입니다.
cn 함수는 Tailwind CSS 클래스를 쉽게 병합하거나 조건부로 적용할 수 있게 합니다.
clsx는 조건부 클래스 추가를 지원하며, tailwind-merge는 클래스를 자동으로 병합하여 중복 및 충돌 문제를 방지합니다. 아래 코드는 cn 함수를 사용했을 때와, 사용하지 않았을 때의 사용 예시입니다.

<div className={`${isActive ? "bg-blue-100" : "bg-gray-100"} text-white`} />
<div className={cn(isActive ? "bg-blue-100" : "bg-gray-100", "text-white")} />

Expandable / Collapsible table

이제 본론으로 들어가 확장/축소가 가능한 테이블을 만들어보겠습니다.

table

npx shadcn@latest add table 명령어로 table 컴포넌트를 추가합니다.
아래는 shadcn/ui 공식문서에 있는 table 컴포넌트 데모 코드입니다.

import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const invoices = [...]

export function TableDemo() {
  return (
    <Table>
      <TableCaption>A list of your recent invoices.</TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead className="w-[100px]">Invoice</TableHead>
          <TableHead>Status</TableHead>
          <TableHead>Method</TableHead>
          <TableHead className="text-right">Amount</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {invoices.map((invoice) => (
          <TableRow key={invoice.invoice}>
            <TableCell className="font-medium">{invoice.invoice}</TableCell>
            <TableCell>{invoice.paymentStatus}</TableCell>
            <TableCell>{invoice.paymentMethod}</TableCell>
            <TableCell className="text-right">{invoice.totalAmount}</TableCell>
          </TableRow>
        ))}
      </TableBody>
      <TableFooter>
        <TableRow>
          <TableCell colSpan={3}>Total</TableCell>
          <TableCell className="text-right">$2,500.00</TableCell>
        </TableRow>
      </TableFooter>
    </Table>
  )
}

collapsible

확장/축소가 가능한 테이블을 만들기 위해 npx shadcn@latest add collapsible 명령어를 통해 collapsible 컴포넌트도 추가합니다.
아래는 shadcn/ui 공식문서에 있는 collapsible 컴포넌트 데모 코드입니다.

import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible"

export function CollapsibleDemo() {
  return (
    <Collapsible>
      <CollapsibleTrigger>Can I use this in my project?</CollapsibleTrigger>
      <CollapsibleContent>
        Yes. Free to use for personal and commercial projects. No attribution required.
      </CollapsibleContent>
    </Collapsible>
  )
}

데모 코드의 구조를 살펴보면 Collapsible로 전체를 감싸고, trigger가 되는 부분은 CollapsibleTrigger를 사용하며, 확장/축소가 되는 콘텐츠는 CollapsibleContent에 작성합니다.
이 구조를 테이블에 적용시켜 확장/축소가 가능한 테이블을 만들 수 있습니다.

Expandable / Collapsible table

1. Collapsible 컴포넌트로 TableRow 감싸기
table의 열을 확장/축소 가능하게 만들어야 하므로 TableRowCollapsible 컴포넌트로 감싸줍니다.
이때 CollapsibleasChild를 작성해야 합니다. 테이블 요소이기 때문에 tr, td와 같은 구조를 지켜주기 위해 asChild를 작성하여 Collapsible이 자체적으로 렌더링 하는 태그를 제거하고, 자식 컴포넌트의 태그를 사용하도록 만듭니다.

export default function TableDemo() {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead className="w-[100px]">Invoice</TableHead>
          <TableHead>Status</TableHead>
          <TableHead>Method</TableHead>
          <TableHead className="text-right">Amount</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {invoices.map((invoice) => (
          <Collapsible key={invoice.invoice} asChild> ☑️
            <TableRow>
              <TableCell className="font-medium">{invoice.invoice}</TableCell>
              <TableCell>{invoice.paymentStatus}</TableCell>
              <TableCell>{invoice.paymentMethod}</TableCell>
              <TableCell className="text-right">
                {invoice.totalAmount}
              </TableCell>
            </TableRow>
          </Collapsible>
        ))}
      </TableBody>
    </Table>
  )
}

2. trigger가 될 요소에 CollapsibleTrigger 감싸기
trigger가 될 요소에 CollapsibleTrigger 컴포넌트를 감싸줍니다.
저는 새로운 컬럼을 만들어 trigger가 될 버튼을 만들고 CollapsibleTrigger 컴포넌트로 감싸줬습니다.
다만, CollapsibleTrigger 컴포넌트는 button으로 렌더링 되어, button을 trigger로 사용하는 경우 1번과 같이 asChild을 작성하여 자식 컴포넌트 태그를 렌더링 하게 해야 합니다.(button 자식에 button이 존재할 수 없기 때문에)
또한 shadcn/ui는 lucide-react를 icon 라이브러리로 사용하여 ChevronDown 아이콘을 가져와 사용했습니다.

...
import { ChevronDown } from "lucide-react" ☑️

export default function TableDemo() {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead className="w-[100px]">Invoice</TableHead>
          <TableHead>Status</TableHead>
          <TableHead>Method</TableHead>
          <TableHead className="text-right">Amount</TableHead>
          <TableHead className="w-20"></TableHead> ☑️
        </TableRow>
      </TableHeader>
      <TableBody>
        {invoices.map((invoice) => (
          <Collapsible key={invoice.invoice} asChild>
            <TableRow>
              <TableCell className="font-medium">{invoice.invoice}</TableCell>
              <TableCell>{invoice.paymentStatus}</TableCell>
              <TableCell>{invoice.paymentMethod}</TableCell>
              <TableCell className="text-right">
                {invoice.totalAmount}
              </TableCell>
              <TableCell className="text-center"> ☑️
                <CollapsibleTrigger asChild>
                  <Button size="icon">
                    <ChevronDown />
                  </Button>
                </CollapsibleTrigger>
              </TableCell>
            </TableRow>
          </Collapsible>
        ))}
      </TableBody>
    </Table>
  )
}

3. CollapsibleContent 추가
trigger가 되는 TableRow 요소의 아랫줄에 확장 콘텐츠가 나타나야 되므로 반복되고 있는 TableRow의 형제에 CollapsibleContent 컴포넌트를 추가합니다.

  • 이때 CollapsibleasChild를 사용하여 자식 요소를 렌더링 하고 있는데, 자식 요소가 2개가 되었으므로 프래그먼트로 감싸주어야 합니다.
  • table의 구조를 지켜야 하기 때문에 CollapsibleContent의 자식 요소는 TableRowTableCell이 되어야 합니다.
  • CollapsibleContent의 너비는 colSpan 으로 지정 가능합니다. colSpan을 컬럼수 만큼 지정하여 한 줄을 가득 채우고 다른 태그들을 활용하여 내용을 작성할 수 있습니다.

"use client"를 작성해 주지 않으면 에러가 발생하여, client 컴포넌트로 사용해야 합니다. (참고링크)

"use client" ☑️

export default function TableDemo() {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead className="w-[100px]">Invoice</TableHead>
          <TableHead>Status</TableHead>
          <TableHead>Method</TableHead>
          <TableHead className="text-right">Amount</TableHead>
          <TableHead className="w-20"></TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {invoices.map((invoice) => (
          <Collapsible key={invoice.invoice} asChild>
            <> ☑️
              <TableRow>
                <TableCell className="font-medium">{invoice.invoice}</TableCell>
                <TableCell>{invoice.paymentStatus}</TableCell>
                <TableCell>{invoice.paymentMethod}</TableCell>
                <TableCell className="text-right">
                  {invoice.totalAmount}
                </TableCell>
                <TableCell className="text-center">
                  <CollapsibleTrigger asChild>
                    <Button size="icon">
                      <ChevronDown />
                    </Button>
                  </CollapsibleTrigger>
                </TableCell>
              </TableRow>
              <CollapsibleContent asChild> ☑️
                <TableRow>
                  <TableCell colSpan={5} className="py-5">
                    <div className="text-base font-bold mb-2">제목</div>
                    <div className="bg-white/10 p-5 rounded-lg">내용</div>
                  </TableCell>
                </TableRow>
              </CollapsibleContent>
            </>
          </Collapsible>
        ))}
      </TableBody>
    </Table>
  )
}

이렇게 확장/축소 가능한 테이블을 구현했습니다.
프래그먼트asChild 작성에만 유의한다면 shadcn/ui의 table과 collapsible 컴포넌트를 활용해 간단하게 구현할 수 있습니다.

마무리

자료 조사 중 JavaScript Rising Stars라는 글을 발견했습니다. 한 해 동안 가장 많은 GitHub Star를 받은 Javascript 프로젝트 순위에 대한 글인데, shadcn/ui가 2년 연속 1위를 했다고 합니다.
처음 shadcn/ui를 사용했을 때 컴포넌트가 복사된다는 개념을 접하고 신세계였는데 이러한 도구들을 적절하게 사용하면 컴포넌트 제작을 효율적으로 할 수 있을 것 같습니다.

profile
FE ✨

2개의 댓글

comment-user-thumbnail
2025년 3월 12일

부럽다 저는 쓰레기같은 Nuxt UI 썼는데

1개의 답글

관련 채용 정보