Navigation

4. 선택자와 선언 배치

선택 대상은 클래스 이름으로 명시하고, 각 클래스의 선언은 한 블록에 모읍니다. 브라우저가 제공하는 DOM 상태는 가상 클래스로, 앱이 정의한 상태는 수정자 클래스로 표현합니다.

4.1 선택자 중첩은 한 겹까지만 쓰고 나머지 경로는 한 줄로 잇습니다

선택자 경로가 여러 중첩 블록에 흩어지지 않습니다.

선택자 블록 중첩은 한 겹, &는 한 선택자에 한 번만 씁니다. 중첩 깊이는 {}로 세되 @media, @supports, @container 같은 at-rule 블록은 제외합니다.

작성 위치방법
중첩 블록의 선택자항상 &로 시작합니다
그 블록이 소유한 요소의 조건이나 가상 요소&:hover, &::before처럼 씁니다
다른 요소로 이어지는 경로&를 다시 열지 않고 같은 선택자 줄에 이어 씁니다
.box 자신의 ::before.box { &::before { } }로 씁니다
.button의 hover에 반응하는 .box::before.button { &:hover .box::before { } }로 씁니다

두 겹 이상 중첩하면 .pg_a .pg_b .pg_c 같은 전체 경로가 여러 블록에 흩어지고 기계 검사도 각 블록만 봅니다. & 없이 시작하면 자손 선택자가 별도 겹처럼 읽혀 표기에 따라 검사 결과가 달라집니다. 기계 검사는 max-nesting-depth: 1이며 최상위는 0겹입니다.

다른 요소의 가상 요소를 &로 다시 엽니다.

.pg_products__sortButton {
	&:hover .pg_products__sortBox {
		&::before {
			border-color: #9fadc7;
		}
	}
}

&는 한 번, 그다음 경로는 같은 줄에 이어 씁니다.

.pg_products__sortBox {
	&::before {
		border: 2px solid #ced4da;
	}
}

.pg_products__sortButton {
	&.MuiButtonBase-root {
		display: inline-flex;
	}

	&:hover .pg_products__sortBox::before {
		border-color: #9fadc7;
	}
}

중첩을 두 겹 이상 열어 실제 선택자를 숨깁니다.

.pg_products__sortButton {
	&.MuiButtonBase-root {
		&:hover {
			.pg_products__sortBox {
				border-color: #9fadc7;
			}
		}
	}
}

외부 라이브러리 경로도 깊이와 무관하게 한 줄로 씁니다.

.pg_orderTable__root {
	& .MuiTableHead-root > tr > th {
		border-bottom: 2px solid #d9d9d9;
	}
}

함께 보기

4.2 요소 선택자 대신 클래스를 붙입니다

태그를 바꿔도 스타일이 유지되도록 마크업을 클래스로 선택합니다.

선택 방법

우리가 렌더하는 마크업은 요소 선택자 대신 클래스로 선택합니다. 태그를 div에서 section으로 바꿔도 스타일이 사라지지 않아야 합니다.

flowchart LR
	q1{"우리가 렌더하는<br>마크업인가?"} -- 아니요 --> q2{"렌더러가 클래스 지정<br>API를 제공하는가?"} -- 아니요 --> r3("감싼 클래스 블록 안에서만<br>요소 선택자")
	q1 -- 예 --> r1("클래스를 붙임")
	q2 -- 예 --> r2("렌더 함수 등<br>그 API로 클래스를 붙임")

dangerouslySetInnerHTML이나 클래스 지정 API가 없는 Markdown 렌더러나 에디터 출력이 마지막 갈래입니다. :first-child 같은 구조 선택자도 같은 기준을 따릅니다. 최상위에 h2 { }를 선언하면 해당 스타일시트를 읽은 문서 전체에 적용되므로 예외에서도 금지합니다.

예외 주석 형태

