Navigation

6. 레이아웃과 반응형

클래스 이름과 선언에 배치 의도를 드러내고, 폭에 따른 변경은 한곳에 모읍니다. 브레이크포인트를 추가하기 전에 고유 크기 지정으로 해결할 수 있는지 확인합니다. 뷰포트 브레이크포인트는 파일 아래 한곳에 모으고 데스크톱 퍼스트로 정한 세 값만 씁니다. 컴포넌트가 받은 폭에 따라 구조를 바꿔야 하면 컨테이너 쿼리를 씁니다.

6.1 @media 브레이크포인트는 파일 맨 아래에 모읍니다

각 브레이크포인트에서 달라지는 스타일을 한 블록에서 확인합니다.

브레이크포인트 재선언은 파일 맨 아래 @media 블록에 모으고 클래스 블록 안에 중첩하지 않습니다. 같은 폭에서 툴바, 패널, 사이드바가 어떻게 달라지는지 한 블록에서 읽도록 합니다. 조건 방향과 값은 규칙 6.2를 따릅니다.

반복되거나 별도로 판단할 것처리
같은 역할에서 값만 파일마다 다름토큰 파일에서 값을 나눕니다
선택자와 선언까지 같은 배치 책임이 여러 파일에 반복됨배치를 컴포넌트 하나로 모을지 검토하고 브레이크포인트를 그 파일에만 둡니다
조건 숫자만 같고 역할은 다름컴포넌트를 합치지 않습니다
브레이크포인트 없이 배치할 수 있음규칙 6.4 규칙을 먼저 적용합니다
prefers-color-scheme 테마 조건이 규칙의 대상이 아닙니다

테마 조건은 규칙 5.4에 따라 토큰 파일의 최상위 @media에 둡니다.

클래스 블록 안에 중첩해서 브레이크포인트가 흩어집니다.

.pg_products__toolbar {
	display: flex;
	gap: 24px;

	@media (width < 1024px) {
		flex-direction: column;
	}
}

.pg_products__layout {
	display: grid;
	grid-template-columns: 280px 1fr;

	@media (width < 1024px) {
		grid-template-columns: 1fr;
	}
}

선언은 위에 두고 브레이크포인트는 파일 아래 한곳에 모읍니다.

.pg_products__toolbar {
	display: flex;
	gap: 24px;
}

.pg_products__layout {
	display: grid;
	grid-template-columns: 280px 1fr;
}

@media (width < 1024px) {
	.pg_products__toolbar {
		flex-direction: column;
	}

	.pg_products__layout {
		grid-template-columns: 1fr;
	}
}

같은 @media 블록을 파일마다 복사합니다.

src/page/products/pg-products.csscss
@media (width < 1024px) {
	.pg_products__filterBar {
		flex-direction: column;
	}
}

/* src/page/orders/pg-orders.css */
@media (width < 1024px) {
	.pg_orders__filterBar {
		flex-direction: column;
	}
}

반복되는 배치를 컴포넌트 하나로 만들고 브레이크포인트는 그 파일에만 둡니다.

src/component/ui/filter-bar/ui-filter-bar.csscss
@media (width < 1024px) {
	.ui_filterBar__root {
		flex-direction: column;
	}
}

함께 보기

6.2 브레이크포인트는 데스크톱 퍼스트로 세 값만 씁니다

넓은 화면부터 좁은 화면 순서로 덮어쓰고 프로젝트 전체에서 세 기준 폭을 공유합니다.

기준 폭 세 값

기본 선언은 1440px 이상인 가장 넓은 화면을 기준으로 하고, 좁아질 때만 덮어씁니다. 브레이크포인트는 아래 순서로 쓰며 (width >= ...) 방향과 섞지 않습니다.

조건구간 이름대상
(width < 1440px)~lg좁은 데스크톱
(width < 1024px)~md가로 태블릿, 좁은 노트북
(width < 640px)~sm세로 태블릿 아래

숫자는 이 셋만 쓰며 이름은 경계 아래의 구간을 가리킵니다. 좁은 화면에서 여러 조건이 함께 맞으면 뒤의 좁은 조건이 앞의 넓은 조건을 덮습니다. @media 조건에는 var()를 쓸 수 없으므로 이 숫자를 토큰으로 만들지 않습니다.

범위 표기

