3. TSX 클래스 조합
TSX에서 클래스를 조합하는 방법과 UI 래퍼가 허용하는 스타일 범위를 정합니다. 클래스의 책임과 수정자로 표현할 변형을 구분하고, 시각적 표현은 인라인 style 대신 클래스로 지정합니다.
3.1 클래스는 clsx()로 조합합니다
기본 클래스와 상태 수정자의 조합을 TSX에서 한눈에 읽을 수 있습니다.
TSX의 className은 클래스가 하나여도 clsx()로 조합합니다.
인자는 기본 클래스 → 수정자 → 받은 className 순서로 적습니다.
+, join(), 삼항 연산자로 클래스를 조합하거나 고르지 않습니다.
클래스 이름에 값을 끼워 넣지 않는 규칙은 규칙 3.7가 정합니다.
문자열 연결로 클래스 조합을 숨깁니다.
<button type="button" className={"pg_products__listButton " + (isActive ? "pg_products__listButton--active" : "")}>
목록
</button>
기본 클래스와 수정자를 clsx()로 조합합니다.
<button
type="button"
className={clsx(
"pg_products__listButton",
isActive && "pg_products__listButton--active",
)}
>
목록
</button>
함께 보기
3.2 수정자는 상태와 반복되는 모양에만 씁니다
일회성 배치 보정이 수정자로 늘어나지 않게 합니다.
수정자는 앱 상태나 여러 곳에서 반복되는 모양에만 씁니다. 한 곳의 여백이나 배치를 보정할 때는 역할 이름을 붙인 별도 요소 클래스를 씁니다.
flowchart LR
q1{"앱이 켜고 끄는<br>상태인가?"} -- 아니요 --> q2{"두 곳 이상에서<br>반복되는 모양인가?"} -- 아니요 --> r3("역할 이름을 붙인<br>요소 클래스")
q1 -- 예 --> r1("수정자")
q2 -- 예 --> r2("수정자")| 표현하려는 것 | 판정 |
|---|---|
| 앱이 켜고 끄는 상태 | 항상 수정자로 씁니다. --active, --selected, --error, --expanded, --current |
브라우저가 부여하는 :disabled, :checked | 수정자로 만들지 않습니다. 규칙 4.5를 따릅니다 |
같은 수정자 이름이 두 개 이상의 scope_slug에 이미 있음 | 반복되는 모양이므로 허용합니다. --dense, --compact, --horizontal |
variant 프롭이 고르는 모양을 두 곳 이상에서 사용함 | scope_slug 수와 무관하게 수정자로 씁니다 |
| 위 조건에 맞지 않는 한 곳의 보정 | 요소 클래스로 씁니다. --compactTop, --marginLeft0, --alignRight 같은 수정자는 만들지 않습니다 |
두 번째 소유자가 같은 이름을 쓰기 전까지는 요소 클래스로 두고, 쓰게 되는 시점에 수정자로 바꿉니다.
그 화면 하나를 고치려고 수정자를 붙입니다.
<div className={clsx("pg_productDetail__section", "pg_productDetail__section--compactTop")} />
<div className={clsx("pg_productDetail__aside", "pg_productDetail__aside--marginLeft0")} />
한 곳의 보정은 역할 이름을 붙인 요소 클래스로 분리합니다.
<div className={clsx("pg_productDetail__specSection")} />
<div className={clsx("pg_productDetail__metaAside")} />
상태와 반복되는 모양만 수정자로 씁니다.
<div className={clsx("ui_table__root", isDense && "ui_table__root--dense")} />
<div className={clsx("pg_products__row", isSelected && "pg_products__row--selected")} />
함께 보기
3.3 기본 스타일과 상태를 한 클래스에 섞지 않습니다
기본 스타일과 상태를 분리해 상태만 켜고 끌 수 있습니다.
기본 스타일과 상태는 기본 클래스와 --수정자로 나눕니다.
listButtonActive처럼 상태를 기본 이름에 넣으면 기본 스타일만 재사용하거나 상태만 끌 수 없습니다.
수정자로 표현할 수 있는 상태인지는 규칙 3.2가 정합니다.
기본 클래스 이름에 상태를 포함합니다.
<div className={clsx("pg_products__listButtonActive")} />
기본 클래스와 상태 수정자를 분리합니다.
<div className={clsx("pg_products__listButton", isActive && "pg_products__listButton--active")} />
3.4 외부 클래스는 컴포넌트 진입점에서만 받습니다
클래스 주입을 한 곳으로 제한해 사용처가 내부 구조에 의존하지 않게 합니다.
우리가 만든 컴포넌트는 레이어와 무관하게 최상위 진입점 한 곳에서만 외부 클래스를 받습니다. 내부 노드의 클래스 주입 지점을 늘리면 사용처가 컴포넌트 구조에 의존하게 됩니다.
사용처가 바꾸는 자리
flowchart LR
q1{"최상위의 배치, 여백,<br>크기를 바꾸는가?"} -- 예 --> r1("받은 className을<br>자기 최상위 클래스와 합침")
q1 -- 아니요 --> r2("variant 프롭을 받아<br>필요한 노드마다 수정자")| 사용처가 바꾸려는 것 | 방법 |
|---|---|
| 최상위의 배치, 여백, 크기 | 받은 className을 자기 최상위 클래스와 clsx()로 합칩니다 |
| 화면마다 달라지는 내부 모양 | variant 프롭을 받고 헤더나 본문 등 필요한 노드마다 수정자를 붙입니다 |
금지하는 형태
| 금지하는 형태 | 이유 또는 예외 |
|---|---|
headerClassName, itemClassName 같은 내부 클래스 프롭 | 내부 구조가 바뀌면 사용처도 함께 깨집니다 |
받은 className을 내부 노드에 전달함 | 클래스 주입은 최상위까지만 허용합니다 |
| 최상위 수정자로 내부를 결합해 선택함 | 자손이 조상 구조에 의존합니다. 조상의 DOM 상태를 전달할 때만 규칙 4.6에 따라 결합자 하나를 씁니다 |
사용처가 어떤 방법을 고를지는 규칙 2.4가 정합니다.
className을 받지 않는 컴포넌트는 규칙 3.5을 따릅니다.
내부 노드마다 클래스 프롭을 열어 주입 지점을 늘립니다.
export interface UiCollapseProps {
className?: string;
headerClassName?: string;
titleClassName?: string;
contentClassName?: string;
}
클래스 프롭은 최상위 className 하나로 두고 내부는 variant로 엽니다.
export interface UiCollapseProps {
className?: string;
variant?: "default" | "compact";
title: ReactNode;
children: ReactNode;
}
받은 className을 내부 노드로 넘깁니다.
export const UiCollapse = (props: UiCollapseProps) => {
return (
<div className={clsx("ui_collapse__root")}>
<button className={clsx("ui_collapse__header", props.className)} type="button">
{props.title}
</button>
<div className={clsx("ui_collapse__content")}>{props.children}</div>
</div>
);
};
className은 최상위 클래스와 합치고, 변형은 필요한 노드마다 수정자로 붙입니다.
export const UiCollapse = (props: UiCollapseProps) => {
const isCompact = props.variant === "compact";
return (
<div className={clsx("ui_collapse__root", props.className)}>
<button className={clsx("ui_collapse__header", isCompact && "ui_collapse__header--compact")} type="button">
<span className={clsx("ui_collapse__title", isCompact && "ui_collapse__title--compact")}>{props.title}</span>
</button>
<div className={clsx("ui_collapse__content")}>{props.children}</div>
</div>
);
};
수정자의 선언은 소유자 CSS에 둡니다.
.ui_collapse__header {
padding: 12px 16px;
}
.ui_collapse__header--compact {
padding: 6px 8px;
}
.ui_collapse__title--compact {
font-size: 13px;
}
사용처는 최상위 스타일만 주고 내부 의도는 프롭으로 넘깁니다.
<UiCollapse className={clsx("pg_orderFilterDialog__collapse")} variant="compact" title="필터">
<PgOrderFilterFields />
</UiCollapse>
.pg_orderFilterDialog__collapse {
margin-block-start: 16px;
width: 100%;
}
함께 보기
3.5 스타일 때문에 요소를 새로 감싸지 않습니다
래퍼 요소는 부모 레이아웃 계산을 바꾸고 역할 없는 클래스를 늘립니다.
스타일을 주기 위해 래퍼 요소를 추가하지 않습니다.
래퍼를 넣으면 부모의 flex나 grid 아이템이 바뀌어
내부의 flex, grid-area, 정렬이 부모 레이아웃에 적용되지 않습니다.
flowchart LR
q1{"우리가 만든<br>컴포넌트인가?"} -- 예 --> r1("className을 받도록 고침")
q1 -- 아니요 --> r2("마지막 수단으로 래퍼<br>역할 이름과 감싼 이유 주석")역할 없는 래퍼는 규칙 1.3이 요구하는 이름도 지을 수 없습니다.
역할 없는 이름의 래퍼를 늘립니다.
<div className={clsx("pg_orders__box")}>
<div className={clsx("pg_orders__inner")}>
<LegacyDatePicker value={value} onChange={handleChange} />
</div>
</div>
외부 라이브러리가 className을 받지 않으면 역할 이름을 붙여 감쌉니다.
{/**
* LegacyDatePicker가 className을 받지 않아 배치용 래퍼 필요
*/}
<div className={clsx("pg_orders__dateField")}>
<LegacyDatePicker value={value} onChange={handleChange} />
</div>
래퍼 div로 최상위 스타일을 우회합니다.
<div className={clsx("pg_orders__collapseWrap")}>
<UiCollapse>
<PgOrderFilterFields />
</UiCollapse>
</div>
.pg_orders__collapseWrap {
margin-block-end: 16px;
}
래퍼를 걷고 컴포넌트에 자기 클래스를 넘깁니다.
<UiCollapse className={clsx("pg_orders__collapse")}>
<PgOrderFilterFields />
</UiCollapse>
.pg_orders__collapse {
margin-block-end: 16px;
}
className을 받지 않던 우리 컴포넌트에 계약을 더합니다.
export interface UiCollapseProps {
className?: string;
children: ReactNode;
}
export const UiCollapse = (props: UiCollapseProps) => {
return (
<div className={clsx("ui_collapse__root", props.className)}>{props.children}</div>
);
};
함께 보기
3.6 인라인 style로 스타일을 주지 않습니다
모든 시각 결정이 스타일시트에 남아 검색과 덮어쓰기가 예측대로 동작합니다.
시각 속성은 스타일시트에 선언하고 style={{ … }}로 직접 지정하지 않습니다.
인라인 선언은 클래스보다 우선순위가 높고 CSS 검색에 나타나지 않으며 :hover, @media, @container도 쓸 수 없습니다.
| 값 | 전달 방법 |
|---|---|
| 화면마다 달라지는 값 | 수정자 클래스로 전달합니다. 주입 위치는 규칙 3.4를 따릅니다 |
| 실행 중 계산해야 알 수 있는 수치 하나 | CSS 변수 한 개만 style로 넘기고 실제 속성 선언은 스타일시트에 둡니다 |
두 번째 행만 예외이며 가상 스크롤 위치, 드래그 좌표, 측정한 높이처럼 스타일시트에 미리 적을 수 없는 수치가 해당합니다.
변수가 없을 때의 대체값은 규칙 5.1를 따릅니다.
래퍼가 HTMLAttributes를 extends하면 style도 열리므로 style을 쓰는지는 리뷰에서 확인합니다.
인라인으로 꾸밉니다.
<section className={clsx("pg_orders__summary")} style={{marginTop: 16, color: isCritical ? "#c00" : undefined}}>
{summaryText}
</section>
스타일시트에 두고 수정자로 가릅니다.
<section className={clsx("pg_orders__summary", isCritical && "pg_orders__summary--critical")}>
{summaryText}
</section>
인라인으로 적던 선언을 스타일시트에 둡니다.
.pg_orders__summary {
margin-block-start: 16px;
}
.pg_orders__summary--critical {
color: var(--app-color-text-danger);
}
실행 중에만 아는 수치를 CSS 변수 하나로 넘깁니다.
<div
className={clsx("pg_orders__virtualRow")}
style={{"--pg-orders-row-offset": `${rowOffset}px`} as CSSProperties}
/>
.pg_orders__virtualRow {
position: absolute;
transform: translateY(var(--pg-orders-row-offset, 0));
}
함께 보기
3.7 수정자는 조건으로 적고 클래스 이름을 조립하지 않습니다
클래스 이름이 코드에 문자열로 남아 CSS와 사용처를 한 번의 검색으로 함께 고칩니다.
수정자는 조건과 완성된 클래스 문자열로 적습니다. 값을 끼워 이름을 조립하면 CSS와 사용처를 같은 문자열로 검색할 수 없습니다.
| 상황 | 작성 방법 |
|---|---|
| 값 하나에 수정자를 붙임 | tone === "positive" && "pg_products__changeRate--positive"처럼 씁니다 |
| 값이 여럿임 | 값마다 한 줄씩 적습니다. 여러 요소에 같은 값을 적용해도 요소마다 나열합니다 |
| 일부 값에만 CSS 수정자가 있음 | 해당 값만 나열하고 나머지는 기본 모습으로 둡니다. 값이 다섯이고 수정자가 둘이면 둘만 적습니다 |
ButtonProps["variant"]처럼 라이브러리 타입을 그대로 받음 | 수정자를 만들지 않고 라이브러리에 넘깁니다. 라이브러리가 추가한 값을 우리 목록이 놓칠 수 있습니다 |
| 라이브러리와 별개인 우리 모습이 필요함 | 우리 어휘로 정의한 프롭을 따로 받습니다 |
수정자를 붙일 수 있는지는 규칙 3.2가 정합니다.
클래스 이름을 값으로 조립합니다.
export interface UiTooltipProps {
variant?: "fit" | "plain";
children: ReactNode;
}
export const UiTooltip = (props: UiTooltipProps) => {
return (
<div className={clsx("ui_tooltip__body", props.variant && `ui_tooltip__body--${props.variant}`)}>
{props.children}
</div>
);
};
값마다 한 줄로 나열합니다.
export interface UiTooltipProps {
variant?: "fit" | "plain";
children: ReactNode;
}
export const UiTooltip = (props: UiTooltipProps) => {
return (
<div
className={clsx(
"ui_tooltip__body",
props.variant === "fit" && "ui_tooltip__body--fit",
props.variant === "plain" && "ui_tooltip__body--plain",
)}
>
{props.children}
</div>
);
};
라이브러리가 정하는 값으로 수정자를 만듭니다.
export interface UiButtonProps {
variant?: ButtonProps["variant"];
className?: string;
}
export const UiButton = (props: UiButtonProps) => {
return <Button className={clsx("ui_button__root", `ui_button__root--${props.variant}`, props.className)} />;
};
라이브러리가 정하는 값은 수정자로 만들지 않고 그대로 넘깁니다.
export interface UiButtonProps {
variant?: ButtonProps["variant"];
className?: string;
}
export const UiButton = (props: UiButtonProps) => {
return <Button className={clsx("ui_button__root", props.className)} variant={props.variant} />;
};
수정자가 없는 값까지 조립해 CSS에 없는 클래스를 붙입니다.
type Tone = "positive" | "negative" | "neutral" | "unknown";
<span className={clsx("pg_products__changeRate", `pg_products__changeRate--${tone}`)}>{formatAmount(amount)}</span>;
.pg_products__changeRate--positive {
color: var(--app-color-rise);
}
.pg_products__changeRate--negative {
color: var(--app-color-fall);
}
CSS에 수정자가 있는 두 값만 적고 나머지는 기본 모습을 씁니다.
<span
className={clsx(
"pg_products__changeRate",
tone === "positive" && "pg_products__changeRate--positive",
tone === "negative" && "pg_products__changeRate--negative",
)}
>
{formatAmount(amount)}
</span>;
.pg_products__changeRate--positive {
color: var(--app-color-rise);
}
.pg_products__changeRate--negative {
color: var(--app-color-fall);
}
같은 값이 요소 셋의 수정자를 정하면 요소마다 나열을 반복합니다.
export interface WgUserCardProps {
role: "owner" | "member";
label: string;
description: string;
}
export const WgUserCard = (props: WgUserCardProps) => {
return (
<div
className={clsx(
"wg_userCard__root",
props.role === "owner" && "wg_userCard__root--owner",
props.role === "member" && "wg_userCard__root--member",
)}
>
<span
className={clsx(
"wg_userCard__title",
props.role === "owner" && "wg_userCard__title--owner",
props.role === "member" && "wg_userCard__title--member",
)}
>
{props.label}
</span>
<p
className={clsx(
"wg_userCard__description",
props.role === "owner" && "wg_userCard__description--owner",
props.role === "member" && "wg_userCard__description--member",
)}
>
{props.description}
</p>
</div>
);
};
함께 보기