selector-disallowed-list가 & 바로 뒤의 요소 선택자를 막으므로 예외에는 다음 주석을 남깁니다. 예외 선택자가 하나면 stylelint-disable-next-line을 쓰고, 둘 이상이면 stylelint-disable과 stylelint-enable 쌍으로 감쌉니다. 규칙 이름 뒤에 -- <마크업 출처>처럼 직접 작성하지 않는 마크업이라는 근거를 함께 적습니다.

우리가 렌더하는 마크업을 요소 선택자로 잡습니다.

<div className={clsx("pg_products__toolbar")}>
	<div>
		<UiSearchInput />
	</div>
	<button type="button">초기화</button>
</div>
.pg_products__toolbar {
	& button {
		height: 32px;
	}

	& > div {
		flex: 1;
	}

	& > :first-child {
		margin-inline-start: 0;
	}
}

우리가 렌더하면 클래스를 붙입니다.

<div className={clsx("pg_products__toolbar")}>
	<div className={clsx("pg_products__toolbarField")}>
		<UiSearchInput />
	</div>
	<button type="button" className={clsx("pg_products__toolbarButton")}>
		초기화
	</button>
</div>
.pg_products__toolbarField {
	flex: 1;
	margin-inline-start: 0;
}

.pg_products__toolbarButton {
	height: 32px;
}

요소 선택자를 최상위에 둡니다.

<div
	className={clsx("wg_productDetail__prose")}
	dangerouslySetInnerHTML={{__html: product.bodyHtml}}
/>
/* 블록 밖에 홀로 둔 요소 선택자. 이 스타일시트를 읽은 문서의 모든 h2에 적용 */
h2 {
	margin: 24px 0 12px;
}

마크업을 우리가 쓰지 않으면 래퍼 블록 안에서 요소 선택자를 씁니다.

<div
	className={clsx("wg_productDetail__prose")}
	// biome-ignore lint/security/noDangerouslySetInnerHtml: 서버가 정제한 본문 HTML
	dangerouslySetInnerHTML={{
		// biome-ignore lint/style/useNamingConvention: React 가 정한 속성 이름
		__html: product.bodyHtml,
	}}
/>
/* stylelint-disable selector-disallowed-list -- dangerouslySetInnerHTML로 들어온 마크업 */
.wg_productDetail__prose {
	& h2 {
		margin: 24px 0 12px;
		font-size: 18px;
	}

	& p {
		margin: 0 0 12px;
		line-height: 1.7;
	}

	& > :first-child {
		margin-top: 0;
	}
}
/* stylelint-enable selector-disallowed-list */

함께 보기

4.3 공통 선언을 쓰려고 클래스를 쉼표로 묶지 않습니다

공통 선언도 각 클래스에 두어 전체 스타일을 한 곳에서 읽습니다.

공통 선언 처리

공통 선언을 공유하려고 여러 클래스를 ,로 묶지 않습니다. 중복되더라도 각 클래스 블록에 선언을 모두 적어 한 클래스의 스타일을 한 곳에서 읽게 합니다.

형태처리
여러 클래스가 같은 선언을 씀각 블록에 반복합니다. 목록을 따로 관리하지 않습니다
한 대상에 진입 조건이 여럿임조건마다 블록을 엽니다. ,나 :is()로 묶지 않습니다
값을 지역 변수로 빼서 공유함규칙 5.2 규칙에 따라 금지합니다
@media나 @supports 안에서 같은 클래스를 재선언함이 규칙의 대상이 아닙니다
전역 스타일시트의 *, *::before, *::after 묶음클래스가 아니므로 이 규칙의 대상이 아닙니다

기계 검사 범위

검사 대상담당
쉼표 목록의 선택자를 아래에서 단독으로 다시 선언함no-duplicate-selectors의 disallowInList 옵션
중복 없이 쉼표로 묶기만 함리뷰. 기계 검사는 묶음 자체를 막지 않습니다

,로 공통 선언을 묶고 아래에서 일부만 다시 엽니다.