조건은 범위 표기로 씁니다. max-width: 1024px과 min-width: 1024px은 경계를 함께 포함하지만 <와 >=는 같은 경계를 소수 보정 없이 나눕니다. 블록 위치는 규칙 6.1을 따릅니다.

기본 선언을 중간 폭에 맞추고 넓고 좁은 방향을 함께 씁니다.

.pg_products__layout {
	display: grid;
	grid-template-columns: 220px 1fr;
}

@media (width >= 1440px) {
	.pg_products__layout {
		grid-template-columns: 280px 1fr;
	}
}

@media (width < 1024px) {
	.pg_products__layout {
		grid-template-columns: 1fr;
	}
}

기본 선언은 가장 넓은 화면에 맞추고 좁아질 때만 덮어씁니다.

.pg_products__layout {
	display: grid;
	grid-template-columns: 280px 1fr;
}

@media (width < 1440px) {
	.pg_products__layout {
		grid-template-columns: 220px 1fr;
	}
}

@media (width < 1024px) {
	.pg_products__layout {
		grid-template-columns: 1fr;
	}
}

max-width를 소수로 보정하고 좁은 쪽부터 씁니다.

@media (max-width: 639.98px) {
	.pg_products__layout {
		padding: 12px;
	}
}

@media (max-width: 1023.98px) {
	.pg_products__layout {
		padding: 20px;
	}
}

범위 표기로 적고 넓은 쪽부터 좁혀 갑니다.

@media (width < 1024px) {
	.pg_products__layout {
		padding: 20px;
	}
}

@media (width < 640px) {
	.pg_products__layout {
		padding: 12px;
	}
}

함께 보기

6.3 레이아웃의 기준과 역할을 클래스 이름과 선언에 드러냅니다

조상 DOM을 찾아보지 않고 sticky, fixed의 기준과 각 요소의 배치 역할을 파악합니다.

레이아웃의 기준과 역할은 클래스 이름과 선언에서 드러나야 합니다. 크기 고정 여부는 규칙 6.4를 따릅니다.

선언이나 요소요구 사항이유
z-index숫자 대신 층 토큰을 씁니다. 목록과 쌓임 맥락은 규칙 5.3를 따릅니다토큰 이름으로 쌓임 순서를 읽습니다
sticky, fixed기준 컨테이너를 주석 한 줄로 남깁니다조상 조건은 해당 선언만으로 알 수 없습니다
로딩 대체 화면실제 내용과 같은 컨테이너 클래스 안에 넣습니다대체 화면에만 높이를 주면 교체 시 그 높이가 사라져 레이아웃이 튈 수 있습니다

fixed는 transform이 적용된 조상 아래에서 뷰포트 기준을 잃습니다. sticky는 중간 조상에 overflow: hidden이나 auto가 있으면 그 조상이 기준이 되어 뷰포트에 붙지 않습니다.

층 숫자를 직접 적고 기준 컨테이너 설명이 없습니다.

.pg_productDetail__toolbar {
	position: sticky;
	top: 0;
	z-index: 9999;
}

기준 컨테이너와 의도를 드러냅니다.

.pg_productDetail__toolbar {
	/* 스크롤 컨테이너는 .pg_productDetail__content */
	position: sticky;
	top: 0;
	z-index: var(--app-z-index-sticky);
}

.pg_productDetail__content {
	display: grid;
	min-height: 0;
	overflow-y: auto;
}

로딩 대체 화면에만 높이를 따로 적습니다.

<Suspense fallback={<UiLineChartSkeleton className={clsx("pg_productDetail__chartSkeleton")} />}>
	<PgProductDetailChartSection />
</Suspense>
.pg_productDetail__chartSkeleton {
	height: 320px;
}

대체 화면을 실제 내용과 같은 컨테이너 클래스 안에 넣습니다.

<div className={clsx("pg_productDetail__chart")}>
	<Suspense fallback={<UiLineChartSkeleton />}>
		<PgProductDetailChartSection />
	</Suspense>
</div>
.pg_productDetail__chart {
	/* 로딩 중과 실제 차트에 같은 최소 높이 확보 */
	min-height: 320px;
}

함께 보기

6.4 브레이크포인트보다 고유 크기 지정을 먼저 검토합니다

컴포넌트가 배치된 폭에 맞춰 크기를 조정해 위치가 바뀌어도 CSS 수정을 줄입니다.

