display: contents로 래퍼 없이 Grid/Flex 정렬하기
레이아웃만 “납작하게” 만들고 싶다면 display: contents로 래퍼의 박스를 제거해 Grid/Flex의 직접 자식처럼 배치할 수 있다.
있었는데, 없어졌습니다: display: contents로 래퍼 없이 Grid/Flex 정렬하기
한 문장 결론: 레이아웃만 “납작하게” 만들고 싶다면 display: contents로 래퍼의 박스를 제거해 Grid/Flex의 직접 자식처럼 배치할 수 있다.
배경과 문제
컴포넌트를 쪼개다 보면 “의미(semantics)를 위한 래퍼”가 자연스럽게 생깁니다. 예를 들어 목록을 표현하려고 <ul>을 두었는데, 바깥 컨테이너가 display: grid인 순간 레이아웃이 꼬이곤 하죠.
포인트는 하나입니다.
- Grid/Flex는 “직접 자식”만 아이템으로 취급한다.
- 의미를 위해 둔 래퍼가 의도치 않게 하나의 Grid/Flex 아이템이 되면, 내부 요소(예:
<li>)는 기대한 칸에 배치되지 않는다.
핵심 개념
display: contents는 “요소 자체의 박스(box)는 만들지 않고, 자식 요소의 박스는 그대로 생성”하는 값입니다. 시각적 레이아웃 관점에서는 부모-자식 관계가 한 단계 납작해진 것처럼 동작합니다. (drafts.csswg.org)
주의: 문서 트리(DOM)가 바뀌는 게 아니라 박스 트리(레이아웃 트리)만 달라집니다. 그래서 선택자 매칭, 이벤트 버블링, 상속 같은 “DOM 기반 의미”는 유지되는 것이 의도입니다. (drafts.csswg.org)
아래 다이어그램을 보면, “DOM은 그대로인데 레이아웃에서만 래퍼가 사라지는” 느낌이 바로 들어옵니다.
→ 기대 결과/무엇이 달라졌는지: DOM 구조는 유지하면서, 레이아웃 계산에서만 UL의 박스가 제거되어 LI가 Grid/Flex 아이템으로 직접 배치됩니다.
해결 접근
같은 문제를 푸는 선택지는 크게 3가지입니다.
- 래퍼를 아예 없애기(가능하면 최선)
React라면
<Fragment>로 불필요한 래퍼 엘리먼트를 만들지 않을 수 있습니다. (react.dev) - 래퍼는 두되, Grid/Flex 배치 규칙으로 제어하기
예: 래퍼에게
grid-column을 주거나, 레이아웃 구조를 재배치해 “직접 자식” 제약을 피합니다. - 래퍼의 박스만 없애기:
display: contents의미(예: 리스트/그룹핑)를 위해 래퍼가 필요한데, 레이아웃에서는 “없는 것처럼” 취급하고 싶을 때 선택합니다. (drafts.csswg.org)
이 글은 3)번을 중심으로 정리합니다.
구현
1) 문제가 드러나는 Grid 예시
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));">
<div style="background: red;">test1</div>
<div style="background: green;">test2</div>
<ul>
<li style="background: blue;">1</li>
<li style="background: purple;">2</li>
</ul>
</div>→ 기대 결과/무엇이 달라졌는지: ul이 Grid의 “세 번째 아이템”이 되어 별도 칸을 차지하고, li는 Grid 아이템으로 배치되지 않아 의도한 2열 흐름이 깨질 수 있습니다.
2) display: contents로 UL 박스 제거
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));">
<div style="background: red;">test1</div>
<div style="background: green;">test2</div>
<ul style="color: #fff; display: contents;">
<li style="background: blue;">1</li>
<li style="background: purple;">2</li>
</ul>
</div>→ 기대 결과/무엇이 달라졌는지: ul의 박스가 사라져 li가 Grid의 직접 자식처럼 배치됩니다. 동시에 color처럼 상속되는 스타일은 li에 전달되는 모습을 확인할 수 있습니다. (drafts.csswg.org)
3) Next.js에서 재현하기
- 컴포넌트 스타일은 CSS Modules로 관리하면 “클래스 충돌”을 피하면서 재현이 쉽습니다. (nextjs.org)
// app/page.tsx
import styles from './page.module.css';
export default function Page() {
return (
<main className={styles.grid}>
<section className={styles.red}>test1</section>
<section className={styles.green}>test2</section>
<ul className={styles.contentsList}>
<li className={styles.blue}>1</li>
<li className={styles.purple}>2</li>
</ul>
</main>
);
}→ 기대 결과/무엇이 달라졌는지: 마크업은 리스트 구조를 유지하면서, 레이아웃은 li가 직접 Grid 아이템이 됩니다.
/* app/page.module.css */
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.contentsList {
display: contents;
color: #fff;
}
.red { background: red; }
.green { background: green; }
.blue { background: blue; }
.purple { background: purple; }→ 기대 결과/무엇이 달라졌는지: contentsList는 “박스가 없기 때문에” 배경/패딩/보더 같은 박스 기반 스타일을 기대하기 어렵고, 대신 자식(li)에 스타일을 주는 쪽이 자연스럽습니다. (developer.mozilla.org)
검증 방법(체크리스트)
li가 Grid 아이템으로 잡히는가ul에 배경/패딩을 줬을 때 기대대로 보이지 않는다는 점을 이해하고, 필요한 스타일은 li 쪽으로 이동했는가 (developer.mozilla.org)img, input, iframe 같은 “replaced element/폼 컨트롤”에는 적용하지 않았는가흔한 실수/FAQ
Q1. display: contents면 “태그 자체가 DOM에서 사라지나요?”
아니요. DOM은 유지되고, 레이아웃 계산에 쓰이는 박스만 생성되지 않습니다. (drafts.csswg.org)
Q2. 그럼 이벤트는 어디에 붙나요?
이벤트 버블링은 DOM을 기준으로 동작하므로, display: contents로 인해 “이벤트 흐름 자체가 바뀌는 것”은 의도된 동작이 아닙니다. (drafts.csswg.org)
Q3. 아무 데나 써도 되나요?
레이아웃 문제를 깔끔하게 풀어주지만, 접근성 트리에서의 처리나 “특수 렌더링 요소”는 브라우저 구현 차이가 있을 수 있습니다. 특히 폼 컨트롤/대체 요소(replaced element)에는 적용을 피하는 편이 안전합니다. (drafts.csswg.org)
요약(3~5줄)
display: contents는 요소의 박스를 제거해 자식을 부모의 직접 자식처럼 레이아웃에 참여시키는 도구입니다. (drafts.csswg.org)
Grid/Flex에서 “의미를 위한 래퍼” 때문에 생기는 배치 문제를 빠르게 해결합니다. (caniuse.com)
다만 접근성 트리에서 요소 의미가 달라질 수 있고, 폼 컨트롤/대체 요소에는 적용이 제한될 수 있어 검증이 필요합니다. (developer.mozilla.org)
결론
래퍼가 레이아웃을 망가뜨릴 때, 선택지는 “구조를 바꾸기 / 배치 규칙으로 제어하기 / 박스만 없애기”입니다.
그중 display: contents는 구조는 유지하면서 레이아웃만 납작하게 만드는 카드입니다. 단, 접근성과 특수 요소 적용 범위는 반드시 테스트하고 적용하세요.