.pg_products__badge--draft,
.pg_products__badge--active,
.pg_products__badge--hidden {
	width: 24px;
	height: 24px;
}

.pg_products__badge--hidden {
	background: rgb(140 152 160 / 12%);
}

각 클래스가 자기 선언을 전부 가집니다.

.pg_products__badge--draft {
	width: 24px;
	height: 24px;
}

.pg_products__badge--active {
	width: 24px;
	height: 24px;
}

.pg_products__badge--hidden {
	width: 24px;
	height: 24px;
	background: rgb(140 152 160 / 12%);
}

한 대상의 진입 조건을 ,로 나열합니다.

.pg_products__sortButton {
	&:hover .pg_products__sortBox,
	&.Mui-focusVisible .pg_products__sortBox {
		border-color: #9fadc7;
	}
}

진입 조건마다 블록을 따로 열고 선언을 그대로 씁니다.

.pg_products__sortButton {
	&:hover .pg_products__sortBox {
		border-color: #9fadc7;
	}

	&.Mui-focusVisible .pg_products__sortBox {
		border-color: #9fadc7;
	}
}

함께 보기

4.4 한 클래스의 선언은 한 블록에 모읍니다

한 클래스의 선언을 한 블록에서 확인하고 수정합니다.

한 클래스의 선언은 파일 안 한 블록에 모읍니다. 같은 클래스를 여러 곳에서 다시 열어 선언 순서로 덮어쓰지 않습니다.

형태판정
기본 클래스와 수정자서로 다른 클래스이므로 각자 블록을 둡니다. 한 요소에 함께 붙으면 명시도와 선언 순서를 확인합니다
쉼표로 묶어 선언을 나눔규칙 4.3 규칙이 금지합니다
@media, @supports, @container 안의 재선언조건이 다른 별개 블록이므로 허용합니다

조건 블록의 위치는 규칙 6.1을 따르고, 기계 검사는 no-duplicate-selectors가 담당합니다.

같은 클래스를 파일 두 곳에서 열어 선언 순서에 의존합니다.

.pg_products__toolbar {
	display: flex;
	gap: 12px;
	padding: 8px;
}

.pg_products__row {
	background: #f5f5f5;
}

.pg_products__toolbar {
	padding: 12px 16px;
}

한 블록에 모으고 최종 값만 남깁니다.

.pg_products__toolbar {
	display: flex;
	gap: 12px;
	padding: 12px 16px;
}

.pg_products__row {
	background: #f5f5f5;
}

조건이 다르면 별개 블록으로 둡니다.

.pg_products__toolbar {
	display: flex;
	gap: 12px;
	padding: 12px 16px;
}

@media (width < 1024px) {
	.pg_products__toolbar {
		padding: 8px;
	}
}

함께 보기

4.5 DOM이 표현하는 상태는 가상 클래스로 씁니다

DOM 상태와 앱 상태를 구분해 같은 상태를 중복 표현하지 않습니다.

네이티브 DOM 기능이 표현하는 상태는 가상 클래스로, 앱이 정하는 상태는 수정자 클래스로 씁니다. 앱이 disabled나 checked를 제어해도 같은 상태의 수정자를 추가하지 않습니다.

상태스타일 표현주의점
hover, visited, focus-visible, 네이티브 disabled와 checked해당 가상 클래스--disabled, --checked로 복제하지 않습니다
앱의 selected, active, error, expanded, current--수정자 클래스aria-*, data-* 속성 선택자로 지정하지 않습니다
네이티브 disabled를 지원하지 않는 요소의 비활성 상태접근성 속성과 앱 수정자aria-disabled="true"만으로 :disabled가 적용되지 않습니다. 실제 동작은 마크업과 이벤트 처리에서 막습니다
사용자가 요소를 누르는 동안의 상태:active앱의 --active와 뜻이 다르므로 서로 바꾸지 않습니다

