ConcertCard
공연 카드 — 섀시 세 벌과 그 짝인 스켈레톤.
import { ConcertCard, ConcertCardSkeleton } from '@coldsurfers/surf-ui/cards'Silica Gel
롤링홀 · 서울 · 7.24 금
좋아한 아티스트와 같은 결
Mid-Air Thief
7.26 일
무신사 개러지
HYUKOH
8.15 금
올림픽홀
Parannoul
7.30 수 · 무신사 개러지
8.02 토
Oohyo
8.09 토
Jambinai
import { ConcertCard } from '@coldsurfers/surf-ui/cards'import { Button } from '@coldsurfers/surf-ui/primitives'import { sprinkles } from '@coldsurfers/surf-ui/sprinkles'export default function Example() { return ( <div style={{ width: '100%', display: 'grid', gap: 20, gridTemplateColumns: 'repeat(auto-fit, minmax(170px, 1fr))', }} > <ConcertCard.Framed initial="ㅅ" matchLabel="96% 취향" title="Silica Gel" meta="롤링홀 · 서울 · 7.24 금" footer={ // `footer` 는 슬롯이라 서식을 소비처가 준다 — 그 서식은 `sprinkles` 에서 온다. // 색을 `var(--surf-text-secondary)` 로 손으로 박으면 토큰이 바뀌는 날 이 자리가 안 따라온다. <p className={sprinkles({ margin: '0', marginTop: '1', color: 'textSecondary' })}> 좋아한 아티스트와 같은 결 </p> } coverAction={<Button size="sm">담기</Button>} /> <ConcertCard.Bare initial="ㅁ" title="Mid-Air Thief" meta="7.26 일" footer={<p className={sprinkles({ margin: '0', color: 'textSecondary' })}>무신사 개러지</p>} reserveTitleLines /> {/* 같은 섀시, 비율만 다르다. 공연 포스터는 세로로 인쇄되므로 칸이 세로로 설 수 있는 자리에서는 `portrait` 이 위아래를 안 자른다 — 기본 `landscape` 와 나란히 두면 잘리는 정도가 바로 보인다. */} <ConcertCard.Bare initial="ㅎ" coverRatio="portrait" title="HYUKOH" meta="8.15 금" footer={<p className={sprinkles({ margin: '0', color: 'textSecondary' })}>올림픽홀</p>} reserveTitleLines /> {/* `initial` 도 `footer` 도 없다 — 이 섀시는 포스터가 없으면 note 면만 남기고, 공연장 줄을 아무 데도 안 그린다. 평평한 `ConcertCardProps` 에서는 둘 다 통과했다. */} <ConcertCard.Cover eyebrow="INDIE ROCK" title="Parannoul" meta="7.30 수 · 무신사 개러지" /> {/* 작은 칸은 문이 다르다. `size` 플래그가 아니라 문 이름이 크기를 말하고, `footer` 는 이쪽에만 있다. */} <ConcertCard.CoverCompact title="Oohyo" meta="8.02 토" footer="예스24 라이브홀" coverAction={<Button size="sm">담기</Button>} /> {/* 위와 같은 그림, 더 큰 칸. 여러 열 그리드처럼 칸이 넓은 자리에서 세로 포스터가 덜 잘린다. */} <ConcertCard.CoverLarge title="Jambinai" meta="8.09 토" footer="무신사 개러지" coverAction={<Button size="sm">담기</Button>} /> </div> )}도메인은 이름에만 남았습니다. props 는 title · meta · initial 이라 이 컴포넌트는 공연
스키마를 모릅니다 — 날짜 포맷도, 공연장 이름 조합도 소비처가 문자열로 만들어 줍니다.
커버는 두 겹입니다. 바닥은 note 면 위에 옅은 이니셜이고 언제나 카드가 그립니다. 그 위를
포스터가 덮는데, 카드가 채우면 posterUrl 이고 소비처가 채우면 cover 슬롯입니다. 덮을 게
없으면(포스터 부재 · 로드 실패) 바닥이 그대로 드러납니다.
바닥 색면에 tone 축이 없습니다. 예전엔 6톤을 id 로 흩었는데, 그 면이 뜻하는 건 「아직
그림이 없다」라서 Skeleton(API 대기)과 밝기가 갈리면 안 됩니다 — 둘 다 note 입니다.
색 다양성이 곧 편집인 자리(ArticleCard · LeadFeature)는 여전히 6톤을 씁니다.
섀시 세 벌
| 문 | variant | 모양 | 쓰는 자리 |
|---|---|---|---|
ConcertCard.Framed | framed(기본) | 테두리 · 배경이 있는 액자 | 목록 표면 |
ConcertCard.Bare | bare | 섀시 없이 포스터 + 아래 3줄. 비율은 coverRatio | 트렌딩 레일 · 담은 공연 그리드 · 목록 그리드 |
ConcertCard.Cover | cover | 세로 커버 한 장에 eyebrow · 제목을 얹고 아래 메타 1줄 | 날짜 피드 |
ConcertCard.CoverCompact | cover + size="compact" | 같은 커버, 날짜 · 제목 · 공연장 셋 다 커버 안 | 레일 |
ConcertCard.CoverLarge | cover + size="large" | CoverCompact 와 같은 그림, 더 큰 칸 | 여러 열 그리드 |
문은 다섯인데 섀시는 셋입니다. 커버만 크기로 갈라 냈습니다 — 이유는 아래.
framed 전용 축(matchLabel) 과 cover 전용 축(eyebrow · size)이 갈리는 건 시안에 그
자리가 없어서입니다.
문은 둘, 하나는 관대하고 하나는 정확합니다
한 줄이 두 가지로 써집니다. 가리키는 컴포넌트가 같아서 렌더 결과도 같습니다.
<ConcertCard variant="cover" initial="ㅍ" title="Parannoul" meta="7.30 수" />
<ConcertCard.Cover title="Parannoul" meta="7.30 수" />variant 문은 관대합니다. 평평한 ConcertCardProps 가 세 섀시의 축을 합집합으로 들고
있어서 안 먹는 prop 이 타입을 통과합니다 — cover 에 matchLabel 을 주거나 framed 에
size 를 줘도 조용히 무시될 뿐입니다. 그중 initial 은 반대로 걸립니다. cover 섀시는 그
자모를 아무 데도 안 그리는데 타입상 필수라, 위 첫 줄처럼 쓰지도 않을 글자를 지어내야 합니다.
정적 프로퍼티 문은 정확합니다. 그 섀시가 그리는 것만 받고 나머지는 컴파일 에러입니다 —
FramedConcertCardProps · BareConcertCardProps · CoverConcertCardProps ·
CoverCompactConcertCardProps 가 각 문이 받는 것의 전부이고 자동완성도 그만큼만 제안합니다.
ConcertCard.Cover 에는 initial 자리도 footer 자리도 없습니다.
둘은 공존합니다. variant 문은 없어지지 않습니다 — 그 축은 native 레인이 같이 읽는
계약(ConcertCardVariant)이고, 문 이름을 바꾸면 발행된 패키지에서 major 입니다. 기존 코드는
그대로 두고, 새 코드는 정확한 쪽으로 옵니다.
커버만 문이 셋인 이유
size 는 크기 플래그처럼 생겼지만 슬롯이 갈리는 축입니다 — full 밖에서만 footer 가
그려지고, meta 가 커버 밖에서 안으로 옮겨 갑니다. 한 문에 플래그로 두면 full 쪽에서
footer 가 initial 과 똑같은 병에 걸립니다. 넘길 수 있는데 아무것도 안 그리는 prop 이요.
크기를 문 이름에 박으면 그 병이 성립하지 않습니다. 플래그가 바깥에서 사라지기 때문입니다.
CoverCompact 와 CoverLarge 사이는 슬롯이 같아 진짜 크기 축입니다. 그런데도 문으로
나눈 건 커버 문만 플래그를 되받으면 규율이 반쪽이 되기 때문입니다 — 「커버는 문으로 고른다」
하나만 외우면 되게 둡니다.
<ConcertCard.Cover footer="예스24 라이브홀" … /> // 컴파일 에러 — 이 섀시는 안 그린다
<ConcertCard.CoverCompact footer="예스24 라이브홀" … /> // 이쪽이 그린다cover 의 크기 축
정적 프로퍼티 문에는 이 축이 없습니다 — 크기가 문 이름에 박혀 있습니다. 아래 표는
variant="cover" 문이 쓰는 size prop 이고, 오른쪽 열이 그 짝인 문입니다.
size | 문 | 높이 | 글이 서는 자리 |
|---|---|---|---|
full(기본) | ConcertCard.Cover | 430 / 500 | 제목만 커버 위. 메타는 커버 밖 한 줄 |
compact | ConcertCard.CoverCompact | 290 / 300 | 날짜(meta) → 제목 → 공연장(footer) 셋 다 커버 안 하단 |
large | ConcertCard.CoverLarge | 420 / 460 | compact 와 같다 (여백만 24) |
compact · large 는 스크림이 카드 전체 높이에 걸립니다. 받쳐줄 글이 세 줄이고 우상단 담기 버튼까지
얹히기 때문입니다. full 은 제목 한 줄만 받치면 되므로 하단에만 깝니다.
footer 는 full 에서만 안 그려집니다 — 커버 아래에 메타 한 줄만 두는 섀시이기 때문입니다.
variant 문에서는 그게 조용한 무시로 끝나고, ConcertCard.Cover 에서는 컴파일 에러입니다.
높이 290 / 300 은 기본값이지 상한이 아닙니다. 더 큰 칸이 필요한 그리드는 축을 하나 더 여는
대신 className 으로 덮습니다. 칸 크기는 그리드의 사정이지 카드의 축이 아닙니다.
reserveTitleLines 는 그리드에서만 켭니다. 카드가 가로로 줄지어 설 때 제목 줄 수가 다른
이웃끼리 날짜 · 공연장 줄이 어긋나기 때문입니다. 레일에서는 시안이 1줄 제목 기준이라 켜면 제목
아래 빈 줄이 생깁니다. framed 는 자체 minHeight 로 항상 예약하므로 이 prop 을 보지 않습니다.
bare 의 커버 비율 축
coverRatio 는 bare 섀시만 봅니다 — framed · cover 는 자기 비율을 갖습니다.
coverRatio | 비율 | 포스터가 잘리는 정도 | 쓰는 자리 |
|---|---|---|---|
landscape(기본) | 4:3 | 가장 많이 | 트렌딩 레일 |
square | 1:1 | 중간 | 폭이 좁은 레일 |
portrait | 3:4 | 안 잘린다 | 여러 열 목록 그리드 |
공연 포스터는 세로로 인쇄됩니다. 가로 · 정사각 칸에 넣으면 object-fit: cover 가 위아래를
잘라 제목 줄이나 출연자 이름이 사라집니다 — 칸이 세로로 설 수 있는 자리라면 portrait 이
원본을 그대로 보여 줍니다.
임의 비율을 받지 않고 축으로 가둡니다. 커버는 카드 정체성이라 지면마다 다른 비율이 생기면 같은 카드로 안 보입니다. 값을 늘리기 전에 묻습니다 — 새 지면이 생겼나, 아니면 한 자리가 취향으로 다르고 싶은 건가.
슬롯 둘
footer 는 meta 아래(매칭 근거 줄 등), coverAction 은 커버 위 담기 버튼 자리입니다. 자리와
정렬만 카드가 정하고 내용물은 소비처가 줍니다.
그래서 서식도 소비처가 줍니다. 위 예시의 footer 는 sprinkles({ color: 'muted' }) 로
색을 읽습니다 — var(--surf-text-secondary) 를 인라인 style 에 손으로 박으면 토큰이 바뀌는 날 그 자리가
안 따라옵니다. 커버의 compact·large 만은 카드가 coverVenue 래퍼로 서식을 주는데, 그 비대칭은
아직 남아 있습니다.
카드 전체가 Link 안에 있으면 coverAction 의 버튼이 preventDefault 로 내비게이션을
막아야 합니다. 카드는 클릭을 모릅니다.
ConcertCardSkeleton
HYUKOH
예스24 라이브홀 · 8.2 토
8.02 토
Oohyo
import { ConcertCard, ConcertCardSkeleton } from '@coldsurfers/surf-ui/cards'export default function Example() { return ( <div style={{ width: '100%', display: 'grid', gap: 20, gridTemplateColumns: 'repeat(auto-fit, minmax(170px, 1fr))', }} > {/* 문 이름이 짝이다 — 스켈레톤과 실카드를 같은 이름으로 부르면 치수가 어긋날 자리가 없다. */} <ConcertCardSkeleton.Framed /> <ConcertCard.Framed initial="ㅎ" title="HYUKOH" meta="예스24 라이브홀 · 8.2 토" /> <ConcertCardSkeleton.CoverCompact /> <ConcertCard.CoverCompact title="Oohyo" meta="8.02 토" footer="예스24 라이브홀" /> </div> )}같은 섀시(라운드 · 테두리 · 커버 면)라 로드 전후에 지면이 튀지 않습니다. CoverBlock 을
재사용하므로 면이 실카드와 정확히 같은 소스에서 나옵니다.
면에 축이 없습니다 — 스켈레톤이 뜻하는 것도 「아직 그림이 없다」라 Skeleton(막대들)과 같은
note 입니다.
문 이름이 짝입니다
실카드의 문 다섯과 1:1 입니다 — ConcertCardSkeleton.Framed · .Bare · .Cover ·
.CoverCompact · .CoverLarge. 같은 이름으로 부르면 섀시도 치수도 어긋날 자리가 없습니다.
<ConcertCardSkeleton.CoverLarge /> // 로딩
<ConcertCard.CoverLarge … /> // 로드 완료 — 같은 420/460플래그(variant · size)도 그대로 삽니다. 다만 그쪽은 짝을 소비처가 기억해야 합니다 —
카드만 size="large" 로 바꾸고 스켈레톤을 framed 에 두면 타입은 통과하고 화면이 로드
순간에 280 → 460 으로 튑니다. 새 코드는 문으로 오는 편이 낫습니다.
reserveTitleLines 와 coverRatio 는 bare 섀시만 봅니다 — 그래서 ConcertCardSkeleton.Bare
에만 열려 있습니다. 둘 다 실카드와 같은 값을 넘겨야 합니다. 커버 비율이 어긋나면 같은 폭에서
높이가 크게 달라지는데(landscape 4:3 ↔ portrait 3:4 는 약 1.78배), 그러면 로드되는 순간
그리드가 통째로 밀립니다 — 이 컴포넌트가 막으려던 바로 그 점프입니다.
<ConcertCardSkeleton.Bare coverRatio="portrait" reserveTitleLines /> // 로딩
<ConcertCard.Bare coverRatio="portrait" reserveTitleLines … /> // 로드 완료 — 같은 3:4커버 스켈레톤은 치수를 옮겨 적지 않고 실카드의 coverCover recipe 를 그대로 가리킵니다.
실카드에 크기가 또 붙어도 자동으로 따라옵니다.
Skeleton 과는 다른 물건입니다. 그쪽은 치수를 받는 축이고,
이쪽은 카드 모양에 고정된 사본입니다. 축으로 이 카드를 흉내 내려면 소비처가 커버 비율 ·
제목 줄 수 · 본문 간격을 다시 적어야 하고, 그 사본이 실카드와 어긋나는 순간을 아무도 못
잡습니다.
Props
아래 표는 variant 문 — 세 섀시의 축을 합친 목록입니다. 어느 섀시가 무엇을 보는지는
「섀시 세 벌」 이 말하고, 정적 프로퍼티로 들어가면 타입이 그만큼만 남깁니다.
| 문 | props 타입 | 합집합에서 빠지는 것 |
|---|---|---|
ConcertCard.Framed | FramedConcertCardProps | eyebrow · size · coverRatio · reserveTitleLines |
ConcertCard.Bare | BareConcertCardProps | matchLabel · eyebrow · size |
ConcertCard.Cover | CoverConcertCardProps | initial · footer · size · matchLabel · coverRatio · reserveTitleLines |
ConcertCard.CoverCompact | CoverCompactConcertCardProps | initial · size · matchLabel · coverRatio · reserveTitleLines |
ConcertCard.CoverLarge | CoverLargeConcertCardProps | initial · size · matchLabel · coverRatio · reserveTitleLines |
다섯 다 cards 진입점에서 함께 나갑니다. CoverLargeConcertCardProps 는 CoverCompact 쪽의
별칭입니다 — 둘이 받는 것이 글자 그대로 같고, 그래도 이름을 두는 건 문↔타입 짝을 깨지 않기
위해서입니다.
CoverCompactConcertCardProps 는 키 목록을 다시
적지 않고 CoverConcertCardProps 에 footer 를 얹은 것입니다 — 두 벌로 적으면 커버 축이
하나 늘 때 한쪽만 따라옵니다.
이름을 ConcertCardBareProps 로 못 둔 건 그 이름이 계약(contract/concert-card.ts)에서
두 구현이 공유하는 props 라는 다른 뜻으로 이미 쓰이고 있어서입니다 — 넷을 통째로 형용사
먼저로 뒀습니다.
Prop
Type
그 밖의 ConcertCardBareProps, CardDomProps 속성은 루트 엘리먼트로 그대로 통과한다.
ConcertCardSkeleton
Prop
Type
그 밖의 HTMLAttributes<HTMLDivElement> 속성은 루트 엘리먼트로 그대로 통과한다.