고유 크기 먼저 보기

브레이크포인트를 추가하기 전에 고유 크기 지정만으로 배치할 수 있는지 확인합니다. @media는 뷰포트 폭을 보므로 같은 컴포넌트를 본문에서 좁은 사이드바로 옮겨도 실제 슬롯 폭을 반영하지 못합니다.

flowchart LR
	q1{"고유 크기 지정만으로<br>배치할 수 있는가?"} -- 아니요 --> q2{"실제 슬롯 폭에 따라<br>구조를 바꾸는가?"} -- 아니요 --> r3("@media")
	q1 -- 예 --> r1("flex-wrap, auto-fit, clamp")
	q2 -- 예 --> r2("@container")
필요한 변화사용할 방법
한 줄에 안 들어가 줄을 바꿈flex-wrap: wrap + flex: 1 1 <기준폭>
폭에 따라 열 개수가 달라짐repeat(auto-fit, minmax(min(100%, <기준폭>), 1fr))
슬롯을 채우되 최대 폭에서 멈춤flex: 1 1 <기준폭> + max-width
여백이나 글자 크기가 조금씩 달라짐clamp(<최소>, <선호>, <최대>)
뷰포트 폭에 따라 사이드바를 숨기거나 표를 카드 목록으로 바꿈@media. 위치는 규칙 6.1을 따릅니다
실제 슬롯 폭에 따라 구조를 바꿈@container

minmax(240px, 1fr)의 고정 최솟값도 슬롯보다 크면 넘치므로 min(100%, 240px)로 제한합니다.

컨테이너 쿼리 자리

@container 작성 위치기준
크기를 제공하는 조상container-type: inline-size를 선언합니다
크기 조건으로 바꿀 요소해당 조상의 자손입니다. 자기 크기를 조건으로 자신을 바꾸지 않습니다
조건 숫자와 블록 위치내용이 깨지는 컨테이너 폭으로 정합니다. 뷰포트용 세 값을 강제로 재사용하지 않고 소유자 파일의 기본 선언 뒤에 모읍니다

폭의 소유

버튼과 입력은 padding, min-height, 글자 크기까지만 소유하고 폭은 사용처가 정합니다. 폭을 고정한 이유가 클래스 이름과 선언에서 드러나는지는 규칙 6.3을 따릅니다.

열 개수를 브레이크포인트마다 직접 지정합니다.

.pg_products__grid {
	display: grid;
	grid-template-columns: repeat(4, 1fr);
	gap: 16px;
}

@media (width < 1440px) {
	.pg_products__grid {
		grid-template-columns: repeat(3, 1fr);
	}
}

@media (width < 1024px) {
	.pg_products__grid {
		grid-template-columns: repeat(2, 1fr);
	}
}

@media (width < 640px) {
	.pg_products__grid {
		grid-template-columns: 1fr;
	}
}

사용 가능한 폭에 따라 열 개수를 조정합니다.

.pg_products__grid {
	display: grid;
	grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
	gap: 16px;
}

버튼이 자기 폭을 뷰포트로 정합니다.

.ui_button__root {
	display: inline-flex;
	min-height: 40px;
	padding: 0 var(--app-space-control-inline);
	width: 300px;
}

@media (width < 1024px) {
	.ui_button__root {
		width: 200px;
	}
}

@media (width < 640px) {
	.ui_button__root {
		width: 100%;
	}
}

버튼은 폭을 정하지 않고 모양만 소유합니다.

/* ui-button.css: 폭 지정 없음 */
.ui_button__root {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	min-height: 40px;
	padding: 0 var(--app-space-control-inline);
}

폭은 사용처가 자기 배치 안에서 정합니다.

/* ui-form-footer.css: 한 번 쓰고 여러 화면에서 그대로 사용 */
.ui_formFooter__root {
	display: flex;
	flex-wrap: wrap;
	justify-content: flex-end;
	gap: var(--app-space-inline);
}

.ui_formFooter__action {
	flex: 1 1 200px;
	max-width: 300px;
}

값이 매끄럽게 변하면 clamp를 씁니다.

.pg_products__hero {
	padding-block: clamp(24px, 4vw, 64px);
	font-size: clamp(1.5rem, 1rem + 2vw, 2.5rem);
}

함께 보기