aria-*는 접근성 계약이므로 마크업에 유지하고, 접근성 속성과 수정자가 함께 필요하면 같은 값에서 계산합니다. 가상 클래스의 위치는 규칙 4.6, :not() 금지는 규칙 4.7을 따릅니다.

앱이 정하는 상태를 data-* 속성 선택자로 잡습니다.

.pg_products__row {
	&[data-pg-expanded="true"] {
		background: #f5f5f5;
	}
}

앱이 정하는 상태는 수정자 클래스로 씁니다.

.pg_products__row--expanded {
	background: #f5f5f5;
}

같은 상태를 속성과 수정자 두 표기로 씁니다.

.pg_products__card--selected {
	border-color: #1677ff;
}

.pg_products__card[aria-pressed="true"] {
	box-shadow: 0 0 0 1px #1677ff;
}

두 표기를 수정자 하나로 모읍니다.

.pg_products__card--selected {
	border-color: #1677ff;
	box-shadow: 0 0 0 1px #1677ff;
}

앱 상태를 속성 선택자로 잡고 DOM 상태를 수정자로 만듭니다.

<button
	type="button"
	aria-pressed={isSelected}
	className={clsx("pg_products__card", isDisabled && "pg_products__card--disabled")}
>
	{product.name}
</button>
.pg_products__card {
	border: 1px solid #d9d9d9;

	&[aria-pressed="true"] {
		border-color: #1677ff;
	}
}

.pg_products__card--disabled {
	opacity: 0.5;
}

aria-*는 마크업에 두고 앱 상태는 수정자로, DOM 상태는 가상 클래스로 씁니다.

<button
	type="button"
	aria-pressed={isSelected}
	disabled={isDisabled}
	className={clsx("pg_products__card", isSelected && "pg_products__card--selected")}
>
	{product.name}
</button>
.pg_products__card {
	border: 1px solid #d9d9d9;

	&:disabled {
		opacity: 0.5;
	}
}

.pg_products__card--selected {
	border-color: #1677ff;
}

함께 보기

4.6 DOM 상태 가상 클래스는 해당 요소의 기본 블록에 둡니다

기본 모습과 상태 변화를 함께 읽고 수정자가 꺼져도 상호작용 표시를 유지합니다.

상태 가상 클래스 자리

DOM 상태 가상 클래스는 해당 요소의 조건 없는 기본 클래스 블록 안에 &:로 씁니다. 기본 모습과 상태 변화를 함께 읽도록 블록 바깥이나 수정자 블록에서 다시 열지 않습니다.

상황작성 방법
도메인 상태와 무관한 :hover, :focus-visible, :disabled기본 블록에 둡니다. 수정자가 꺼졌을 때 상호작용 표시가 사라지지 않게 합니다
수정자가 켜졌을 때만 상호작용이 달라져야 한다는 제품 요구가 있음수정자 안에 둘 수 있으며 그 예외를 적습니다
여러 상태가 같은 선언을 씀상태마다 블록을 엽니다. 규칙 4.3에 따라 묶지 않습니다
조상의 DOM 상태가 자손을 바꿈조상 블록에서 식별자가 같은 자손을 결합자 하나로 선택합니다
조상 상태를 자손 블록에서 읽거나 지역 변수로 전달함:has()도 쓰지 않습니다. 지역 변수는 규칙 5.2 규칙이 금지합니다

포커스 표시 자체는 규칙 7.1를 따릅니다. 자손의 :hover는 포인터가 자손 위에 있을 때만 적용되므로 조상의 hover를 대신하지 못합니다. 자손 기본 블록은 조상 규칙보다 앞에 둡니다. 뒤에 두면 낮은 명시도의 규칙이 나중에 나와 no-descending-specificity에 걸립니다.

기계 검사 범위

기계 검사검사 대상
selector-disallowed-list최상위에 다시 선언한 상태 가상 클래스
property-disallowed-list지역 변수 선언

가상 클래스를 최상위 선택자로 다시 엽니다.

.wg_siteHeader__brandLink {
	color: #1677ff;
}

