본문으로 건너뛰기

© 2026 Molayo

CSS-T헤드라인2026. 05. 20. 01:36

Grid와 Transform 트릭을 이용한 지그재그 CSS 레이아웃 만들기

요약

CSS Grid와 transform 속성을 활용하여 아이템이 대각선으로 배치되는 지그재그 레이아웃을 구현하는 방법을 설명합니다. Flexbox를 이용한 방식의 한계인 고정 높이 설정 문제와 탭 순서(tab order) 문제를 해결하기 위해, 2열 그리드를 생성한 뒤 짝수 번째 아이템을 translateY로 이동시키는 전략을 제안합니다.

핵심 포인트

  • Flexbox의 wrap 방식은 컨테이너의 고정 높이가 필요하며 탭 순서가 깨질 수 있음
  • CSS Grid를 사용하면 접근성(Tab order)을 유지하면서 레이아웃을 구현할 수 있음
  • nth-child(even of .item) 선택자를 사용하여 짝수 번째 아이템만 정밀하게 제어 가능
  • transform: translateY(50%)를 통해 아이템을 자신의 높이 절반만큼 이동시켜 엇갈린 효과를 생성

대부분의 그리드 레이아웃 (grid layouts)은 대열을 맞춘 군인들처럼 깔끔한 행에 완벽하게 정렬되어 있습니다. 하지만 때로는 더 리드미컬한 것, 즉 폭포에서 떨어지는 물처럼 아이템들이 대각선으로 쏟아지는 레이아웃을 원할 때가 있습니다.

이것이 바로 지그재그 레이아웃 (zigzag layout)입니다. 그리고 이를 구축하려면 CSS 변형 (transforms)이 실제로 어떻게 작동하는지에 대한 흥미로운 사실을 보여주는 작은 트릭이 필요합니다.

전략 (The Strategy)

CSS를 한 줄도 쓰기 전에, 접근 방식에 대해 생각해 봅시다.

가장 먼저 떠오르는 아이디어는 flex-direction: columnflex-wrap: wrap을 설정한 플렉스 컨테이너 (flex container)를 만드는 것입니다. 이렇게 하면 아이템들이 아래로 흐르다가 두 번째 열로 감싸지게 됩니다. 보통 우리는 flex-wrap 속성을 행 (rows)의 관점에서 생각하지만, 플렉스박스 (flexbox)의 장점은 어느 방향으로든 작동한다는 것입니다.

두 가지 문제 때문에 이 접근 방식은 어색해집니다:

고정된 높이가 필요합니다. 감싸기 (wrapping)가 시작되려면 컨테이너에 "높이는 500px이다"라고 알려줘야 합니다. 이는 취약한 방식입니다.
탭 순서 (tab order)가 깨집니다. 아이템들이 첫 번째 열을 따라 아래로 흐른 다음 (즉, 1, 2, 3), 두 번째 열로 점프합니다 (즉, 4, 5, 6). 이것은 폭포가 아니라 두 개의 양동이와 같습니다.

공정하게 말하자면, 우리가 곧 구축할 CSS 그리드 (CSS Grid) 접근 방식도 자체적인 하드코딩된 값을 가집니다. 그 부분은 나중에 다루겠습니다. 하지만 이 방식은 Tab 순서 문제를 완전히 피할 수 있으며, 이는 의미 있는 승리입니다.

그리드 계획 (The Grid Plan)

대신 제가 하고 싶은 것은 다음과 같습니다:

  • 특별한 것 없이 아이템들이 나란히 놓이는 2열 그리드 (two-column grid)를 생성합니다.
  • 두 번째 열에 있는 모든 아이템, 즉 짝수 번째 아이템을 선택합니다.
  • 엇갈린 레이아웃 (staggered layout)을 만들기 위해 그들을 자신의 높이 절반만큼 아래로 이동시킵니다.

그 이동(shift)이 마법이 일어나는 지점입니다. 이제 만들어 봅시다.

그리드 (The Grid)

우리는 래퍼 (wrapper)와 5개의 아이템으로 시작합니다. 아직 파일에는 아무것도 없으며, 단지 빈 도화지 상태입니다.

