React Native + Unistyles v3 스타일링 컨벤션
목적: 가독성, 일관성, 성능(무리렌더 업데이트), 확장성을 보장하는 팀 표준.
0) 핵심 원칙 (요약)
- 점 표기만 사용:
styles.ui_text_root✅ /styles["ui-text-root"]❌ - 언더스코어 스네이크 케이스 키:
ui_text_root,w_header_left… - 파트 단위 쌍 구조: 앵커(객체)
*_root↔ 동적(함수)*_root_dyn - 의미/토글 = variants, 연속/런타임 = dynamic
- style 배열 우선순위: 앵커 → 동적 → 사용자 오버라이드
1) 키/접근 규칙
키 네이밍:
snake_case+ 접두어- 도메인 접두어:
ui_(디자인 시스템/원자),w_(widget),scr_(screen),tpl_(template) - 로컬 접두어(컴포넌트 단위): 예)
w_header_*,ui_text_* - 파트 명:
_root,_left,_center,_right,_title,_subtitle,_caption,_action… - 동적 함수 접미어:
*_dyn
- 도메인 접두어:
접근 방식: 항상 점 표기만 사용해 Unistyles 바인딩을 보장.
2) 컴포넌트 프롭 표준
variants: 닫힌 집합(열거형) — 의미/의도/상태/사이즈 등dynamic: 연속·런타임 값 — 측정/제스처/실시간/미세 보정- 내부에서 기본값을 초기화·병합 후
styles.useVariants로 적용
// 내부 병합 예시
const init = { tone: 'base', size: 'md' } as const;
styles.useVariants({ ...init, ...variants });
3) Variants vs Dynamic — 선택 기준
룰 오브 썸
- 토글/열거형(의도, 톤, 상태, 사이즈) → variants
- 연속/계산/실시간 값(opacity, lineHeight, radius, width/height, 측정치) → dynamic
- 여러 속성이 함께 토글 → variants (+ 필요 시 compound variants)
- 조합 폭발 기미가 보이면 → 핵심 축만 variants, 나머지는 dynamic으로 분리
- 디자인 토큰 강제 필요 → variants 우선
안티패턴
- 불리언 프롭 다발(
isTitle,isMuted…) → 단일 variants로 수렴(tone) - 연속값을 억지 열거(
opacity_10,opacity_20…) → dynamic 사용
4) 스타일 시트/컴포넌트 템플릿
4.1 텍스트 (UiText)
// ui-text.style.ts
import { StyleSheet } from 'react-native-unistyles';
export type UiTextVariants = {
tone?: 'base'|'title'|'muted'|'subtitle'|'caption';
size?: 'sm'|'md'|'lg';
};
export type UiTextRootDynamic = {
opacity?: number; // 0~1
letterSpacing?: number; // px
lineHeight?: number; // px
};
const styles = StyleSheet.create(theme => ({
// 앵커(객체): variants를 선언적으로 모은다
ui_text_root: {
color: theme.colors.text,
fontFamily: 'Pretendard',
fontSize: theme.fontSizes.md,
variants: {
tone: {
base: {},
title: { fontWeight: '700' },
muted: { color: theme.colors.muted },
subtitle: { color: theme.colors.text },
caption: { color: theme.colors.muted },
},
size: {
sm: { fontSize: theme.fontSizes.sm },
md: { fontSize: theme.fontSizes.md },
lg: { fontSize: theme.fontSizes.lg },
},
compoundVariants: [
{ tone: 'title', size: 'lg', style: { letterSpacing: 0.2 } },
],
},
},
// 동일 파트 전용 동적 함수(연속값만 다룸)
ui_text_root_dyn: (d?: UiTextRootDynamic) => {
const o = d ?? {};
const opacity = typeof o.opacity === 'number' ? Math.max(0, Math.min(1, o.opacity)) : undefined;
return { opacity, letterSpacing: o.letterSpacing, lineHeight: o.lineHeight };
},
}));
export default styles;
// UiText.tsx
import type { ComponentProps } from 'react';
import { Text } from 'react-native';
import styles, { type UiTextVariants, type UiTextRootDynamic } from './ui-text.style';
export interface UiTextProps extends ComponentProps<typeof Text> {
variants?: UiTextVariants; // 의미/토글
dynamic?: UiTextRootDynamic; // 연속/런타임
}
const UiText = ({ variants, dynamic, ...rest }: UiTextProps) => {
const init: UiTextVariants = { tone: 'base', size: 'md' };
styles.useVariants({ ...init, ...variants });
return (
<Text
style={[
styles.ui_text_root, // 앵커(variants 적용 지점)
styles.ui_text_root_dyn(dynamic), // 연속값 보정
rest.style, // 사용자 오버라이드
]}
{...rest}
/>
);
};
export default UiText;
4.2 헤더 (WidgetHeader) - 파트별 _dyn 예시
// widget-header.style.ts
import { StyleSheet } from 'react-native-unistyles';
const styles = StyleSheet.create(theme => ({
w_header_root: {
flexDirection: 'row', alignItems: 'center', minHeight: 56,
variants: {
size: {
sm: { minHeight: 48, paddingHorizontal: 12 },
md: { minHeight: 56, paddingHorizontal: 16 },
lg: { minHeight: 64, paddingHorizontal: 20 },
},
},
},
w_header_root_dyn: ({ insetTop }: { insetTop?: number } = {}) => ({ paddingTop: insetTop }),
w_header_left: { width: 56, justifyContent: 'center' },
w_header_left_dyn: ({ opacity }: { opacity?: number } = {}) => ({ opacity }),
w_header_center:{ flex: 1, alignItems: 'center', justifyContent: 'center' },
w_header_right: { width: 56, alignItems: 'flex-end', justifyContent: 'center' },
}));
export default styles;
// 사용 예
<View
style={[
styles.w_header_root,
styles.w_header_root_dyn({ insetTop }),
]}
>
<View style={[styles.w_header_left, styles.w_header_left_dyn({ opacity: 0.7 })]} />
<View style={styles.w_header_center} />
<View style={styles.w_header_right} />
</View>
5) style 배열 우선순위 (중요)
- 앵커
styles.<part>_root(variants 적용·바인딩 앵커) - 동적
styles.<part>_root_dyn(dynamic)(연속값 보정) - 사용자 오버라이드
props.style
규칙(필수): style은 분리해서 마지막 배열에 합치기 →
[앵커, 동적, style]예:
<Text style={[styles.ui_text_root, styles.ui_text_root_dyn(dynamic), style]} />
컴포넌트별 패턴
View/Text/Image 등(비-Pressable)
const { style, ...rest } = props; return ( <View {...rest} style={[styles.ui_box_root, styles.ui_box_root_dyn(dynamic), style]} /> );Pressable — 상태 콜백 내에서 사용자 style이 함수면 호출해 병합
const { style, ...rest } = props; return ( <Pressable {...rest} style={(state) => [ styles.ui_button_root, styles.ui_button_root_dyn(dynamic), styles.ui_button_root_state(state), typeof style === 'function' ? style(state) : style, ]} /> );
안티패턴
- init/merged 단계에서
style을 미리 넣고...rest로 얕은 병합 → 호출자style이 통째로 덮어씀 style={[...]} {...rest}(스프레드가 뒤) →rest.style이 앞의 배열을 덮어씀
6) FAQ
Q. 대괄호 표기도 되는데 왜 점 표기만?
- 점 표기는 Unistyles의 정적 바인딩(테마/브레이크포인트/variants 업데이트)을 확실히 연결한다. 대괄호는 계산형 접근으로 취급되어 최적화/분석이 스킵될 수 있어 리스크.
Q. dynamic을 root 외 다른 파트에 적용하고 싶다?
- 각 파트에 대응하는
_dyn을 만들고, 같은 노드의 style 배열에서 그 파트의 앵커와 함께 사용.
Q. 공용 *_dyn을 써도 되나?
- 전 파트 공통·안전 속성(opacity 단일 등)만 제한적으로. 권장은 파트 전용
_dyn.
7) PR/리뷰 체크리스트
- 키는
snake_case이며 점 표기만 사용 - 최소 1개 **앵커(객체)**가 style 배열에 포함되어 바인딩 확보
- 의미/토글은
variants, 연속/런타임은dynamic - 조합 폭발 징후 없음(과도한 compound variants 금지)
- dynamic에서 토큰 성격 속성(색/폰트사이즈 등)을 임의로 덮지 않음
- 연속값 클램핑/검증(예:
opacity0~1) - style 배열 순서: 앵커 → 동적 → 사용자 오버라이드
8) 마이그레이션 가이드
하이픈 키 → 언더스코어로 변경:
"w-header-root"→w_header_root대괄호 접근 제거, 점 표기 앵커 확보
불리언 프롭 다발 → 단일
variants축으로 통합(tone,size등)연속값 열거 제거 → 해당 파트의
*_dyn으로 이전임시 별칭으로 점진적 이전 가능:
// deprecate 예정 ui_text_dyn: (...args) => styles.ui_text_root_dyn(...args),
9) 확장 팁
- DX 향상:
tone/size를 최상위 프롭으로 받고 내부에서variants로 래핑 - 필요 시
*_dyn을 도메인별로 쪼개기:*_dynTypography,*_dynLayout(과분화 금지) - 팀 린트 규칙(권장):
no-restricted-syntax로styles["..."]사용 금지,key-spacing으로 snake_case 강제 등
10) 예시 요약 (Good/Bad)
Good
<Text style={[styles.ui_text_root, styles.ui_text_root_dyn({ opacity: 0.8 }), props.style]} />
Bad
<Text style={[styles["ui-text-root"], props.style]} /> // 대괄호 전용
<Text style={[styles.ui_text_root, styles.ui_text_root, props.style]} /> // 중복 앵커
<Text style={[{ fontSize: 13 }, props.style]} /> // 앵커 누락 → 바인딩 없음