Card
콘텐츠를 시각적으로 묶는 컨테이너 primitive. shadcn/ui Card API 기반.
Card는 관련 콘텐츠를 하나의 시각 블록으로 묶는 컨테이너 primitive다. shadcn/ui Card 구조를 따르며, Tiro 디자인 토큰으로 오버라이드한다.
Mobile
컨테이너 primitive — 플랫폼 분기 없음. Web 명세(surface · muted · featured · gallery · selectable variant) 그대로 mobile에 적용. 단, selectable Card는 Item Mobile 패턴에 따라 행 전체 hit area를 ≥44pt 확보할 것.
Anatomy
Card
├─ CardHeader
│ ├─ CardTitle
│ ├─ CardDescription
│ └─ CardAction (optional — 우측 상단 슬롯: 버튼, 배지)
├─ CardContent
└─ CardFooterCardHeader는 CardAction이 존재할 때 자동으로 2열 grid로 전환한다 (has-data-[slot=card-action]). CardTitle · CardDescription은 좌열, CardAction은 우열 상단에 정렬된다.
Preview
Variants
shadcn Card는 variant prop이 없다. 시각적 변형은 Card에 className을 추가해 표현한다.
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>featured
마케팅·플랜 업그레이드처럼 큰 임팩트가 필요한 카드. rounded-3xl, 넓은 여백.
<Card className="rounded-3xl py-8">
<CardContent className="px-8">...</CardContent>
</Card>gallery
템플릿·기능 그리드. 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
| Slot | data-slot | Required | Purpose |
|---|---|---|---|
Card | card | — | 루트 컨테이너 |
CardHeader | card-header | optional | Title + Description + Action 묶음 |
CardTitle | card-title | — | 카드 제목 (title3SemiBold text-brown-800) |
CardDescription | card-description | — | 보조 설명 (body2Regular text-stone-400) |
CardAction | card-action | optional | 헤더 우측 상단 슬롯 (버튼, 배지 등) |
CardContent | card-content | required | 주 콘텐츠 (px-6) |
CardFooter | card-footer | optional | 하단 바 (flex items-center px-6) |
CardAction 레이아웃 원리
CardHeader는 has-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
| Default | muted | info block | featured | gallery | selectable | |
|---|---|---|---|---|---|---|
| radius | rounded-xl (12px) | rounded-xl | rounded-xl | rounded-3xl (24px) | rounded-2xl (16px) | rounded-2xl (16px) |
| bg | white | stone-50 | white | white | white | white |
| bg selected/hover | — | — | — | — | stone-50 | stone-50 |
| border | stone-100 | stone-100 | stone-100 | stone-100 | stone-100→200 | stone-100→300 |
| vertical padding | py-6 | py-6 | p-5 | py-8 | p-0 (media flush) | p-5 |
| shadow | none | none | none | none | none | none |
| CardTitle | title3SemiBold | title3SemiBold | label1Medium stone-800 | title3SemiBold | title3SemiBold | title3SemiBold |
| CardDescription | body2Regular stone-400 | body2Regular stone-400 | body2Regular stone-600 | body2Regular stone-400 | caption1Regular stone-400 | caption1Regular 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.Itemroot —div+onClick만 쓰지 않는다 CardAction의 icon-only 버튼은aria-label필수- selectable 카드 그룹은
RadioGroup.Root에aria-label제공
Do / Don't
- Do:
CardAction을CardHeader안에 배치해 자동 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 규칙을 따른다