.wg_siteHeader__brandLink:hover {
	color: #0958d9;
}

.wg_siteHeader__brandLink:focus-visible {
	color: #0958d9;
	outline: 2px solid #1677ff;
}

기본 블록 안에서 각 상태를 별도의 &: 블록으로 선언합니다.

.wg_siteHeader__brandLink {
	color: #1677ff;

	&:hover {
		color: #0958d9;
	}

	&:focus-visible {
		color: #0958d9;
		outline: 2px solid #1677ff;
	}
}

조상이 hover일 때 바뀌는 모습을 자손 블록의 &:hover로 씁니다.

.wg_siteHeader__brandMark {
	transform: rotate(0deg);

	/* 링크 전체에 hover하면 로고가 기울어야 하는데 로고 위에서만 적용 */
	&:hover {
		transform: rotate(-2deg);
	}
}

.wg_siteHeader__brandLink {
	color: #1677ff;
}

조상 상태가 자손을 바꾸면 같은 소유자 안에서 결합자 하나만 씁니다.

.wg_siteHeader__brandMark {
	transform: rotate(0deg);
}

.wg_siteHeader__brandLink {
	color: #1677ff;

	&:hover .wg_siteHeader__brandMark {
		transform: rotate(-2deg);
	}
}

상호작용 상태를 수정자 아래로 옮겨 적용 대상을 좁힙니다.

.ui_button__root--active {
	background: var(--app-color-accent);

	&:hover {
		background: var(--app-color-accent-strong);
	}

	&:focus-visible {
		outline: 2px solid var(--app-color-focus);
	}
}

상호작용 상태를 조건 없는 기본 블록으로 되돌립니다.

.ui_button__root {
	&:hover {
		background: var(--app-color-accent-strong);
	}

	&:focus-visible {
		outline: 2px solid var(--app-color-focus);
	}
}

.ui_button__root--active {
	background: var(--app-color-accent);
}

함께 보기

4.7 선택자에 :not()을 쓰지 않습니다

기본 모습을 기본 블록에 두어 부정 조건을 따로 해석하지 않게 합니다.

:not()을 쓰지 않고 기본 모습은 기본 블록에, 상태가 켜진 모습은 상태 블록에 둡니다. 부정 조건을 없앨 때도 상태별 결과를 보존합니다.

기존 형태변경 방법
&:not(:disabled)해당 선언을 기본 블록으로 옮기고 &:disabled에서 필요한 속성을 덮습니다
:not(:disabled):hover부정 조건만 지우지 않습니다. 기본 상태와 비활성 상태의 조합을 확인하고 해당 속성을 명시적으로 되돌립니다
네이티브 폼 컨트롤의 활성 상태만 선택함:enabled:hover 같은 긍정 조건을 쓸 수 있습니다
조상 수정자가 자손의 모습을 바꿈자손 수정자로 옮깁니다. 선택 여부 각각에서 hover와 포커스 결과도 확인합니다

앱 상태는 각 요소의 수정자로 표현하고 조상에서 다시 읽지 않습니다. DOM 상태와 앱 상태의 구분은 규칙 4.5를 따릅니다.

활성 버튼의 hover를 부정 조건으로 표현합니다.

.pg_products__cardButton {
	&:not(:disabled):hover {
		background: #f5f5f5;
	}
}

네이티브 버튼의 활성 상태를 긍정 조건으로 표현합니다.

.pg_products__cardButton {
	&:enabled:hover {
		background: #f5f5f5;
	}
}

DOM 상태를 :not()으로 뒤집어 기본 모습을 상태 블록에 넣습니다.

.pg_products__cardButton {
	&:not(:disabled) {
		cursor: pointer;
	}

	&:disabled {
		cursor: default;
	}
}

DOM 상태도 기본을 먼저 두고 그 상태만 덮습니다.

.pg_products__cardButton {
	cursor: pointer;

	&:disabled {
		cursor: default;
	}
}

함께 보기