Tiro
Tiro Design
Components

Card

콘텐츠를 시각적으로 묶는 컨테이너 primitive. shadcn/ui Card API 기반.

Card는 관련 콘텐츠를 하나의 시각 블록으로 묶는 컨테이너 primitive다. shadcn/ui Card 구조를 따르며, Tiro 디자인 토큰으로 오버라이드한다.


Mobile

컨테이너 primitive — 플랫폼 분기 없음. Web 명세(surface · muted · featured · gallery · selectable variant) 그대로 mobile에 적용. 단, selectable CardItem Mobile 패턴에 따라 행 전체 hit area를 ≥44pt 확보할 것.


Anatomy

Card
├─ CardHeader
│  ├─ CardTitle
│  ├─ CardDescription
│  └─ CardAction   (optional — 우측 상단 슬롯: 버튼, 배지)
├─ CardContent
└─ CardFooter

CardHeaderCardAction이 존재할 때 자동으로 2열 grid로 전환한다 (has-data-[slot=card-action]). CardTitle · CardDescription은 좌열, CardAction은 우열 상단에 정렬된다.


Preview


Variants

shadcn Card는 variant prop이 없다. 시각적 변형은 CardclassName을 추가해 표현한다.

surface (기본)

<Card>...</Card>
// rounded-xl border-stone-100 bg-white — 기본값

muted

보조 카드, featured 카드 내부에 중첩하는 카드.

<Card className="bg-stone-50">...</Card>

info block

가이드, 도움말, 정책 설명처럼 제목+아이콘+불릿 텍스트를 묶는 정보 카드. 섹션 라벨은 카드 바깥에 두고, 카드 안에서는 본문 설명만 담는다.

<CardInfoBlock>
  <h4 className="flex items-center gap-2 label1Medium text-stone-800">
    <UserMinus size={16} strokeWidth={1.75} className="text-stone-400" />
    What happens after removal
  </h4>
  <ul className="list-disc space-y-2 pl-5 body2Regular text-stone-600">
    <li>The member is immediately removed from the team plan.</li>
    <li>No further subscription fees will be charged.</li>
  </ul>
</CardInfoBlock>

마케팅·플랜 업그레이드처럼 큰 임팩트가 필요한 카드. rounded-3xl, 넓은 여백.

<Card className="rounded-3xl py-8">
  <CardContent className="px-8">...</CardContent>
</Card>

템플릿·기능 그리드. overflow-hidden, hover 인터랙션.

<Card className="rounded-2xl overflow-hidden p-0 gap-0 cursor-pointer hover:bg-stone-50 transition-colors">
  {/* CardContent: media 영역 */}
  {/* CardContent: text 영역 */}
  {/* CardFooter */}
</Card>

selectable

옵션 선택형 카드. RadioGroup.Item root로 구현.

<RadioGroup value={value} onValueChange={setValue}>
  <Card
    asChild
    className={cn(
      "rounded-2xl cursor-pointer transition-colors",
      value === "coding"
        ? "border-stone-300 bg-stone-50"
        : "border-stone-100 bg-white hover:bg-stone-50",
    )}
  >
    <RadioGroup.Item value="coding">...</RadioGroup.Item>
  </Card>
</RadioGroup>

Slots

Slotdata-slotRequiredPurpose
Cardcard루트 컨테이너
CardHeadercard-headeroptionalTitle + Description + Action 묶음
CardTitlecard-title카드 제목 (title3SemiBold text-brown-800)
CardDescriptioncard-description보조 설명 (body2Regular text-stone-400)
CardActioncard-actionoptional헤더 우측 상단 슬롯 (버튼, 배지 등)
CardContentcard-contentrequired주 콘텐츠 (px-6)
CardFootercard-footeroptional하단 바 (flex items-center px-6)

CardAction 레이아웃 원리

CardHeaderhas-data-[slot=card-action] CSS selector를 감지해 자동으로 2열 grid로 전환한다.

[CardTitle   ] [CardAction]   ← 2열 grid (CardAction 존재 시)
[CardDescription]

CardAction이 없을 때는 1열로 흐른다. 별도 조건 분기가 필요 없다.

CardFooter 구분선

border-t 클래스를 CardFooter 내부에 추가하면 [.border-t]:pt-6 규칙이 자동으로 padding-top을 추가한다.

<CardFooter className="border-t border-stone-100">
  <Button size="sm">저장</Button>
</CardFooter>

Token Mapping

Defaultmutedinfo blockfeaturedgalleryselectable
radiusrounded-xl (12px)rounded-xlrounded-xlrounded-3xl (24px)rounded-2xl (16px)rounded-2xl (16px)
bgwhitestone-50whitewhitewhitewhite
bg selected/hoverstone-50stone-50
borderstone-100stone-100stone-100stone-100stone-100→200stone-100→300
vertical paddingpy-6py-6p-5py-8p-0 (media flush)p-5
shadownonenonenonenonenonenone
CardTitletitle3SemiBoldtitle3SemiBoldlabel1Medium stone-800title3SemiBoldtitle3SemiBoldtitle3SemiBold
CardDescriptionbody2Regular stone-400body2Regular stone-400body2Regular stone-600body2Regular stone-400caption1Regular stone-400caption1Regular stone-400

