` 한 겹이라 두지 않습니다. chrome 은 `Page` 의 자식으로 놓습니다 |
| `Section` | 소비처 34곳 중 그대로 맞는 건 3곳이었습니다. 나머지는 DS 가 준 걸 도로 지우는 className 을 답니다 |
| `Bar.Left` · `Bar.Right` | 소비처가 아직 하나뿐이라 보류입니다. 두 번째가 생기면 엽니다 |
| 헤더 · 푸터 **내용물** | 라우터 · 세션 · i18n · 계측을 뭅니다. 앱의 것입니다 |
---
# 누름 (/docs/foundations/press)
```tsx
import { Button } from '@coldsurfers/surf-ui/primitives'
export default function Example() {
return (
<>
sm
md
cta
lg
읽기
>
)
}
```
위 버튼을 눌러 보세요. 투명도(`BUTTON_SPEC.pressedOpacity`)에 더해 **살짝 줄어듭니다.**
값은 `PRESS_SPEC` 하나라 웹 `:active` 와 RN 스프링이 같은 비율로 줄어듭니다.
| 크기 | 비율 | 쓰는 곳 |
| ------- | ---- | --------------------------------------------------------------------------------- |
| `large` | 0.97 | `PinCard` · `cta`·`lg` 버튼 — 넓은 면은 조금만 줄여야 흔들려 보이지 않는다 |
| `small` | 0.94 | `sm`·`md` 버튼 · `IconButton` · `Chip` · `RowAction` · `ReactionBar` · `TextButton` |
동작 줄이기 설정(`prefers-reduced-motion` · iOS/Android 접근성)이면 두 레인 다 줄이지 않습니다.
## 직접 만든 버튼에 붙이기 [#직접-만든-버튼에-붙이기]
웹은 `@coldsurfers/surf-ui/style-utils` 의 `pressable` 을 스타일에 펼칩니다. 돌아오는 애니메이션은
`transition` 에 `scale` 을 더해야 붙습니다.
```ts
import { pressable } from '@coldsurfers/surf-ui/style-utils'
export const myButton = style({ transition: 'scale 150ms', ...pressable('small') })
```
`pressable` 은 `@media` 하나를 돌려줍니다. 받는 규칙에 이미 `@media` 가 있으면 덮어쓰므로 `style([a, b])` 로 합칩니다.
---
# 여백 (/docs/foundations/spacing)
키는 `4px × N` 입니다 — Tailwind 수치 스케일과 같은 좌표계입니다.
| 토큰 | 값 |
| --- | --- |
| `--spacing-0` | `0` |
| `--spacing-1` | `0.25rem` |
| `--spacing-2` | `0.5rem` |
| `--spacing-3` | `0.75rem` |
| `--spacing-4` | `1rem` |
| `--spacing-5` | `1.25rem` |
| `--spacing-6` | `1.5rem` |
| `--spacing-7` | `1.75rem` |
| `--spacing-8` | `2rem` |
| `--spacing-10` | `2.5rem` |
| `--spacing-12` | `3rem` |
| `--spacing-14` | `3.5rem` |
| `--spacing-16` | `4rem` |
| `--spacing-20` | `5rem` |
| `--spacing-24` | `6rem` |
| `--spacing-1-5` | `0.375rem` |
| `--spacing-2-5` | `0.625rem` |
| `--spacing-3-5` | `0.875rem` |
## 반 눈금이 있는 이유 [#반-눈금이-있는-이유]
`1.5` · `2.5` · `3.5` · `7` · `14` · `20` · `24` 는 **빈 눈금을 메운 것**입니다. 실측에서
spacing 유틸 2,045건 중 388건(19%)이 스케일 밖이었고, 그중 336건이 이 일곱 단계에 몰려 있었습니다.
**이건 "이름에 값을 적어 넣는" 문제가 아닙니다.** [fontSize](/docs/foundations/typography) 는
`13` · `15` 같은 숫자 키를 거부하고 `sm` · `base` 로 접었습니다 — 거기선 이름이 *역할*이고 숫자가
드리프트였습니다. 반면 spacing 은 **처음부터 수치 좌표계**였고, 빠진 눈금을 채우는 건 새 명명
철학이 아니라 같은 격자의 완성입니다.
잔여 52건(`0.5` · `9` · `11` · `18` · `28` · `32` · `60`)은 승격하지 않습니다 —
`style()` 리터럴로 남습니다.
## sprinkles [#sprinkles]
여백을 유틸로 쓸 땐 `sprinkles` 진입점입니다.
```ts
import { sprinkles } from '@coldsurfers/surf-ui/sprinkles'
sprinkles({ paddingY: '20', gap: '4' })
```
`sprinkles` 는 **배럴에 없습니다.** 런타임 맵이 JS 38 kB(gzip 5.7 kB)라, 배럴에서 재수출하면
`vars` 한 줄 쓰려고 `.` 을 연 소비처까지 그걸 뭅니다(실측: `index.js` 3.2 → 41.2 kB).
반응형 축은 `mobile` · `tablet` · `desktop` 세 조건을 갖습니다.
```ts
sprinkles({ paddingX: { mobile: '4', desktop: '10' } })
```
---
# 타이포 (/docs/foundations/typography)
## font family [#font-family]
| 키 | 쓰임 |
| ------- | --------------------------------------- |
| `sans` | 본문 · UI. 한국어 권위 + Latin 보조(Pretendard) |
| `serif` | 헤드라인 · 매거진 마스트헤드(Instrument Serif) |
| `mono` | 메타 · 라벨 · 코드. 매거진 콜로폰 톤(JetBrains Mono) |
## text style — 역할 하나로 고른다 [#text-style--역할-하나로-고른다]
크기 · 행간 · 자간을 따로 고르는 대신 **역할 이름 하나**를 고릅니다.
```tsx
import { text } from '@coldsurfers/surf-ui'
import { sprinkles } from '@coldsurfers/surf-ui/sprinkles'
export default function Example() {
return (
이번 주말, 홍대
금요일 밤의 라인업
크기 하나가 아니라 역할 하나를 고릅니다. 행간과 자간이 그 역할에 맞게 같이 따라오므로, 같은
자리에 선 글자는 지면이 달라도 같은 리듬으로 읽힙니다.
{/* 램프는 색을 묶지 않는다 — 같은 역할이 표면에 따라 다른 색을 입는 건 정상이다. */}
보조 문장은 한 단 아래에서 같은 규칙을 따릅니다.
라벨
21:00 입장
ISSUE 07
)
}
```
```ts
import { text } from '@coldsurfers/surf-ui'
text('body')
text('label', { weight: 'medium' })
```
| 이름 | fontSize | lineHeight | letterSpacing | 자리 |
| --------- | -------- | ---------- | ------------- | ------------- |
| `heading` | `2xl` | `tight` | `tight` | 섹션 제목 |
| `title` | `xl` | `snug` | `normal` | 카드 · 블록 제목 |
| `body` | `base` | `relaxed` | `normal` | 읽는 본문 |
| `bodySm` | `sm` | `normal` | `normal` | 보조 본문 · UI 문장 |
| `label` | `xs` | `snug` | `normal` | 라벨 · 메타 |
| `labelSm` | `2xs` | `snug` | `normal` | 좁은 라벨 · 수치 |
| `micro` | `3xs` | `snug` | `normal` | 마이크로 라벨 |
**왜 합성인가.** 크기만 토큰이고 행간 · 자간을 호출부가 각자 정하면 같은 역할의 글자가 지면마다
다르게 섭니다. 실측에서 `fontSize` 가 들어간 `sprinkles` 호출 478건에 구별되는 조합이 46가지였고,
그중 대다수가 `lineHeight` 를 비운 채였습니다 — 비우면 상속값이 들어오니 *정해진 적 없는 행간*이
화면에 서는 셈입니다.
**`fontWeight` 는 묶지 않습니다.** 같은 크기에 굵기가 2\~3종씩 붙어서, 이름에 넣으면 7단계가
21개가 됩니다. 그건 램프가 아니라 목록입니다. 색도 같은 이유로 밖에 둡니다.
램프 밖 크기가 필요하면 덮지 말고 escape hatch 를 씁니다 — `sprinkles({ fontSize: 'lg' })`.
램프 위에 크기만 덮으면 행간 · 자간이 그 크기에 맞지 않은 채 남습니다. `lg`(18px)에 단계를 주지
않은 것도 같은 이유입니다 — 실사용 8건이 `body` 와 `title` 사이에 흩어져 역할이 서지 않았습니다.
`text()` 는 `ds-components` 레이어라, 호출자가 얹는 `sprinkles` 유틸이 **항상 이깁니다.**
바닥을 램프로 깔고 한 속성만 유틸로 덮는 것이 성립합니다.
## font size [#font-size]
**rem 단일 축**입니다. 값은 전부 16px root 기준 정수 px 등가입니다. px 로 직접 적지 않는 이유는
사용자의 브라우저 기본 글자 크기 설정을 따라가야 해서입니다.
| 토큰 | 값 |
| --- | --- |
| `--font-size-3xs` | `0.625rem` |
| `--font-size-2xs` | `0.6875rem` |
| `--font-size-xs` | `0.75rem` |
| `--font-size-sm` | `0.875rem` |
| `--font-size-base` | `1rem` |
| `--font-size-lg` | `1.125rem` |
| `--font-size-xl` | `1.25rem` |
| `--font-size-2xl` | `1.5rem` |
| `--font-size-3xl` | `1.875rem` |
| `--font-size-4xl` | `2.25rem` |
| `--font-size-5xl` | `3rem` |
| `--font-size-6xl` | `3.75rem` |
`3xs` · `2xs` 는 라벨 축입니다. 실측에서 9~~11px 사용의 uppercase 41~~68% · tracking 69~~88% ·
mono 지배가 12px 위와 뚜렷하게 갈렸고, 파일당 1~~2종만 쓰여 **역할이 실재**했습니다.
반면 12.5~~17px 은 한 파일이 3~~5종을 섞어 쓰는 드리프트라 단계를 늘리지 않고 기존 네 단계
(`xs` · `sm` · `base` · `lg`)로 접었습니다.
**이름에 값을 적어 넣지 않습니다.** `13` · `15` 같은 숫자 키를 거부한 이유는 하나입니다 — 토큰의
값을 바꿀 때 사용처를 전부 고쳐야 하면 간접화가 0 이라 그건 토큰이 아닙니다.
## line height [#line-height]
| 토큰 | 값 |
| --- | --- |
| `--line-height-tight` | `1.2` |
| `--line-height-snug` | `1.4` |
| `--line-height-normal` | `1.6` |
| `--line-height-relaxed` | `1.75` |
## font weight [#font-weight]
| 토큰 | 값 |
| --- | --- |
| `--font-weight-light` | `300` |
| `--font-weight-regular` | `400` |
| `--font-weight-medium` | `500` |
| `--font-weight-semibold` | `600` |
| `--font-weight-bold` | `700` |
| `--font-weight-black` | `900` |
## letter spacing [#letter-spacing]
**본문·UI 축**입니다. 세 단계뿐이고, 크기가 아니라 역할로 고릅니다.
| 토큰 | 값 |
| --- | --- |
| `--letter-spacing-none` | `0` |
| `--letter-spacing-normal` | `-0.02em` |
| `--letter-spacing-tight` | `-0.03em` |
```tsx
import { sprinkles } from '@coldsurfers/surf-ui/sprinkles'
const SENTENCE = '서울의 공연을 하나의 지면에서 발견합니다'
export default function Example() {
return (
{/* 본문 — 한글은 0 에 두면 헐렁하다. 같은 문장을 두 줄로 나란히 둬야 차이가 보인다. */}
{SENTENCE}
{SENTENCE}
위 none · 아래 normal
{/* 헤드라인 — 큰 글자는 더 조여야 같은 무게로 읽힌다. */}
이번 주말, 홍대
{/* mono — 격자가 곧 리듬이라 조이지 않는다. */}
2026-09-11 · 21:00 · KRW 25,000
)
}
```
`sprinkles({ letterSpacing })` 로 얹거나, `.css.ts` 에서 `vars.letterSpacing.normal` 을 읽습니다.
어느 쪽이든 같은 `--letter-spacing-*` 변수를 가리킵니다.
이 축이 왜 필요했는지: 아래 `editorialType` 3그룹만 `letterSpacing` 을 갖고 있어서, 본문·UI 는
브라우저 기본값(0)이었습니다. Pretendard 로 한글을 0 에 두면 글자가 느슨하게 벌어져 같은
크기에서 라틴보다 헐렁하게 읽힙니다. 그래서 소비처가 자기 CSS 로 이 자리를 메우고 있었습니다.
큰 글자는 더 조여야 같은 무게로 읽히므로 24px 이상 헤드라인은 `tight` 로 갑니다. mono 는
`none`(0) 입니다 — 고정폭 글리프는 격자가 곧 리듬이라, 조이면 코드·수치가 뭉칩니다.
**`editorialType` 의 자간과 겹쳐 쓰지 않습니다.** 저쪽은 크기까지 묶은 합성 슬롯(clamp display ·
uppercase eyebrow)이고 이쪽은 어느 크기에든 얹는 단일 속성입니다. 합성 슬롯을 쓰는 자리에서는
그쪽 값이 이미 자간을 정하므로 이 토큰을 덧대지 않습니다.
## text box [#text-box]
알약 · 칩처럼 **상자 안 한 줄 글자**를 눈으로 가운데에 세울 때 씁니다.
```tsx
새 11
```
`textBox: 'cap'` 은 글자 상자를 대문자 높이에서 기준선까지 자릅니다(`text-box: trim-both cap alphabetic`).
Pretendard 는 위 여백이 아래보다 커서, 상자만 flex 로 가운데 두면 글자가 0.5–1px 뜹니다.
패딩 1px 로 메우면 크기가 바뀔 때마다 다시 재야 합니다.
**flex · grid 상자 자신에는 먹지 않습니다.** 안쪽 블록 한 겹에 겁니다. 지원하지 않는
브라우저(Firefox)에서는 자르지 않은 원래 상자로 남습니다 — 깨지지는 않습니다.
## editorial type [#editorial-type]
매거진 톤의 합성 타이포 슬롯입니다. `eyebrow` · `display` · `caption` 세 그룹이 있고,
`letterSpacing` 과 `lineHeight` 만 자기 값을 갖습니다 — 크기는 위 `fontSize` 를 읽습니다.
그 슬롯을 컴포넌트로 쓰는 쪽이 [Eyebrow](/docs/components/eyebrow) 입니다.
---
# AnimatedTabBar (/docs/native/animated-tab-bar)
```tsx
import { AnimatedTabBar } from '@coldsurfers/surf-ui/native/AnimatedTabBar'
import { TabBar, useTabBarHeight } from '@coldsurfers/surf-ui/native/TabBar'
function Screen() {
const [visible, setVisible] = useState(true)
const tabBarHeight = useTabBarHeight()
return (
{/* ... */}
{TABS.map(({ key, label, Icon }) => (
setSelected(key)}
renderIcon={({ color, size, strokeWidth }) => (
)}
/>
))}
)
}
```
reanimated 는 워클릿을 babel 플러그인으로 변환하는데, turbopack 은 **`node_modules` 를
babel 대상에서 뺍니다.** 이 사이트가 무는 워클릿은 DS 의 `dist` 안, 즉 `node_modules` 에
있어서 기본 배선이 닿지 않습니다. 그래서 이 표면만 코드로 설명합니다 —
[#119](https://github.com/coldsurfers/public/issues/119) 에서 뚫을 방법을 재고 있습니다.
## [`TabBar`](/docs/native/tab-bar) 와 왜 나뉘어 있나 [#tabbar-와-왜-나뉘어-있나]
의존 하나 때문입니다. reanimated 애니메이션 스타일은 `Animated.*` 에만 먹어서, 위치와 이동을
면이 같이 들면 **면 자체가 reanimated 를 뭅니다.** 그러면 움직일 일이 없는 소비처도 그 의존을
집니다. 그래서 움직이는 축만 여기로 뺐습니다.
자리를 `position: 'absolute'` 로 잡는 것도 여기입니다 — 슬라이드하려면 레이아웃 흐름에서
빠져 있어야 하고, 그건 *이동의 조건* 이지 면의 성질이 아닙니다.
## 그래서 화면이 아래 여백을 직접 잡아야 한다 [#그래서-화면이-아래-여백을-직접-잡아야-한다]
탭바가 흐름에서 빠져 있으니 자리를 비워 주지 않습니다. `useTabBarHeight()` 로 받아
스크롤 콘텐츠의 `paddingBottom` 에 넣습니다. 안 넣으면 마지막 항목이 탭바에 가립니다.
## 숨김은 언마운트가 아니다 [#숨김은-언마운트가-아니다]
`visible={false}` 는 아래로 **밀어** 숨깁니다(150ms). 언마운트가 아니라 이동이라
되돌아올 때 아이템 상태가 남아 있습니다.
## Props [#props]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `visible?` | `boolean` | — | false 면 아래로 밀어 숨긴다. 언마운트가 아니라 이동이라 되돌아올 때 상태가 남는다. |
| `children` | `ReactNode` | — | `TabBar.Item` 들. |
아이템은 그대로 [`TabBar.Item`](/docs/native/tab-bar) 을 씁니다.
---
# Getting Started (/docs/native/getting-started)
`@coldsurfers/surf-ui/native` 는 React Native 앱을 위한 별도 구현입니다.
웹 `primitives` 와 **같은 prop 계약**을 따르지만 스타일 엔진이 다릅니다 — 웹은 vanilla-extract,
이쪽은 `@emotion/native` 입니다.
## 왜 구현이 둘인가 [#왜-구현이-둘인가]
웹 레인의 산출물은 CSS 파일과 클래스 이름 문자열입니다. RN 에는 그 둘을 받을 곳이 없습니다 —
`className` 도 스타일시트도 없습니다. 그래서 공유선은 **토큰 값과 prop 계약**까지고,
스타일은 플랫폼마다 따로 씁니다.
값이 갈리지 않게 묶는 자리가 `contract/` 입니다. 축(`ButtonVariant`), 공통 props,
치수 표(`CONCERT_CARD_BARE_SPEC`)를 여기 올리고 양쪽 구현이 그걸 읽습니다.
## 설치 [#설치]
```bash
pnpm add @coldsurfers/surf-ui
```
peer 는 쓰는 표면만큼만 필요합니다. 전부 optional 이라 웹만 쓰는 소비처는 하나도 안 깝니다.
| 무엇을 쓰면 | 무엇이 필요한가 |
| ---------------------------------- | --------------------------------------------------- |
| 전부 | `@emotion/native` · `react-native` |
| `TabBar` · `AnimatedTabBar` | `react-native-safe-area-context` |
| `AnimatedTabBar` · `PullToRefresh` | `react-native-reanimated` · `react-native-worklets` |
| `PullToRefresh` | `react-native-gesture-handler` |
| `Spinner` · `PullToRefresh` | `react-native-svg` |
## import 경로 [#import-경로]
배럴(`/native`)과 표면별 진입점(`/native/TabBar`) 둘 다 열려 있습니다.
```tsx
import { Button, Text } from '@coldsurfers/surf-ui/native'
import { TabBar } from '@coldsurfers/surf-ui/native/TabBar'
```
무거운 peer 를 무는 표면은 **진입점으로 여는 편이 낫습니다.** 배럴은 `PullToRefresh` 까지
끌고 오므로, 탭바만 쓰는 화면도 gesture-handler 를 번들에 들입니다.
## 색은 훅으로 받는다 [#색은-훅으로-받는다]
RN 에는 CSS 변수가 없습니다. 웹에서 `var(--surf-action-primary)` 한 줄로 끝나던 색 참조를
[`useScheme()`](/docs/native/scheme) 이 대신합니다.
```tsx
const scheme = useScheme()
```
## 축은 웹부터 늘린다 [#축은-웹부터-늘린다]
새 variant 나 size 가 필요하면 **웹에서 먼저 엽니다.** 여기서 먼저 늘리면 계약이 갈라집니다.
예외는 **웹에 짝이 생길 수 없는 표면** 하나뿐입니다 — [`TabBar`](/docs/native/tab-bar) ·
[`AnimatedTabBar`](/docs/native/animated-tab-bar) · [`PullToRefresh`](/docs/native/pull-to-refresh)
셋이 그렇습니다. 하단 탭 크롬과 당겨서 새로고침은 웹에 대응하는 면이 없습니다.
이 사이트는 `react-native` 를 `react-native-web` 으로 별칭 걸어 native 컴포넌트를 그대로
그립니다. 그래서 그려진다는 것 자체가 그 조합이 실제 앱에서 선다는 증거입니다.
다만 `PullToRefresh` 와 `AnimatedTabBar` 는 미리보기가 없습니다 — reanimated 의 워클릿
변환이 babel 플러그인인데, turbopack 은 `node_modules` 를 babel 대상에서 빼기 때문입니다.
이 사이트가 무는 워클릿은 DS 의 `dist` 안, 즉 `node_modules` 에 있습니다
([#119](https://github.com/coldsurfers/public/issues/119)).
---
# IconButton (/docs/native/icon-button)
```tsx
import { IconButton } from '@coldsurfers/surf-ui/native/IconButton'
import { nativeSpacing } from '@coldsurfers/surf-ui/tokens/native'
import { Bookmark, Heart, Share2, X } from 'lucide-react'
import { View } from 'react-native'
export default function Example() {
return (
)
}
```
`Button` 과 **같은 variant 축**을 씁니다. 색 어휘를 하나 더 만들면 같은 `outline` 이
두 컴포넌트에서 다른 뜻이 될 수 있습니다.
## 크기 축이 `Button` 과 다른 이유 [#크기-축이-button-과-다른-이유]
`Button` 의 `sm`·`md`·`cta` 는 *라벨이 있는 컨트롤의 높이* 입니다. 여기는 **손가락이 닿는
정사각** 이라 축이 다릅니다.
| | 한 변 |
| --------- | --- |
| `sm` | 36 |
| `md` (기본) | 44 |
44 는 시안 치수가 아니라 iOS HIG 의 최소 터치 타깃입니다 — 접근성 하한이라 기본값입니다.
## `label` 이 필수인 이유 [#label-이-필수인-이유]
아이콘만 있어 스크린 리더가 읽을 이름이 없습니다. `accessibilityLabel` 로 들어갑니다.
## Props [#props]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `variant?` | `'primary' \| 'secondary' \| 'ghost' \| 'accent' \| 'outline' \| 'danger'` | — | — |
| `size?` | `'sm' \| 'md'` | — | — |
| `label` | `string` | — | 스크린 리더용 이름. 아이콘만 있어 라벨이 없으므로 필수. |
| `children` | `ReactNode` | — | — |
그 밖의 `ComponentPropsWithRef
` 속성은 루트 엘리먼트로 그대로 통과한다.
---
# Masonry · PinCard (/docs/native/masonry)
```tsx
import { Masonry } from '@coldsurfers/surf-ui/native/Masonry'
import { PinCard } from '@coldsurfers/surf-ui/native/PinCard'
function Feed() {
return (
pin.id}
paddingHorizontal={16}
header={ }
footer={isFetchingNextPage ? : null}
onEndReached={() => hasNextPage && fetchNextPage()}
renderItem={(pin) => (
open(pin)} />
)}
/>
)
}
```
FlashList v2 는 react-native-web 에서 열 폭을 재지 못해 셀이 폭 0 으로 섭니다. 카드 한 장은 아래에서 그립니다.
웹 [`Masonry`](/docs/patterns/masonry) 와 간격(열 12 · 줄 16)이 같습니다 — `MASONRY_SPEC`.
다른 점은 배치입니다. 웹은 추정 높이로 놓고, 여기는 FlashList `masonry` 가 **재서** 놓으므로
`heightOf` 가 없습니다. 화면 밖 카드는 그리지 않습니다.
## 스크롤을 소유한다 [#스크롤을-소유한다]
다른 `ScrollView` 안에 넣으면 가상화가 꺼집니다. 위 콘텐츠는 `header`, 다음 쪽 스피너는 `footer`,
다음 쪽 받기는 `onEndReached` 로 줍니다. 바텀시트 안이면 시트의 스크롤을 `renderScrollComponent` 로
줍니다 — `@gorhom/bottom-sheet` 5.2 는 `useBottomSheetScrollableCreator()` 가 그 값을 만듭니다.
끝에 이어 붙는 목록만 다룹니다. 앞에 끼워 넣어도 보던 자리를 붙잡는 FlashList
`maintainVisibleContentPosition` 은 꺼 두었습니다 — 켜 두면 먼 아래에서 맨 위로(`scrollToTop`)가
몇 pt 못 미쳐 멈춥니다.
## 설치 [#설치]
소비처가 `@shopify/flash-list` ≥2 (New Architecture) 를 들고 있어야 합니다. JS 전용이라 네이티브 빌드는
필요 없습니다. 배럴(`/native`)에는 없고 `@coldsurfers/surf-ui/native/Masonry` 로만 엽니다 —
FlashList 없는 소비처도 배럴을 열 수 있게.
## PinCard [#pincard]
```tsx
import { PinCard } from '@coldsurfers/surf-ui/native/PinCard'
import { View } from 'react-native'
import { samplePins } from './pin-samples'
export default function Example() {
const [first, second] = [samplePins[0], samplePins.at(-1)]
return (
{[first, second].map((pin) =>
pin ? (
) : null,
)}
)
}
```
웹 `PinCard` 와 치수가 같습니다 — `PIN_CARD_SPEC`. 포스터가 도착하면 페이드인하고, 누르는 동안
살짝 줄어듭니다. 동작 줄이기 설정이면 둘 다 끕니다. 한 번 받은 포스터 비율은 기억해 두어,
카드가 재활용돼도 3:4 에서 덜컥 늘어나지 않습니다.
## Props [#props]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `items` | `readonly T[]` | — | — |
| `keyOf` | `(item: T) => string` | — | 항목의 고유 키 — 재활용 · 배치의 열쇠. |
| `renderItem` | `(item: T) => ReactElement \| null` | — | — |
| `columnCount?` | `number` | — | 기본 2. |
| `header?` | `ReactNode` | — | 그리드 위 — 화면 머리 · 위 콘텐츠. |
| `footer?` | `ReactNode` | — | 그리드 아래 — 다음 쪽 스피너 · 「더 불러오기」. |
| `paddingHorizontal?` | `number` | — | 그리드 좌우 여백. 기본 0. |
| `ref?` | `Ref>` | — | — |
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `poster?` | `string \| null` | — | 포스터 주소. 없으면 3:4 회색 자리에 `fallback`. |
| `title` | `string` | — | — |
| `meta?` | `string \| null` | — | 출처 한 줄 — 도메인 · `@올린 사람`. |
| `fallback?` | `string` | — | 포스터가 없을 때 회색 자리 안 글자. 기본 `title`. |
| `action?` | `ReactNode` | — | 포스터 오른쪽 아래 — 보통 담기 +. 안쪽 `Pressable` 이 터치를 먼저 받아 카드 누름과 갈린다. |
| `onPress?` | `(() => void)` | — | 같은 함수를 넘기면 `memo` 가 리렌더를 끊는다. |
---
# PullToRefresh (/docs/native/pull-to-refresh)
```tsx
import { PullToRefresh } from '@coldsurfers/surf-ui/native/PullToRefresh'
import { useAnimatedRef } from 'react-native-reanimated'
function Feed() {
const scrollableRef = useAnimatedRef>()
return (
Promise.all([refetchFeed(), refetchBanner()])}
onHapticFeedback={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)}
>
{(scrollableProps) => (
)}
)
}
```
reanimated 의 워클릿 변환이 babel 플러그인인데, turbopack 은 `node_modules` 를 babel 대상에서
뺍니다. 이 사이트가 무는 워클릿은 DS 의 `dist` 안에 있어서 기본 배선이 닿지 않습니다
([#119](https://github.com/coldsurfers/public/issues/119)).
뚫리더라도 제스처는 **마우스로 흉내 내면 감각이 거짓**이라, 이 표면은 실기기에서 보는 편이
맞습니다.
## RN 기본 `RefreshControl` 을 왜 안 쓰나 [#rn-기본-refreshcontrol-을-왜-안-쓰나]
그건 iOS `UIRefreshControl` · Android `SwipeRefreshLayout` 이 그리는 **네이티브 뷰**라
자식도 커스텀 인디케이터도 받지 않습니다. 색 채널(`tintColor`/`colors`)만 열려 있어서
색을 맞춰 봐야 모양은 플랫폼 것 그대로입니다. 그래서 당김 감지부터 이쪽이 합니다.
## 스크롤러를 감싸지 않고 돌려주는 이유 [#스크롤러를-감싸지-않고-돌려주는-이유]
화면마다 스크롤러가 다릅니다 — 피드는 `SectionList`, 목록 화면은 `FlatList` 입니다.
어느 쪽을 쓸지는 소비처가 정하고, 여기서는 거기 꽂을 props 만 넘깁니다.
받은 props 는 **그대로 펼쳐 넣어야 합니다.**
| props | 왜 |
| ------------------------- | ----------------------------------------------------- |
| `ref` | 안 달면 "지금 맨 위인가"를 알 수 없어 스크롤 중간에서도 당김이 걸립니다 |
| `bounces: false` | iOS 네이티브 바운스가 켜져 있으면 네이티브가 밀어 올린 만큼 틈에 더해져 두 배로 움직입니다 |
| `overScrollMode: 'never'` | Android 글로우/스트레치도 같은 이유 |
## `scrollableRef` 를 소비처가 만드는 이유 [#scrollableref-를-소비처가-만드는-이유]
여기서 만들어 children 인자로만 주면, 소비처가 scroll-to-top 같은 다른 용도와 합치려고
렌더마다 새 콜백 ref 를 쓰게 됩니다. 그러면 React 가 매 렌더 `ref(null)` → `ref(node)` 를
돌려 당김 판정이 흔들립니다.
## Android 에는 오버스크롤 바운스가 없다 [#android-에는-오버스크롤-바운스가-없다]
스크롤 오프셋이 음수로 내려가지 않으므로 "오프셋이 마이너스면 당김" 이라는 iOS 식 판정이
통째로 안 먹습니다. 그래서 오프셋이 아니라 **제스처의 이동량**을 재고, 오프셋은 "지금 맨
위인가"를 가르는 데만 씁니다.
## `onRefresh` 는 promise 를 돌려주면 된다 [#onrefresh-는-promise-를-돌려주면-된다]
promise 가 끝날 때까지 인디케이터가 떠 있습니다. 화면이 여러 쿼리로 쪼개져 있으면
`Promise.all` 로 묶어 넘깁니다. 최소 노출 시간은 **같이** 기다립니다 — 순차로 기다리면
느린 응답에 그만큼이 그대로 얹힙니다.
실패는 삼킵니다. 네트워크가 죽으면 늘 나는 경로라 여기서 안 삼키면 unhandled rejection 이
됩니다. 인디케이터는 닫히고, 에러 표시는 소비처의 몫입니다.
## `onHapticFeedback` 은 왜 콜백인가 [#onhapticfeedback-은-왜-콜백인가]
DS 가 `expo-haptics` 를 물면 Expo 를 쓰지 않는 소비처까지 그걸 깔아야 합니다. 무엇을 어떤
세기로 울릴지도 화면의 맥락이라 소비처가 정합니다. 그래서 **시점만 여기가 알고, 울리는 건
소비처가** 합니다.
성공 · 실패를 가리지 않고 불립니다. "요청이 끝났다"는 신호지 "성공했다"는 신호가 아닙니다.
## 절대 위치 헤더가 있으면 `topInset` [#절대-위치-헤더가-있으면-topinset]
헤더가 콘텐츠 위에 떠 있는 화면에서 0 에 열면 인디케이터 스트립이 헤더 뒤에 가립니다.
헤더 높이를 넘기면 그 아래에서 열립니다.
## Props [#props]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `scrollableRef` | `PullToRefreshScrollableRef` | — | 스크롤러 ref — **소비처가 만들어 넘긴다**(`useAnimatedRef`). 여기서 만들어 children 인자로만 주면 소비처가 scroll-to-top 같은 다른 용도와 합치려고 렌더마다 새 콜백 ref 를 쓰게 되고, 그러면 React 가 매 렌더 `ref(null)` → `ref(node)` 를 돌려 당김 판정이 흔들린다. |
| `onRefresh` | `() => Promise` | — | 새로고침 본체. **promise 를 돌려주면 그게 끝날 때까지** 인디케이터가 떠 있는다 — 화면이 여러 쿼리로 쪼개져 있으면 `Promise.all` 로 묶어 넘기면 된다. |
| `onHapticFeedback?` | `(() => void)` | — | 새로고침이 끝난 순간 한 번 불린다 — haptic 을 물릴 자리다. 여기서 DS 가 직접 울리지 않는 건 계약 때문이다. `expo-haptics` 를 물면 Expo 를 쓰지 않는 소비처까지 그걸 깔아야 한다. 무엇을 어떤 세기로 울릴지도 화면의 맥락이라 소비처의 몫이다. 성공·실패를 가리지 않고 불린다 — "요청이 끝났다"는 신호지 "성공했다"는 신호가 아니다. |
| `children` | `(scrollableProps: PullToRefreshScrollableProps) => ReactNode` | — | 스크롤러를 그리는 자리. 받은 props 를 그대로 펼쳐 넣어야 한다. |
| `enabled?` | `boolean` | — | false 면 당김을 아예 받지 않는다(스크롤은 그대로). |
| `topInset?` | `number` | — | 인디케이터 스트립이 열리기 시작하는 y. 절대 위치 헤더가 콘텐츠 위에 떠 있는 화면에서 0 에 열면 스트립이 헤더 뒤에 가려진다 — 헤더 높이를 넘기면 그 아래에서 열린다. |
### 스크롤러에 펼쳐 넣는 props [#스크롤러에-펼쳐-넣는-props]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `ref` | `PullToRefreshScrollableRef` | — | — |
| `bounces` | `false` | — | iOS 네이티브 바운스를 끈다 — 켜두면 네이티브가 밀어 올린 만큼 이 틈에 더해져 두 배로 움직인다. |
| `overScrollMode` | `"never"` | — | Android 글로우/스트레치도 같은 이유로 끈다. |
---
# useScheme (/docs/native/scheme)
```tsx
import { useScheme } from '@coldsurfers/surf-ui/native/scheme'
import { Text } from '@coldsurfers/surf-ui/native/Text'
import { nativeRadius, nativeSpacing } from '@coldsurfers/surf-ui/tokens/native'
import { View } from 'react-native'
/** 색을 훅에서 받아 스타일에 직접 넣는다 — RN 엔 CSS 변수가 없다. */
const SLOTS = [
'bgBase',
'surfaceRaised',
'lineDivider',
'textPrimary',
'textSecondary',
'actionPrimary',
] as const
export default function Example() {
const scheme = useScheme()
return (
{SLOTS.map((slot) => (
{slot}
{scheme[slot]}
))}
)
}
```
색 **객체**를 돌려줍니다. 스타일에 직접 넣으면 됩니다.
## 왜 훅인가 [#왜-훅인가]
웹에서는 `var(--surf-surface-raised)` 한 줄이면 끝납니다 — 값이 어디서 오는지 컴포넌트가 몰라도 됩니다.
RN 에는 그 간접층이 없어서, 누군가는 색을 **읽어다 넘겨야** 합니다. 그 자리가 이 훅입니다.
## 스킴이 하나뿐인데 왜 훅 모양인가 [#스킴이-하나뿐인데-왜-훅-모양인가]
지금 `useScheme()` 은 상수 하나를 돌려줍니다. ink(dark) 스킴을 폐기하면서 뒤집을 축이
사라졌기 때문입니다.
그래도 훅 모양을 남겨 둔 건 호출부가 이미 이 경계를 쓰고 있어서입니다. 스킴이 다시 생기면
**이 파일 한 곳만** 되돌리면 됩니다. 소비처가 `nativeColor.light` 를 직접 읽고 있었다면
그때 전부를 고쳐야 합니다.
그래서 **색을 `nativeColor.light` 에서 직접 읽지 마세요.** 지금은 같은 값이지만, 그 순간
되돌릴 자리가 한 곳에서 여러 곳으로 늘어납니다.
## 슬롯 [#슬롯]
표면 · 텍스트 · 상태 색이 한 객체에 있습니다. 전체 값은
[Foundations › Colors](/docs/foundations/colors) 가 정본입니다 — 웹과 같은 팔레트입니다.
`Text` 의 `tone` 축에 없는 색(`statusDanger` 등)이 필요하면 여기서 읽어 `style` 로 넣습니다.
```tsx
@ 가 없습니다.
```
---
# TabBar (/docs/native/tab-bar)
```tsx
import { TabBar } from '@coldsurfers/surf-ui/native/TabBar'
import { Text } from '@coldsurfers/surf-ui/native/Text'
import { House, Search, Tickets, UserRound } from 'lucide-react'
import { useState } from 'react'
import { View } from 'react-native'
const TABS = [
{ key: 'feed', label: '피드', Icon: House },
{ key: 'events', label: '이벤트', Icon: Tickets },
{ key: 'search', label: '검색', Icon: Search },
{ key: 'my', label: '나의 창꼬', Icon: UserRound },
]
export default function Example() {
const [selected, setSelected] = useState('feed')
return (
<>
{TABS.find((tab) => tab.key === selected)?.label}
{TABS.map(({ key, label, Icon }) => (
setSelected(key)}
renderIcon={({ color, size, strokeWidth }) => (
)}
/>
))}
>
)
}
```
**웹에 짝이 없는 표면**입니다. 이건 면(chrome)이지 네비게이션도 배치도 아닙니다.
## 무엇을 들고 무엇을 안 드는가 [#무엇을-들고-무엇을-안-드는가]
| 여기 | 여기가 아닌 곳 |
| ----------------------------------------------------- | ----------------------------------------------------------------------- |
| 색 · 테두리 · 높이(safe-area 포함) · 아이템 배치 · 라벨 서식 · 활성 tint | 라우터 배선 · route → 아이콘 매핑 · 햅틱 → **소비처** |
| | 화면 하단 고정 · 숨김 애니메이션 → [`AnimatedTabBar`](/docs/native/animated-tab-bar) |
**위치를 정하지 않는 이유는 의존입니다.** reanimated 애니메이션 스타일은 `Animated.*` 에만
먹으므로, 위치와 이동을 한 컴포넌트가 들면 이 파일이 reanimated 를 물게 됩니다. 그러면
애니메이션을 안 쓰는 소비처까지 그 의존을 집니다. 여기 의존은 emotion 과
safe-area-context 둘뿐입니다.
## 아이콘을 노드가 아니라 함수로 받는 이유 [#아이콘을-노드가-아니라-함수로-받는-이유]
색 · 크기 · 굵기는 활성 여부에서 나오는 **이 면의 계약**이고, 무엇을 그릴지는 소비처의 것입니다.
노드로 받으면 소비처가 tint 를 알아야 하고, 그러면 색 계약이 밖으로 샙니다.
```tsx
renderIcon={({ color, size, strokeWidth }) => (
)}
```
활성 상태는 채움 아이콘 세트가 아니라 **굵기와 accent 색**으로 말합니다 (1.5 → 2.5).
## 높이는 `useTabBarHeight()` 에서 읽는다 [#높이는-usetabbarheight-에서-읽는다]
`AnimatedTabBar` 로 띄우면 탭바가 레이아웃에서 빠져 자리를 비워 주지 않습니다. 화면이
자기 콘텐츠 아래 여백을 이 값으로 잡아야 합니다.
```tsx
const tabBarHeight = useTabBarHeight()
```
인셋을 상수로 박지 않습니다 — 홈 인디케이터 높이는 기기마다 다르고 0 인 기기(SE · iPad)도
있어서, 박으면 그런 기기에서 탭바 아래가 빈 채로 남습니다.
화면 전체가 이 여백을 져야 하면 직접 계산하는 대신
[`AppScreen`](/docs/screens/app-screen) 의 `offsetBottom="tabBar"` 를 씁니다.
브라우저에서는 `Platform.OS === 'web'` 이라 아래 여백이 0 입니다. 실기기에서는 iOS 가
홈 인디케이터 인셋만큼, Android 가 12 만큼 더 큽니다.
그리고 `useSafeAreaInsets()` 를 부르므로 앱 루트에 `SafeAreaProvider` 가 있어야 합니다.
## Props [#props]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `children` | `ReactNode` | — | `TabBar.Item` 들. |
그 밖의 `ViewProps` 속성은 루트 엘리먼트로 그대로 통과한다.
### `TabBar.Item` [#tabbaritem]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `label` | `string` | — | — |
| `active?` | `boolean` | — | — |
| `renderIcon` | `(state: { color: string; size: number; strokeWidth: number; }) => ReactNode` | — | 아이콘을 그리는 자리. 색·크기·굵기는 이 면이 정해 넘긴다 — 위 「무엇을 들고 무엇을 안 드는가」. |
그 밖의 `TouchableOpacityProps` 속성은 루트 엘리먼트로 그대로 통과한다.
---
# TextInput (/docs/native/text-input)
```tsx
import { useScheme } from '@coldsurfers/surf-ui/native/scheme'
import { Text } from '@coldsurfers/surf-ui/native/Text'
import { TextInput } from '@coldsurfers/surf-ui/native/TextInput'
import { nativeSpacing } from '@coldsurfers/surf-ui/tokens/native'
import { useState } from 'react'
import { View } from 'react-native'
export default function Example() {
const scheme = useScheme()
const [email, setEmail] = useState('')
const invalid = email.length > 0 && !email.includes('@')
return (
이메일
{/* 에러 문구는 `Text` 의 톤 축에 없다 — 스킴에서 직접 읽는다. */}
{invalid ? (
@ 가 없습니다.
) : null}
)
}
```
웹은 `Field` 가 라벨 · 에러 · 입력을 한 묶음으로 들지만, RN 판은 **입력만** 듭니다.
## 왜 `Field` 가 아닌가 [#왜-field-가-아닌가]
라벨과 에러의 조판은 폼 레이아웃의 문제고, 그건 화면마다 다릅니다. 지금 소비처가 하나라
묶어 두면 그 화면의 조판이 계약이 됩니다. 두 번째 소비처가 같은 조판을 요구할 때 올립니다.
그래서 `invalid` 는 **테두리 색만** 바꿉니다. 메시지를 어디에 어떻게 그릴지는 소비처의 몫입니다.
## ref 는 좁히지 않는다 [#ref-는-좁히지-않는다]
내부 `TextInput` 인스턴스가 그대로 나옵니다. 다음 칸으로 포커스를 넘기는 게 가장 흔한 경로입니다.
```tsx
const next = useRef(null)
next.current?.focus()} />
```
`useImperativeHandle` 로 `focus` 만 열지 않은 이유는 공개 패키지라서입니다. 소비처가
`measure`·`isFocused` 중 무엇을 쓸지 아직 모르는데, **좁힌 핸들을 넓히는 것보다 넓은 걸
좁히는 쪽이 major** 입니다. 두 번째 소비처가 축을 말할 때 좁힙니다.
## 포커스 테두리는 로컬 상태다 [#포커스-테두리는-로컬-상태다]
RN 에는 `:focus` 가 없어 누군가 상태를 들어야 합니다. 그 상태가 필요한 곳이 이 컴포넌트
하나뿐이라 컨텍스트로 올리지 않았습니다. `onFocus`/`onBlur` 를 넘겨도 그대로 불립니다.
## Props [#props]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `invalid?` | `boolean` | — | 있으면 테두리를 danger 색으로. 메시지 렌더는 소비처의 몫. |
그 밖의 `ComponentPropsWithRef` 속성은 루트 엘리먼트로 그대로 통과한다.
---
# ArticleCard (/docs/patterns/article-card)
```ts
import { ArticleCard } from '@coldsurfers/surf-ui/cards'
```
```tsx
import { ArticleCard } from '@coldsurfers/surf-ui/cards'
export default function Example() {
return (
)
}
```
`CoverBlock` 과 `Eyebrow` 를 안에서 조립합니다 — 소비처가 그 둘을 따로 import 하지 않습니다.
`thumbnailUrl` 이 없으면 tone 색면만 노출합니다. 없는 이미지를 회색 박스로 흉내 내지 않는 건
**데이터 정직성** 쪽 결정입니다 — 색면은 그 자체로 카드의 표지입니다.
폭은 소비처가 정합니다. 카드는 `width: 100%` 라 그리드 칸을 그대로 채웁니다.
## 클릭은 소비처가 감싼다 [#클릭은-소비처가-감싼다]
라우터를 물지 않습니다. `next/link` 든 `react-router` 든 카드 바깥에서 감쌉니다.
```tsx
```
## Props [#props]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `tone` | `'forest' \| 'wine' \| 'navy' \| 'moss' \| 'steel' \| 'plum'` | — | — |
| `thumbnailUrl?` | `string` | — | 실제 썸네일 URL. 있으면 tone 색면 위에 채워 얹고, 없으면 색면만 노출(데이터 정직성). |
| `category` | `string` | — | 카테고리 eyebrow — `TAPE` · `MIND` · `DEV`. |
| `title` | `string` | — | — |
| `excerpt` | `string` | — | — |
| `meta` | `string` | — | `7.15 · 24분` · `7.11`. |
그 밖의 `HTMLAttributes` 속성은 루트 엘리먼트로 그대로 통과한다.
---
# ConcertCard (/docs/patterns/concert-card)
```ts
import { ConcertCard, ConcertCardSkeleton } from '@coldsurfers/surf-ui/cards'
```
```tsx
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 (
좋아한 아티스트와 같은 결
}
coverAction={담기 }
/>
무신사 개러지}
reserveTitleLines
/>
{/* 같은 섀시, 비율만 다르다. 공연 포스터는 세로로 인쇄되므로 칸이 세로로 설 수 있는
자리에서는 `portrait` 이 위아래를 안 자른다 — 기본 `landscape` 와 나란히 두면
잘리는 정도가 바로 보인다. */}
올림픽홀}
reserveTitleLines
/>
{/* `initial` 도 `footer` 도 없다 — 이 섀시는 포스터가 없으면 note 면만 남기고,
공연장 줄을 아무 데도 안 그린다. 평평한 `ConcertCardProps` 에서는 둘 다 통과했다. */}
{/* 작은 칸은 문이 다르다. `size` 플래그가 아니라 문 이름이 크기를 말하고,
`footer` 는 이쪽에만 있다. */}
담기}
/>
{/* 위와 같은 그림, 더 큰 칸. 여러 열 그리드처럼 칸이 넓은 자리에서 세로 포스터가 덜 잘린다. */}
담기}
/>
)
}
```
도메인은 이름에만 남았습니다. 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`)이 갈리는 건 시안에 그
자리가 없어서입니다.
### 문은 둘, 하나는 관대하고 하나는 정확합니다 [#문은-둘-하나는-관대하고-하나는-정확합니다]
한 줄이 두 가지로 써집니다. 가리키는 컴포넌트가 같아서 렌더 결과도 같습니다.
```tsx
```
`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` 사이는 슬롯이 같아 **진짜 크기 축**입니다. 그런데도 문으로
나눈 건 커버 문만 플래그를 되받으면 규율이 반쪽이 되기 때문입니다 — 「커버는 문으로 고른다」
하나만 외우면 되게 둡니다.
```tsx
// 컴파일 에러 — 이 섀시는 안 그린다
// 이쪽이 그린다
```
### `cover` 의 크기 축 [#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` 의 커버 비율 축 [#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 [#concertcardskeleton]
```tsx
import { ConcertCard, ConcertCardSkeleton } from '@coldsurfers/surf-ui/cards'
export default function Example() {
return (
{/* 문 이름이 짝이다 — 스켈레톤과 실카드를 같은 이름으로 부르면 치수가 어긋날 자리가 없다. */}
)
}
```
같은 섀시(라운드 · 테두리 · 커버 면)라 로드 전후에 지면이 튀지 않습니다. `CoverBlock` 을
재사용하므로 면이 실카드와 **정확히 같은 소스**에서 나옵니다.
면에 축이 없습니다 — 스켈레톤이 뜻하는 것도 「아직 그림이 없다」라 `Skeleton`(막대들)과 같은
`note` 입니다.
### 문 이름이 짝입니다 [#문-이름이-짝입니다]
실카드의 문 다섯과 **1:1** 입니다 — `ConcertCardSkeleton.Framed` · `.Bare` · `.Cover` ·
`.CoverCompact` · `.CoverLarge`. 같은 이름으로 부르면 섀시도 치수도 어긋날 자리가 없습니다.
```tsx
// 로딩
// 로드 완료 — 같은 420/460
```
플래그(`variant` · `size`)도 그대로 삽니다. 다만 그쪽은 짝을 **소비처가 기억해야** 합니다 —
카드만 `size="large"` 로 바꾸고 스켈레톤을 `framed` 에 두면 타입은 통과하고 화면이 로드
순간에 280 → 460 으로 튑니다. 새 코드는 문으로 오는 편이 낫습니다.
`reserveTitleLines` 와 `coverRatio` 는 `bare` 섀시만 봅니다 — 그래서 `ConcertCardSkeleton.Bare`
에만 열려 있습니다. **둘 다 실카드와 같은 값을 넘겨야 합니다.** 커버 비율이 어긋나면 같은 폭에서
높이가 크게 달라지는데(`landscape` 4:3 ↔ `portrait` 3:4 는 약 1.78배), 그러면 로드되는 순간
그리드가 통째로 밀립니다 — 이 컴포넌트가 막으려던 바로 그 점프입니다.
```tsx
// 로딩
// 로드 완료 — 같은 3:4
```
커버 스켈레톤은 치수를 옮겨 적지 않고 **실카드의 `coverCover` recipe 를 그대로 가리킵니다.**
실카드에 크기가 또 붙어도 자동으로 따라옵니다.
[`Skeleton`](/docs/components/skeleton) 과는 다른 물건입니다. 그쪽은 치수를 받는 **축**이고,
이쪽은 카드 모양에 고정된 **사본**입니다. 축으로 이 카드를 흉내 내려면 소비처가 커버 비율 ·
제목 줄 수 · 본문 간격을 다시 적어야 하고, 그 사본이 실카드와 어긋나는 순간을 아무도 못
잡습니다.
## Props [#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 | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `cover?` | `ReactNode` | — | **포스터 층**을 소비처가 채운다 — 주면 `posterUrl` 대신 이게 선다. 커버는 두 겹이다. 바닥은 `note` 면 + 옅은 이니셜이고 **그건 언제나 카드가 그린다.** 이 슬롯은 그 위를 덮는 층이라, 여기서 아무것도 안 그리면(포스터 부재·로드 실패) 바닥이 그대로 드러난다. 그래서 `initial` 과 **같이** 준다 — 둘은 다른 층이라 겹치지 않는다. 여는 이유는 **상태**다. 로드 실패를 잡으려면 `useState` 가 필요한데 이 엔트리는 훅도 `'use client'` 도 없는 서버 안전 모듈이다 — 들기 시작하면 `cards` 가 통째로 클라이언트 전용이 되고, `primitives` 배럴이 `Toast` 로 겪은 RSC 사고를 그대로 반복한다. 그래서 자리만 카드가 정하고 내용물은 소비처가 준다(`coverAction` 과 같은 규율). ⚠️ 공유 계약(`ConcertCardBareProps`)이 아니라 **웹 props 에만** 있다. native 는 이 자리를 안 그리므로 공유로 올리면 「있는데 안 먹는 prop」이 하나 더 생긴다(`footer` 가 이미 앓은 병). |
| `matchLabel?` | `string` | — | 취향 매치 라벨 — `96% 취향`. 없으면 미노출. **`framed` 전용** (시안의 `bare` 엔 자리가 없다). |
| `eyebrow?` | `string` | — | 커버 좌상단 라벨 — 시안의 장르 자리(`INDIE ROCK`). 없으면 미노출. **`cover` 전용**. |
| `size?` | `'full' \| 'compact' \| 'large'` | — | 커버 카드의 크기. 기본 `full`. **`cover` 전용**. `full` — 세로 커버 한 장(430/500)에 eyebrow·담기·제목을 얹고, **메타는 커버 밖** 1줄. `compact` — 작은 칸(290/300)이라 셋을 다 커버 안에 넣는다: 날짜(`meta`) → 제목 → 공연장(`footer`). 스크림이 카드 전체 높이에 걸려 글이 포스터 위에서 읽힌다. `large` — `compact` 와 같은 그림, 더 큰 칸(420/460 · 여백 24). 여러 열 그리드용. ⚠️ `full` 에서만 `footer` 가 안 그려진다 — 커버 아래에 메타 한 줄만 두는 섀시라서다. 그 비대칭이 **이 문에만 남아 있다.** 정적 프로퍼티 문에는 이 축이 없다 — 크기가 문 이름 (`ConcertCard.Cover` · `.CoverCompact` · `.CoverLarge`)에 박혀 있고, `full` 쪽은 `footer` 를 타입에서 아예 안 연다. 여기 남긴 건 `variant` 문이 쓰던 축이라서다(빼면 major). |
| `variant?` | `'framed' \| 'bare' \| 'cover'` | — | `framed`(기본) — 테두리·배경 있는 액자 카드(`/live-events`·`/nearby`·`/gig-guide`). `bare` — 시안 dice.fm 리스킨(Figma `931:32`·`931:259`): 섀시 없이 4:3 포스터 블록 + 그 아래 3줄 텍스트. `/`(트렌딩 레일)·`/@`(담은 공연 그리드)이 쓴다. `cover` — 시안 날짜 피드 리스킨(Figma `1093:171`·`1093:576`): 세로 커버 한 장에 eyebrow·담기· 제목을 **얹고** 커버 아래엔 메타 1줄. `/live-events/new` 와 그 날짜 상세가 쓴다. 작은 칸(레일)은 같은 섀시의 `size="compact"`, 여러 열 그리드는 `size="large"` — 축은 `ConcertCardSize`. ⚠️ native 는 `bare` 만 구현한다 — 그래서 그쪽엔 이 prop 이 아예 없다. |
그 밖의 `ConcertCardBareProps, CardDomProps` 속성은 루트 엘리먼트로 그대로 통과한다.
### ConcertCardSkeleton [#concertcardskeleton-1]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `variant?` | `'framed' \| 'bare' \| 'cover'` | — | `ConcertCard` 의 같은 이름 prop 과 짝 — 섀시가 어긋나면 로드 전후가 튄다. |
| `size?` | `'full' \| 'compact' \| 'large'` | — | `ConcertCard` 의 같은 이름 prop 과 짝 — **`cover` 전용**. 어긋나면 높이가 튄다. |
| `reserveTitleLines?` | `boolean` | — | `ConcertCard` 의 같은 이름 prop 과 짝 — 제목 자리 높이를 실카드와 맞춘다. |
| `coverRatio?` | `'landscape' \| 'square' \| 'portrait'` | — | `ConcertCard` 의 같은 이름 prop 과 짝 — **`bare` 전용**. 어긋나면 커버 높이가 튄다. 실카드와 **같은 값을 넘겨야 한다.** `portrait`(3:4) 카드 자리에 기본값 `landscape`(4:3) 스켈레톤을 깔면 같은 폭에서 커버 높이가 약 1.78배 달라져, 로드되는 순간 그리드가 통째로 아래로 밀린다. |
그 밖의 `HTMLAttributes` 속성은 루트 엘리먼트로 그대로 통과한다.
---
# LeadFeature (/docs/patterns/lead-feature)
```ts
import { LeadFeature } from '@coldsurfers/surf-ui/cards'
```
```tsx
import { LeadFeature } from '@coldsurfers/surf-ui/cards'
export default function Example() {
return (
)
}
```
시안의 `THIS WEEK'S PICK` 자리입니다. tablet 이상에서 커버와 텍스트가 2단으로 갈리고,
모바일에서는 세로로 쌓입니다.
`ArticleCard` 와 무엇이 다른가 — **지면에서의 자리**입니다. 아티클 카드는 목록의 한 칸이라
제목이 `` 이지만, 리드 피처는 그 목록 위에 한 장으로 서므로 `` 입니다. 같은 재료로
같은 크기의 카드를 두 개 만든 게 아닙니다.
`eyebrow` · `cta` 는 선택입니다. 없으면 그 줄 자체가 사라집니다 — 빈 자리를 남기지 않습니다.
## Props [#props]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `tone` | `'forest' \| 'wine' \| 'navy' \| 'moss' \| 'steel' \| 'plum'` | — | — |
| `thumbnailUrl?` | `string` | — | 실제 커버 URL. 있으면 tone 색면 위에 채워 얹고, 없으면 색면만 노출(데이터 정직성). |
| `eyebrow?` | `string` | — | 상단 eyebrow — `THIS WEEK'S PICK`. |
| `title` | `string` | — | — |
| `excerpt` | `string` | — | — |
| `byline` | `string` | — | `tomwaters · 7월 3주`. |
| `cta?` | `string` | — | 액션 라벨 — `읽기 →`. 없으면 미노출. |
그 밖의 `HTMLAttributes` 속성은 루트 엘리먼트로 그대로 통과한다.
---
# Masonry (/docs/patterns/masonry)
```tsx
import { Masonry, PinCard, pinCardHeight } from '@coldsurfers/surf-ui/cards'
import { Button } from '@coldsurfers/surf-ui/primitives'
import { useState } from 'react'
import { type SamplePin, samplePins } from './pin-samples'
/** 다음 쪽 흉내 — 같은 핀을 새 키로 한 쪽 더. */
const pageOf = (page: number): SamplePin[] =>
samplePins.map((pin) => ({ ...pin, id: `${pin.id}-${page}` }))
export default function Example() {
const [pages, setPages] = useState(1)
const items = Array.from({ length: pages }, (_, page) => pageOf(page)).flat()
return (
pin.id}
heightOf={(pin) => pinCardHeight(pin.ratio ?? undefined, !!pin.meta)}
renderItem={(pin) => (
{pin.title}
)}
/>
setPages((n) => n + 1)}>
다음 쪽 붙이기
)
}
```
항목을 가장 낮은 열에 차례로 놓습니다. 다음 쪽이 붙어도 이미 놓인 카드는 다른 열로 옮기지 않습니다 —
CSS `column-count` 와 다른 점입니다. 쪽은 따로 그리지 말고 이어 붙인 `items` 하나로 넘기세요 — 쪽 사이에 빈칸이 남지 않습니다.
높이는 그려진 칸이면 실측으로, 아직 안 그려진 칸만 `heightOf`(열 폭 = 1)로 칩니다. 핀 카드면 `pinCardHeight` 를 쓰고, 비율을 모르면 `undefined` 를 넘겨도 됩니다.
「다음 쪽 붙이기」를 눌러 보세요 — 새 칸은 아래에서 살짝 떠오르고, `PinCard` 포스터는 3:4 자리에서 받은 비율로 옮겨 갑니다. 동작 줄이기 설정이면 둘 다 바로 바뀝니다.
간격(열 12 · 줄 16)은 `MASONRY_SPEC` 이 정본이라 native 와 같습니다.
## native [#native]
RN 판은 [Native › Masonry · PinCard](/docs/native/masonry) — FlashList v2 위에 서고 높이를 재서 놓습니다.
## Props [#props]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `items` | `readonly T[]` | — | 쪽을 이어 붙인 전체 — 다음 쪽이 오면 뒤에 붙여 넘긴다. |
| `keyOf` | `(item: T) => string` | — | 항목의 고유 키 — 놓인 열 · 잰 높이를 기억하는 열쇠다. |
| `heightOf` | `(item: T) => number` | — | 열 폭 = 1 기준 높이 추정 — 아직 안 그려진 칸에만 쓴다. 핀 카드면 `pinCardHeight`. |
| `renderItem` | `(item: T) => ReactNode` | — | — |
| `columnCount?` | `number` | — | 기본 2. 바뀌면 처음부터 다시 놓는다. |
---
# MediaRow (/docs/patterns/media-row)
```ts
import { MediaRow } from '@coldsurfers/surf-ui/cards'
```
```tsx
import { MediaRow } from '@coldsurfers/surf-ui/cards'
import { StatPill } from '@coldsurfers/surf-ui/primitives'
export default function Example() {
return (
88,000원
>
}
aside={
<>
>
}
/>
69,000~원
>
}
aside={ }
/>
)
}
```
[`PickRow`](/docs/patterns/pick-card) 에 썸네일을 더한 줄입니다(Figma `1028:331`). 시각 키커 · 제목 · 장소 · 아래 줄(`footer`) · 오른쪽 칸(`aside`)이 섭니다.
고른 줄은 `selected`, 고른 뒤 나머지는 `dimmed` 입니다. 포스터가 없으면 `cover`(보통 이니셜)가 색면 위에 섭니다.
줄을 눌러 시트를 열거나 이동하려면 `asChild` 로 `button` · 라우터 `Link` 를 넘깁니다 — **그 자식의 글이 제목**이 되고 나머지가 둘레에 그려집니다.
## Props [#props]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `title?` | `ReactNode` | — | `asChild` 면 비우고 자식 링크의 글로 준다. |
| `posterUrl?` | `string` | — | — |
| `cover?` | `ReactNode` | — | 포스터가 없을 때 색면 위 글자 — 보통 이니셜. |
| `kicker?` | `ReactNode` | — | 제목 위 Geist 키커 — `토 10.10 · 12:00`. |
| `meta?` | `ReactNode` | — | 제목 아래 — 장소. |
| `footer?` | `ReactNode` | — | 맨 아래 줄 — 가격 · 판매처 칩. |
| `aside?` | `ReactNode` | — | 오른쪽 칸 — 보통 `StatPill`. |
| `selected?` | `boolean` | — | — |
| `dimmed?` | `boolean` | — | — |
| `asChild?` | `boolean` | — | 자기 엘리먼트 대신 자식(`button` · 라우터 `Link`)에 줄을 입힌다. |
그 밖의 `Omit, 'title'>` 속성은 루트 엘리먼트로 그대로 통과한다.
---
# PickCard (/docs/patterns/pick-card)
```ts
import { PickCard, PickRow } from '@coldsurfers/surf-ui/cards'
```
```tsx
import { PickCard, PickRow } from '@coldsurfers/surf-ui/cards'
import { RowAction } from '@coldsurfers/surf-ui/primitives'
export default function Example() {
const card = (
18건 모두 ›}
style={{ width: '100%', maxWidth: 420 }}
>
예매 ↗}
/>
예매 ↗}
/>
)
return (
<>
{card}
{card}
>
)
}
```
홈의 `오늘 저녁` · `이번 주말` 자리입니다(Figma `821:92`). 패널 안에 머리 한 줄, 줄 낱장, 꼬리 한 줄이 섭니다.
색은 전부 역할 토큰이라 `data-surface="ink"` 안에 넣으면 잉크 면이 됩니다 — 위 두 번째 카드가 그 모습입니다.
머리 오른쪽(`aside`) · 줄 둘째 줄(`meta`) · 행동(`action`)은 슬롯입니다. 스웰 미터 · 스웰 태그 같은 앱 부품은
소비처가 넣습니다. 여러 장을 3단으로 놓는 그리드도 소비처 몫입니다.
## Props [#props]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `title` | `ReactNode` | — | 머리 한 줄 — `오늘 저녁`. |
| `kicker?` | `ReactNode` | — | 머리 아래 Geist 키커 — `TODAY · 10.01`. |
| `aside?` | `ReactNode` | — | 머리 오른쪽 슬롯 — 스웰 미터 같은 앱 부품. |
| `footer?` | `ReactNode` | — | 꼬리 한 줄 — `18건 모두 ›` 링크. |
그 밖의 `Omit, 'title'>` 속성은 루트 엘리먼트로 그대로 통과한다.
### PickRow [#pickrow]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `time?` | `ReactNode` | — | 제목 앞 Geist 시각 — `19:30` · `토 18:00`. |
| `title` | `ReactNode` | — | — |
| `meta?` | `ReactNode` | — | 둘째 줄 — 장소 · 태그. |
| `action?` | `ReactNode` | — | 오른쪽 행동 — 보통 `RowAction`. |
그 밖의 `Omit, 'title'>` 속성은 루트 엘리먼트로 그대로 통과한다.
---
# PinCard (/docs/patterns/pin-card)
```tsx
import { PinCard } from '@coldsurfers/surf-ui/cards'
import { IconButton } from '@coldsurfers/surf-ui/primitives'
export default function Example() {
return (
)
}
```
시안 v3 `v3/PinCard`(Figma `1455:263`)입니다. 제목에 링크 하나를 넣으면 그 링크가 카드 전체를 덮고,
`action`(담기 +)만 그 위에 뜹니다. `action` 은 링크 밖 형제라 단추를 넣어도 됩니다.
포스터가 없으면 3:4 회색 자리에 `fallback` 을 그립니다. 여러 장은 [`Masonry`](./masonry) 로 놓습니다. RN 판은 `@coldsurfers/surf-ui/native/PinCard`.
## Props [#props]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `poster?` | `string` | — | 포스터 주소. 없으면 3:4 회색 자리. |
| `fallback?` | `ReactNode` | — | 포스터가 없을 때 회색 자리 안 글자. |
| `meta?` | `ReactNode` | — | 출처 한 줄 — 도메인 · 장소. |
| `action?` | `ReactNode` | — | 포스터 오른쪽 아래 — 보통 담기 +. 링크 밖 형제라 단추를 넣어도 된다. |
| `children` | `ReactNode` | — | 제목. 링크 엘리먼트 하나면 그 링크가 카드 전체를 덮는다. |
그 밖의 `Omit, 'children'>` 속성은 루트 엘리먼트로 그대로 통과한다.
---
# PosterThumb (/docs/patterns/poster-thumb)
```ts
import { PosterThumb } from '@coldsurfers/surf-ui/cards'
```
```tsx
import { PosterThumb } from '@coldsurfers/surf-ui/cards'
import { StatPill } from '@coldsurfers/surf-ui/primitives'
export default function Example() {
return (
)
}
```
3:4 · r12 포스터에 왼쪽 아래 배지 하나입니다(Figma `1028:217`). 폭을 채우므로 칸 폭은 소비처 그리드가 정합니다.
포스터가 없으면 `cover` 가 색면 위에 섭니다. 옆에 제목이 있으면 `alt` 는 비워 둡니다(기본).
## Props [#props]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `posterUrl?` | `string` | — | — |
| `cover?` | `ReactNode` | — | — |
| `alt?` | `string` | — | 이미지 대체 글. 옆에 제목이 있으면 비운다(기본). |
| `badge?` | `ReactNode` | — | 왼쪽 아래 — 보통 `StatPill`. |
그 밖의 `HTMLAttributes` 속성은 루트 엘리먼트로 그대로 통과한다.
---
# AppScreen (/docs/screens/app-screen)
```tsx
import { AppScreen, type AppScreenOffsetBottom } from '@coldsurfers/screens/AppScreen'
import { useScheme } from '@coldsurfers/surf-ui/native/scheme'
import { TabBar, useTabBarHeight } from '@coldsurfers/surf-ui/native/TabBar'
import { Text } from '@coldsurfers/surf-ui/native/Text'
import { House, Search, Tickets, UserRound } from 'lucide-react'
import { useState } from 'react'
import { Pressable, View } from 'react-native'
const TABS = [
{ key: 'feed', label: '피드', Icon: House },
{ key: 'events', label: '이벤트', Icon: Tickets },
{ key: 'search', label: '검색', Icon: Search },
{ key: 'my', label: '나의 창꼬', Icon: UserRound },
]
const OFFSETS: AppScreenOffsetBottom[] = ['tabBar', 'safeArea', 'none']
export default function Example() {
const [offsetBottom, setOffsetBottom] = useState('tabBar')
const scheme = useScheme()
const tabBarHeight = useTabBarHeight()
return (
{/* 테두리가 곧 AppScreen 이 비운 자리의 경계다. */}
offsetBottom = {offsetBottom} · useTabBarHeight() = {tabBarHeight}
{OFFSETS.map((candidate) => (
setOffsetBottom(candidate)}
style={{
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 6,
borderColor:
candidate === offsetBottom ? scheme.actionPrimary : scheme.lineDivider,
backgroundColor: candidate === offsetBottom ? scheme.statePressed : scheme.bgBase,
}}
>
{candidate}
))}
콘텐츠 끝 — 이 선이 탭바에 닿으면 여백이 모자란 것이다
{/* 흐름 밖이라 자리를 안 비워 준다 — offsetBottom 이 있는 이유. */}
{TABS.map(({ key, label, Icon }) => (
(
)}
/>
))}
)
}
```
`offsetBottom` 을 `none` 으로 내려 보세요. 콘텐츠 테두리가 탭바 아래로 들어갑니다 — 이 컴포넌트가
존재하는 이유가 그 한 줄입니다.
## 무엇을 들고 무엇을 안 드는가 [#무엇을-들고-무엇을-안-드는가]
| 여기 | 여기가 아닌 곳 |
| ---------------------------------------- | ---------------------------------------- |
| 배경색 · safe-area 여백 · 탭바 여백 · Suspense 경계 | 헤더 → react-navigation `options.header` |
| | 에러 경계 → **소비처** |
| | 탭바 자체 → [`TabBar`](/docs/native/tab-bar) |
**에러 경계를 안 드는 이유는 의존입니다.** react-query 경계를 넣으면 이 패키지가 react-query 를
물고, 데이터를 안 읽는 화면까지 그 의존을 집니다. 소비처가 바깥에서 감쌉니다.
## 여백을 두 축으로 가른다 [#여백을-두-축으로-가른다]
```tsx
```
| | 값 | 무엇이 되는가 |
| -------------- | ------------------------------ | ------------------------------------------- |
| `offsetTop` | `none` · `safeArea` | `0` · `insets.top` |
| `offsetBottom` | `none` · `safeArea` · `tabBar` | `0` · `insets.bottom` · `useTabBarHeight()` |
`left` · `right` 는 받지 않습니다. 앞판 소비처 22곳에서 한 번도 안 썼습니다 — 있는데 안 먹는
prop 은 거짓말을 합니다.
### 왜 불리언 둘이 아니라 열거형인가 [#왜-불리언-둘이-아니라-열거형인가]
앞판은 `edges: Edges` 와 `withBottomTab: boolean` 을 따로 받았는데 **둘 다 하단을 건드렸습니다.**
조합 넷이 만드는 값이 이랬습니다.
| 앞판 | paddingBottom |
| ----------------------------------- | ------------------------------ |
| `edges=[]`, `withBottomTab` | `tabBarHeight - insets.bottom` |
| `edges=['bottom']`, `withBottomTab` | `tabBarHeight` |
| `edges=['bottom']` | `insets.bottom` |
| `edges=[]` | `0` |
`tabBarHeight - insets.bottom` 은 앞판이 탭바 높이를 **상수로 박아서** 생긴 보정식입니다.
[`useTabBarHeight()`](/docs/native/tab-bar) 는 인셋을 포함해 계산하므로 보정이 필요 없고,
넷이 세 값으로 접힙니다.
## 탭바는 `absolute` 로 얹힌다 [#탭바는-absolute-로-얹힌다]
화면이 자기 아래 여백을 직접 잡아야 하는 이유입니다. 탭바를 흐름 안에 넣으면 `offsetBottom` 이
할 일이 없습니다.
```tsx
{children}
{/* … */}
```
## Suspense [#suspense]
`fallback` 없이 쓰면 정중앙 design-system `Spinner` 가 섭니다.
`Spinner` 가 `react-native-svg` · `react-native-reanimated` 를 씁니다. native 레인을 쓰는
소비처는 보통 이미 들고 있지만, 안 들고 싶으면 `fallback` 을 직접 넘기세요.
브라우저에는 안전 영역이 없어 인셋이 전부 0 입니다. `safeArea` 두 값이 여기서는 `none` 과
똑같이 보이고, `useTabBarHeight()` 도 `Platform.OS === 'web'` 이라 인셋을 안 탑니다.
**실기기에서는 홈 인디케이터만큼 더 큽니다** — 자로 재서 시안과 맞추면 안 됩니다.
스피너도 안 돕니다. 문서 사이트가 reanimated 를 얇은 대역으로 바꿔 두기 때문입니다.
## Props [#props]
| prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `children` | `ReactNode` | — | — |
| `offsetTop?` | `'none' \| 'safeArea'` | — | 상단 여백. 기본 `'none'` — 헤더는 react-navigation 소관이라 화면 밖이다. |
| `offsetBottom?` | `'none' \| 'safeArea' \| 'tabBar'` | — | 하단 여백. 기본 `'tabBar'`. |
| `fallback?` | `ReactNode` | — | Suspense 폴백. 기본은 정중앙 DS `Spinner`. |
그 밖의 `ViewProps` 속성은 루트 엘리먼트로 그대로 통과한다.
---
# Getting Started (/docs/screens)
`@coldsurfers/screens` 는 **화면 한 장이 어떻게 조립되는가**를 드는 패키지입니다.
[design-system](/docs) 과 별개로 발행되고, 그 위에 얹힙니다.
```
design-system 무엇처럼 보이는가 토큰 · 프리미티브 · 단품
screens 어떻게 조립되는가 safe area · 탭바 여백 · 로딩 경계
```
판별은 한 줄입니다.
> **화면 전체의 배치를 정하면 여기, 그 안의 한 칸이면 design-system.**
그래서 [`TabBar`](/docs/native/tab-bar) 는 단품이라 design-system 에 남고, 그 탭바만큼 아래를
비우는 [`AppScreen`](/docs/screens/app-screen) 은 여기입니다.
## 왜 design-system 안이 아닌가 [#왜-design-system-안이-아닌가]
이 층이 자라면 **내비게이션 프레임워크를 뭅니다.** 화면 헤더는 react-navigation 의
`NativeStackHeaderProps` 를 받는 자리고, 그 축이 design-system 계약에 들어오면 웹 소비자까지
내비게이션 라이브러리를 지게 됩니다.
같은 이유로 `@seed-design/stackflow` 가 `@seed-design/react` 코어 밖에 있습니다. 갈린 기준은
"셸이냐 프리미티브냐" 가 아니라 **프레임워크 의존**입니다.
## 설치 [#설치]
```bash
pnpm add @coldsurfers/screens
```
peer 는 전부 **필수**입니다 — design-system 과 달리 이 패키지는 RN 전용이라 안 쓰는 레인이
없습니다.
| peer | 왜 |
| ------------------------------------ | ----------------------------- |
| `@coldsurfers/surf-ui` | 색 · 타이포 · `useTabBarHeight()` |
| `react-native-safe-area-context` | 인셋 |
| `@emotion/native` · `@emotion/react` | 스타일 엔진 |
사본이 둘이 되면 `useScheme()` 이 다른 컨텍스트를 읽습니다. 마이크로프론트엔드로 원격
번들을 얹는 앱에서는 호스트가 노출한 **한 벌**을 써야 합니다.
## import 경로 [#import-경로]
배럴(`.`)과 표면별 진입점 둘 다 열려 있습니다.
```tsx
import { AppScreen } from '@coldsurfers/screens/AppScreen'
```
**진입점으로 여는 편이 낫습니다.** Metro 는 tree-shaking 을 하지 않아 배럴을 열면 이 패키지가
가진 것이 전부 딸려옵니다. 배럴을 남겨 둔 건 빼면 major 이기 때문입니다.