<div class="wrapper">
<div class="item"></div>
<div class="item"></div>
...
*,
*::before,
*::after {
...

우리는 전역적으로 box-sizing: border-box를 적용하고 있습니다. 왜냐하면 이것이 없으면 아이템들이 실제로 100px가 되지 않기 때문입니다.

tall — 테두리(border)가 추가되면 약간 더 높아집니다. 이는 잠시 후에 중요하게 작용할 것입니다.

변화 (The Shift)

이제 재미있는 부분입니다. 모든 짝수 번째 아이템을 선택하여 아래로 이동(translate)시켜 보겠습니다.

.item:nth-child(even of .item) {
  transform: translateY(50%);
}

선택자(selector)에 대해 짧게 언급하겠습니다. 여기서 .item:nth-of-type(even)을 사용하고 싶을 수도 있습니다. 이 데모에서는 모든 자식 요소가 동일한 엘리먼트 타입이므로 동일한 결과를 생성할 것입니다. 하지만 nth-of-type은 클래스(class)가 아닌 태그 이름(tag name)으로 선택합니다. 따라서 컨테이너(wrapper) 안에 서로 다른 엘리먼트 타입이 섞여 있다면, 예상치 못한 방식으로 매칭될 것입니다. :nth-child(even of .item)은 클래스로 명시적으로 필터링하기 때문에 더 정밀하며, 최신 브라우저에서 잘 지원됩니다.

지그재그(zigzag) 형태가 즉시 나타납니다. 하지만 여기서 잠시 멈춰보겠습니다. 미묘한 현상이 일어나고 있으며, 이를 이해할 가치가 있기 때문입니다.

변형(Transform)의 퍼센트(%)는 다릅니다

변형(transform)에서의 퍼센트(%)는 CSS의 다른 모든 곳에서 작동하는 방식과 완전히 다르게 작동합니다.

플로우 레이아웃(flow layout), 포지션 레이아웃(positioned layout), 또는 사실상 그 어떤 레이아웃 모드에서도 퍼센트는 부모의 가용 공간(available space)을 참조합니다. 만약 컨테이너 내부의 엘리먼트에 width: 50%라고 작성한다면, 이는 다음과 같이 말하는 것과 같습니다: "컨테이너의 너비가 이만큼이니, 나는 그 절반의 크기로 만들어라."

변형(transform)은 이런 식으로 작동하지 않습니다. 변형에서 퍼센트는 엘리먼트 자기 자신을 참조합니다. 따라서 translateY(50%)는 "가용 공간의 절반만큼 아래로 이동하라"는 뜻이 아닙니다. 이는 "자기 자신의 높이의 절반만큼 아래로 이동하라"는 뜻입니다. 만약 엘리먼트의 높이가 200px라면, 100px만큼 아래로 이동합니다.

이는 사실 개별적인 translate(), scale(), rotate() CSS 속성에서 볼 수 있는 것과 동일한 좌표계(coordinate-system) 동작입니다. 이 모든 속성은 레이아웃(layout) 이후, 엘리먼트 자체의 좌표 공간(coordinate space)에서 적용됩니다. 브라우저는 위치, 크기 — 기본적으로 박스 모델(box model) 전체 — 를 포함하여 모든 레이아웃 배치를 먼저 완료한 다음, 엘리먼트 자체를 기준으로 변형을 적용합니다. 이것이 scale(2)가 페이지의 왼쪽 상단(top-left)이 아닌, 엘리먼트의 중심에서부터 바깥쪽으로 커지는 이유입니다.

이것이 바로 이 트릭이 작동하는 정확한 이유입니다. 각 짝수 번째 아이템은 컨테이너의 크기가 아닌, 자기 자신의 크기를 기준으로 아래로 이동합니다. 따라서 아이템의 높이가 얼마가 되든 지그재그(zigzag) 형태는 비례를 유지합니다.

결과물은 비슷해 보이지만, 완전히 정확하지는 않습니다.

간격 문제 (The Gap Problem)

gap 값을 터무니없이 큰 값, 예를 들어 100px로 높여보면 이러한 불완전함을 드러낼 수 있습니다. 그렇게 하면 짝수 번째 아이템들이 있어야 할 위치에 있지 않다는 것이 명확히 보입니다. 행(row) 사이의 수직 공간을 고려하여 조금 더 멀리 이동해야 합니다.

해결 방법은 다음과 같습니다. 먼저, 여러 곳에서 참조할 수 있도록 gap을 CSS 사용자 정의 속성(CSS custom property)에 저장합니다.

.wrapper {
--gap: 16px;
display: grid;
...

우리는 엘리먼트 높이의 50%에 간격(gap)의 절반을 더한 만큼 이동(translate)시킵니다. 간격을 2로 나누는 이유는 행 사이 거리의 절반만 채우면 되기 때문입니다. 전체 값을 사용하면 너무 멀리 밀려나게 됩니다.

간격을 16px로 설정하면 아주 멋지게 보입니다. 100px로 설정해도 여전히 멋지게 보입니다. 값에 상관없이 수학적 계산이 유효합니다.

오버플로의 반전 (The Overflow Surprise)

핵심 퍼즐은 풀었습니다. 하지만 수면 위로 드러나기를 기다리는 숨겨진 문제가 있습니다.

경계를 확인하기 위해 wrapper에 테두리(border)를 추가해 보겠습니다.

.wrapper {
border: 2px solid red;
}

아이템이 5개일 때는 모든 것이 정상적으로 보입니다. wrapper가 모든 자식 엘리먼트를 포함하고 있습니다. 오버플로(overflow)도 없고 문제도 없습니다.

이제 6번째 아이템을 추가해 봅시다.

<div class="wrapper">
<div class="item"></div>
<div class="item"></div>
...

6번째 아이템은 짝수 번째입니다. 아래로 이동(translate)하게 됩니다. 그리고 컨테이너 밖으로 바로 넘쳐흐릅니다.

왜일까요? 변형(transform)은 레이아웃(layout)에 영향을 주지 않기 때문입니다. 브라우저의 레이아웃 엔진 입장에서는, 그 6번째 아이템이 여전히 변형되지 않은 원래 위치에 있는 것으로 간주됩니다. wrapper는 그 원래 위치를 기준으로 크기를 결정합니다. transform은 시각적으로 픽셀을 이동시키지만, 부모 엘리먼트는 무언가가 움직였다는 사실을 전혀 알지 못합니다.

우리가 브라우저를 놀라게 한 것입니다.

해결책: 공간 확보하기 (The Fix: Reserve the Space)

가장 간단한 해결책은 padding-bottom(또는 padding-block-end)을 추가하는 것입니다.

래퍼(wrapper)에 넘치는 부분(overshoot)을 수용할 수 있을 만큼 padding을 추가합니다. 이 패딩은 아이템 높이의 절반에 간격(gap)의 절반을 더한 값과 일치해야 합니다.

패딩 백분율(padding percentages)은 자식의 높이가 아닌 부모의 너비를 참조하기 때문에, 여기서는 동일한 50% 트릭을 사용할 수 없습니다. 대신, 아이템의 높이를 변수로 저장합니다:

.wrapper {
--gap: 16px;
--item-height: 100px;
...

이제 솔직하게 말씀드리겠습니다. --item-height: 100px는 하드코딩된 값입니다. 이는 Flexbox 방식에서 줄바꿈(wrapping)이 작동하기 위해 컨테이너의 고정 높이가 필요했던 것과 마찬가지로 취약한 부분입니다. 두 방식 모두 미리 차원을 알고 있어야 한다는 공통점이 있습니다. 차이점은 여기서는 컨테이너의 높이가 아닌 아이템의 높이를 고정한다는 것이며, 나머지 레이아웃(열 구조, 간격 계산, 소스 순서)은 유연하게 유지된다는 점입니다. 이는 트레이드오프(trade-off)이지 결함은 아니지만, 솔직하게 짚고 넘어갈 가치가 있습니다.

이제 래퍼는 하단에 정확히 필요한 만큼의 공간을 확보합니다. 넘침(overflow)도 없고, 예상치 못한 문제도 발생하지 않습니다.

접근성(Accessibility)에 관한 참고 사항

이 방식은 아이템을 자연스러운 소스 순서(source order)대로 유지하며, 이는 처음 생각하는 것보다 훨씬 중요합니다.

스크린 리더(Screen readers)에 영향을 주지 않습니다. 변형(Transforms)은 순수하게 시각적인 요소입니다. DOM 순서는 1-6으로 유지되며, 보조 기술(assistive technology)은 정확히 그 순서대로 항목을 안내합니다. 시각적 순서와 DOM 순서가 어긋날 수 있는 Flexbox의 column-wrap 방식과 달리, 순서가 뒤바뀌는 의외의 상황이 발생하지 않습니다.

초점 순서(Focus order) 또한 그대로 유지됩니다. 사용자가 탭(tab) 키로 아이템을 이동할 때, 초점은 아이템이 시각적으로 보이는 위치가 아닌 소스 순서를 따릅니다. 우리의 지그재그 레이아웃에서는 시각적 흐름과 소스 순서가 모두 왼쪽에서 오른쪽, 위에서 아래로 흐르므로 자연스럽게 일치합니다. 만약 레이아웃이 복잡해져서 시각적 순서와 소스 순서가 어긋나기 시작한다면, 그때는 초점 관리(focus management)에 대해 더 신중하게 고민해야 합니다.

움직임 선호도(motion preferences)를 존중하세요. 지그재그 자체는 정적입니다. 우리는 transform을 애니메이션화하고 있지 않습니다. 하지만 만약 아이템들을 계단식 위치로 애니메이션화하기로 결정한다면(예: 페이지 로드 시), 해당 애니메이션을 prefers-reduced-motion으로 감싸세요.

확인 사항:

/* 사용자가 움직임 선호도를 설정하지 않았을 때 애니메이션 실행 */
@media (prefers-reduced-motion: no-preference) {
.item {
...

이 경우, 우리는 움직임에 대한 선호도가 없는 사용자들만이 애니메이션을 받도록 설정했습니다. 하지만 일반적으로는 그 반대로 작업할 수도 있습니다. 레이아웃은 어느 쪽이든 여전히 잘 작동합니다.

최종 데모

다시 한번 말씀드리자면:

결론

지그재그 레이아웃은 사실 세 가지 아이디어가 층층이 쌓인 결과물입니다:

  • 2열 그리드(two-column grid)가 기초를 제공합니다.
  • translateY(50%)가 계단식 효과(stagger)를 만듭니다. 이는 transform 퍼센트가 부모가 아닌 요소 자체를 참조하기 때문에 작동합니다.
  • padding-bottom은 번역된(translated) 아이템들을 위한 공간을 확보합니다. transform은 레이아웃 엔진에 알리지 않고 픽셀을 이동시키기 때문입니다.

간격을 변경해 보세요. 아이템 높이를 변경해 보세요. 아이템을 더 추가해 보세요. 지그재그 레이아웃은 그대로 유지됩니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 CSS-Tricks의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0