Flat-first: 모든 variant에서 shadow 없음. floating context(Popover 내부)에서만 shadow-md.


Section Label

섹션 제목은 Card 바깥에 둔다.

{/* ✅ 올바름 */}
<p className="caption1Regular text-stone-400 mb-3">Active plan</p>
<Card>...</Card>

{/* ❌ 피할 것 */}
<Card>
  <CardHeader><CardTitle>Active plan</CardTitle></CardHeader>
</Card>

단, featured 카드처럼 제목이 콘텐츠 자체일 때는 CardTitle을 내부에 쓴다.


Usage

import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";

{
  /* 기본 — 헤더 + 콘텐츠 + 푸터 */
}
<Card>
  <CardHeader>
    <CardTitle>Notifications</CardTitle>
    <CardDescription>회의 알림 설정을 관리합니다.</CardDescription>
    <CardAction>
      <Button variant="ghost" size="icon" aria-label="설정">
        <Settings size={16} />
      </Button>
    </CardAction>
  </CardHeader>
  <CardContent>{/* 콘텐츠 */}</CardContent>
  <CardFooter className="border-t border-stone-100">
    <Button size="sm">저장</Button>
  </CardFooter>
</Card>;

{
  /* muted */
}
<Card className="bg-stone-50">
  <CardContent>
    <CardTitle>Usage</CardTitle>
    <CardDescription>503 minutes · Unlimited</CardDescription>
  </CardContent>
</Card>;

{
  /* info block */
}
<CardInfoBlock>
  <h4 className="flex items-center gap-2 typography-label1NormalMedium text-tds-stone-800">
    <UserMinus className="h-4 w-4 text-tds-stone-400" />
    What happens after removal
  </h4>
  <ul className="list-disc space-y-2 pl-5 typography-body2NormalRegular text-tds-stone-600">
    <li>The member is immediately removed from the team plan.</li>
    <li>No further subscription fees will be charged.</li>
  </ul>
</CardInfoBlock>;

{
  /* featured */
}
<Card className="rounded-3xl py-8">
  <CardContent className="px-8">
    <CardTitle className="title3SemiBold mb-1">Basic</CardTitle>
    <CardDescription>Free · 9 team members</CardDescription>
  </CardContent>
</Card>;

{
  /* gallery */
}
<Card className="rounded-2xl p-0 gap-0 overflow-hidden cursor-pointer hover:bg-stone-50 transition-colors">
  <CardContent className="p-0">
    <div className="flex h-24 items-center justify-center bg-stone-50 border-b border-stone-100">
      <FileText size={18} className="text-stone-400" />
    </div>
  </CardContent>
  <CardContent className="px-4 pt-3 pb-2">
    <CardTitle>Meeting Minutes</CardTitle>
    <CardDescription>회의록을 자동으로 정리합니다.</CardDescription>
  </CardContent>
  <CardFooter className="px-4 pb-3 caption1Regular text-stone-400 justify-between">
    <span>Tiro</span>
    <span>50</span>
  </CardFooter>
</Card>;

{
  /* selectable — RadioGroup 결합 */
}
<RadioGroup
  value={mode}
  onValueChange={setMode}
  className="grid grid-cols-2 gap-3"
>
  {options.map((opt) => (
    <RadioGroup.Item key={opt.value} value={opt.value} asChild>
      <Card
        className={cn(
          "rounded-2xl cursor-pointer py-5 transition-colors",
          mode === opt.value
            ? "border-stone-300 bg-stone-50"
            : "border-stone-100 bg-white hover:bg-stone-50",
        )}
      >
        <CardContent>
          <CardTitle>{opt.title}</CardTitle>
          <CardDescription>{opt.desc}</CardDescription>
        </CardContent>
      </Card>
    </RadioGroup.Item>
  ))}
</RadioGroup>;

A11y

  • gallery / selectable: 클릭 가능한 카드는 button 또는 RadioGroup.Item root — div + onClick만 쓰지 않는다
  • CardAction의 icon-only 버튼은 aria-label 필수
  • selectable 카드 그룹은 RadioGroup.Rootaria-label 제공

Do / Don't

  • Do: CardActionCardHeader 안에 배치해 자동 2열 grid 활용
  • Do: 섹션 라벨은 카드 외부에 caption1Regular text-stone-400
  • Do: 내부에 row가 필요하면 List + Item 중첩
  • Do: gallery / selectable의 hover bg는 stone-50
  • Do: 제목+불릿 설명만 있는 가이드 블록은 CardInfoBlock 사용
  • Do: CardFooter에 구분선이 필요하면 className="border-t border-stone-100" 추가
  • Don't: shadow 추가 금지 — flat-first, floating context에서만 shadow-md
  • Don't: cool gray (slate-*, zinc-*, gray-*, neutral-*) 사용 금지
  • Don't: dark:* 스타일 추가 금지 — Tiro는 light mode only
  • Don't: interactive 카드(gallery/selectable) 안에 primary Button 배치 금지 — click target 중복
  • Don't: 카드 내부에서 임의 radius 재정의 금지 — variant 규칙을 따른다

On this page