surf-uiby COLDSURF
Patterns

ConcertCard

공연 카드 — 섀시 세 벌과 그 짝인 스켈레톤.

import { ConcertCard, ConcertCardSkeleton } from '@coldsurfers/surf-ui/cards'
96% 취향

Silica Gel

롤링홀 · 서울 · 7.24 금

좋아한 아티스트와 같은 결

Mid-Air Thief

7.26 일

무신사 개러지

HYUKOH

8.15 금

올림픽홀

INDIE ROCK

Parannoul

7.30 수 · 무신사 개러지

8.02 토

Oohyo

예스24 라이브홀

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.Framedframed(기본)테두리 · 배경이 있는 액자목록 표면
ConcertCard.Barebare섀시 없이 포스터 + 아래 3줄. 비율은 coverRatio트렌딩 레일 · 담은 공연 그리드 · 목록 그리드
ConcertCard.Covercover세로 커버 한 장에 eyebrow · 제목을 얹고 아래 메타 1줄날짜 피드
ConcertCard.CoverCompactcover + size="compact"같은 커버, 날짜 · 제목 · 공연장 셋 다 커버 안레일
ConcertCard.CoverLargecover + 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.Cover430 / 500제목만 커버 위. 메타는 커버 밖 한 줄
compactConcertCard.CoverCompact290 / 300날짜(meta) → 제목 → 공연장(footer) 셋 다 커버 안 하단
largeConcertCard.CoverLarge420 / 460compact 와 같다 (여백만 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가장 많이트렌딩 레일
square1:1중간폭이 좁은 레일
portrait3: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

예스24 라이브홀
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.FramedFramedConcertCardPropseyebrow · size · coverRatio · reserveTitleLines
ConcertCard.BareBareConcertCardPropsmatchLabel · eyebrow · size
ConcertCard.CoverCoverConcertCardPropsinitial · footer · size · matchLabel · coverRatio · reserveTitleLines
ConcertCard.CoverCompactCoverCompactConcertCardPropsinitial · size · matchLabel · coverRatio · reserveTitleLines
ConcertCard.CoverLargeCoverLargeConcertCardPropsinitial · 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> 속성은 루트 엘리먼트로 그대로 통과한다.

On this page