본문으로 건너뛰기

© 2026 Molayo

Smashing헤드라인2026. 05. 21. 21:59

고급 트리 카운팅: `sibling-index()`와 `sibling-count()`를 활용한 수학적 레이아웃

요약

CSS의 새로운 기능인 sibling-index()와 sibling-count()를 활용하여 JavaScript나 복잡한 Sass 루프 없이 계단식 애니메이션을 구현하는 방법을 소개합니다. 브라우저가 이미 알고 있는 DOM 정보를 CSS에서 직접 활용함으로써 코드의 효율성과 유지보수성을 높일 수 있습니다.

핵심 포인트

  • sibling-index()를 통해 요소의 순번을 CSS에서 직접 참조 가능
  • JavaScript 없이 단 한 줄의 CSS로 계단식 애니메이션 구현
  • Sass 루프나 nth-child를 사용한 하드코딩 문제 해결
  • DOM 구조 정보를 활용하여 런타임 오버헤드 감소

sibling-index()

그리고 sibling-count()

. :nth-child() 규칙이나 JavaScript 편법 없이 단 한 줄의 CSS만으로 구현하는 계단식 캐스케이드 효과 (Staggered cascade effect). 5개의 아이템이든 5,000개의 아이템이든 상관없이 작동합니다. 카드 그리드가 있고, 카드들이 하나씩 차례대로 나타나길(fade in) 원했던 적이 있으신가요? 그 계단식 캐스케이드 효과 말입니다. 보기에는 아주 멋지고 간단해 보입니다. 하지만 제가 이를 구현할 때마다, 마치 근본적으로 어리석은 일을 하고 있다는 기분이 들곤 했습니다.

Durgesh가 만든 Pen Dynamic Staggered Animations with CSS sibling-index() [forked]을 확인해 보세요.

그 이유는 선택지가 항상 똑같았기 때문입니다. 예를 들어 10개의 아이템이 있는 리스트에 계단식 애니메이션 지연(staggered animation delays)을 주고 싶다고 가정해 봅시다. 당신은 특정 위치를 위해 --index 변수를 하드코딩하는 수십 개의 :nth-child() 규칙을 출력하는 Sass 루프를 작성해야 했습니다:

/* 아이템당 하나의 규칙. 리스트가 절대 늘어나지 않기를 바라며. */
li:nth-child(1) { --idx: 1; }
li:nth-child(2) { --idx: 2; }
...

10개의 아이템이면 10개의 규칙이 필요합니다. 만약 리스트가 50개로 늘어난다면요? 그냥 최대치를 제한하고 운에 맡기거나, 빌드 타임에 수백 개의 선택기를 생성하는 Sass 루프를 설정해야 합니다. Roman Komarov와 같은 엔지니어들은 $O(\sqrt{N})$ 전략이라는 정말 영리한 방법을 고안해냈지만, 여전히 1,023개의 요소를 커버하기 위해 63개의 규칙이 필요합니다.

또는 JavaScript로 요소를 루프 돌며 인라인 스타일(style="--index: 3")을 설정할 수도 있습니다. 바로 DOM에 말이죠. 잘 작동합니다. 하지만 레이아웃 관련 관심사를 스크립트 전반에 분산시키며, 6개월 뒤 누군가 CSS가 JavaScript로 주입된 변수에 의존하고 있다는 사실을 모른 채 컴포넌트를 리팩터링할 때 조용히 문제를 일으킵니다.

두 방식 모두 동일한 이유로 저를 괴롭혔습니다. 브라우저가 이미 알고 있는 정보를 당신이 다시 말해주고 있다는 점입니다. 브라우저는 DOM 트리를 구축했습니다. 어떤 요소가 세 번째 자식인지 이미 알고 있습니다. 데이터를 가지고 있습니다. 단지 CSS가 그 데이터에 접근할 수 없었을 뿐입니다.

이제는 가능합니다:

li {
animation-delay: calc(sibling-index() * 100ms);
}

단 한 줄이면 됩니다. 5개의 아이템이든 5,000개의 아이템이든 작동합니다. 이벤트 리스너 (event listeners)도, 뮤테이션 옵저버 (mutation observers)도, 리렌더링 (re-renders)도 필요 없습니다.

sibling-index()

sibling-count()

는 CSS Values and Units Module Level 5 명세 (Section 9, 만약 당신이 재미로 W3C 초안을 읽는 유형이라면)의 일부입니다. 이 제안은 상당한 논의 끝에 CSSWG 이슈 #4559를 통해 승인되었습니다. 함수 자체에는 인수가 필요하지 않습니다 — 그냥 사용하면 됩니다.

sibling-index()는 부모의 자식들 사이에서 해당 요소의 1부터 시작하는 위치를 알려줍니다. 첫 번째 자식은 1을 반환하고, 다섯 번째 자식은 5를 반환합니다. 오직 요소 노드 (element nodes)만 계산하며 — 텍스트 노드 (text nodes), 주석 (comments), 공백 (whitespace)은 모두 무시됩니다.

sibling-count()는 부모가 가진 전체 요소 자식의 수를 알려줍니다. 기본적으로 JavaScript의 element.parentElement.children.length와 동일한 CSS 버전이지만, 스타일시트에서 바로 사용할 수 있습니다.

두 함수 모두 <string>이 아닌 실제 숫자인 <integer>로 해결됩니다. 즉, calc(), min(), max(), round(), mod(), 그리고 sin()이나 cos()와 같은 삼각함수 (trigonometric stuff) 안에 이들을 넣을 수 있다는 뜻입니다. calc(sibling-index() * 100ms)라고 작성하면, CSS가 타입 변환 (type coercion)을 처리하여 유효한 <time> 값을 출력합니다. 별도의 트릭이 필요 없습니다. 문자열을 반환하며 의사 요소 (pseudo-elements)의 content 내부에서만 사용할 수 있는 counter()와 비교해 보세요 — 이는 완전히 다른 것입니다.

사람들이 혼동하는 한 가지 명확한 점은 :nth-child()는 *선택자 (selector)*라는 것입니다. 이는 요소를 선택할 뿐, 값을 생성하지 않습니다. calc(:nth-child() * 10px)와 같이 작성할 수는 없으며, 이는 유효한 CSS가 아닙니다. sibling-index()는 그 반대입니다. 선언문 (declarations) 내부에 위치하며 계산 가능한 숫자를 제공합니다. 이 둘은 서로 다른 문제를 해결하며, 지금까지 우리는 :nth-child()를 원래 설계된 역할이 아닌 용도로 억지로 끼워 맞춰 사용해 왔습니다.

훔칠 만한 가치가 있는 패턴들

이것들이 단순한 정수(integers)라는 점을 이해하고 나면, 아이디어가 빠르게 떠오릅니다.

역순 스테거 (Reverse Stagger)

마지막 아이템이 먼저 애니메이션되기를 원하시나요? 뺄셈을 사용하세요:

.card {
  animation: fade-in 0.4s ease both;
  animation-delay: calc((sibling-count() - sibling-index()) * 80ms);
  ...
}

마지막 자식 요소는 (N - N) * 80ms = 0ms가 됩니다.

— 즉, 즉시 실행됩니다. 첫 번째 자식 요소는 (N - 1) * 80ms가 됩니다.

어색하게 멈춰 있는 대신, 페이지가 로드되는 순간 애니메이션이 시작됩니다.

자동 균등 너비 (Automatic Equal Widths)

퍼센트(%)를 설정하기 위해 자식 요소의 개수를 수동으로 세는 일을 멈추세요:

.tab {
  width: calc(100% / sibling-count());
}

탭이 5개라면? 각각 20%입니다. 6개를 추가하면? 16.66%가 됩니다. 2개를 제거하면? 25%가 됩니다. 미디어 쿼리(Media queries), 리사이즈 옵저버(Resize observers), JavaScript가 전혀 필요 없습니다.

물론, 아이템이 너무 많아져서 탭이 지나치게 좁아지는 시나리오를 상상할 수 있으며, 그 시점에는 다른 방식, 예를 들어 Flexbox의 줄 바꿈(wrapping) 솔루션을 고려해야 할 수도 있습니다.

색조 분포 (Hue Distribution)

색상 휠(Color wheel)을 따라 색상을 균등하게 배치하세요:

.swatch {
  background-color: hsl(
    calc((360deg / sibling-count()) * sibling-index()) 70% 50%
    ...
  )
}

아이템이 3개라면 색조(Hues)는 120° 간격으로 배치됩니다. 12개라면 30°씩 증가합니다. 팔레트가 DOM에 있는 요소에 맞춰 적응하며, 이는 보통 JavaScript 색상 라이브러리를 사용하여 수행하던 작업입니다.

원형 메뉴 (Circular Menus)

아이템을 원형으로 배치하려면 예전에는 JavaScript에서 사인(Sine)과 코사인(Cosine)을 계산해야 했습니다. 이제 CSS에는 sin()cos()가 네이티브로 지원되며 (Juan Diego Rodríguez가 CSS-Tricks에서 이에 대한 훌륭한 실무 가이드를 작성했습니다), 트리 카운팅(Tree-counting)과 결합하면 이 모든 과정이 순수 CSS로 압축됩니다:

.radial-item {
  --angle: calc((360deg / sibling-count()) * sibling-index());
  --radius: 120px;
  ...
}

아이템이 6개라면? 육각형입니다. 8개라면? 팔각형입니다. 아이템을 추가하거나 제거하면 레이아웃이 다시 계산됩니다. JavaScript가 좌표를 계산할 필요가 없습니다.

Z-Index 스태킹 (Z-Index Stacking)

카드 부채꼴(Card fan)을 만드시나요? 한 줄이면 충분합니다:

.card {
  z-index: calc(sibling-count() - sibling-index());
}

첫 번째 카드가 가장 높게 쌓이고, 마지막 카드는 0이 됩니다. 반대로 만들고 싶다면 수학 식을 뒤집으세요.

주의사항 (The Gotchas)

이 내용들은 명세(Spec)에서 명확히 드러나지 않기 때문에 개별적으로 살펴볼 가치가 있습니다.

Shadow DOM 스코핑 (Shadow DOM Scoping)

sibling-index()sibling-count()는 평탄화된 시각적 트리 (flattened visual tree)가 아니라 DOM 트리 (DOM tree)를 기준으로 작동합니다. 이 차이점은 Web Components를 사용할 때 반드시 문제를 일으킬 것입니다.

다음과 같은 Shadow DOM을 가진 커스텀 엘리먼트 (custom element)가 있다고 가정해 봅시다:

<section>
<slot></slot>
<div class="internal"></div>
...

만약 .internalsibling-index()를 사용하여 스타일을 지정한다면, 결과는 항상 2를 반환합니다. <slot>이 300개의 엘리먼트를 투영 (projects)하더라도 마찬가지입니다. 이 함수는 Shadow 트리 내의 <section> 자식 노드인 <slot>.internal div, 이렇게 두 개만을 인식합니다. 투영된 Light DOM 콘텐츠는 카운트(count) 관점에서는 존재하지 않는 것으로 간주됩니다.

여기에는 보안 관련 사항도 포함되어 있습니다. 만약 Light DOM 스타일시트가 ::part()를 통해 컴포넌트 내부로 접근하여 sibling-index()를 사용하려고 하면, 브라우저는 0을 반환합니다. 완전히 0입니다. 이는 외부 CSS가 제3자 컴포넌트의 내부 구조를 탐색하는 것을 방지하기 위한 의도적인 장벽입니다. 솔직히 말해서, 이것이 올바른 결정이라고 생각합니다.

의사 엘리먼트 (Pseudo-Elements)는 카운트되지 않음

::before::after는 형제 노드 (siblings)가 아닙니다. 이들은 sibling-count()에 나타나지 않으며, 자체적인 sibling-index()도 가질 수 없습니다. 하지만 — 이 부분이 여러분의 디버깅 시간을 줄여줄 핵심입니다 — 이 함수들을 의사 엘리먼트 선언문 내부에서 사용할 수 있습니다. #target::before { width: calc(sibling-index() * 10px); }라고 작성하면, sibling-index()는 의사 엘리먼트가 아니라 #target을 기준으로 계산됩니다. 의사 엘리먼트는 실제 노드가 아니기 때문에, 함수는 해당 엘리먼트의 기원이 되는 요소로 거슬러 올라가 추적합니다. ::slotted(*)::before의 경우도 마찬가지로, Light DOM 내에서 슬롯된(slotted) 엘리먼트의 인덱스를 확인합니다.

display: none은 여전히 카운트됨

이 부분에서 제가 실수를 했습니다. display: none이 적용된 엘리먼트는 레이아웃 트리 (layout tree)에서 사라집니다. 공간을 차지하지도 않고, 스크린 리더(screen reader)에도 잡히지 않습니다. 하지만 여전히 DOM에는 존재합니다.

sibling-index()는 레이아웃 트리가 아닌 DOM 트리를 읽기 때문에, 숨겨진 엘리먼트도 카운트에 포함됩니다:

<ul>
<!-- sibling-index() = 1 -->
<li>Apple</li>
...

Cherry의 인덱스는 2가 아니라 3이 됩니다.

. 숨겨진 바나나는 여전히 제자리를 지킵니다.

이것은 대부분의 레이아웃에서는 중요하지 않습니다. 하지만 display: none을 사용하여 일치하지 않는 항목들을 숨기는 검색 필터와 같은 것을 구축하는 경우,

당신의 계단식 애니메이션(staggered animations)과 원형 레이아웃에는 간격이 생길 것입니다. 보이는 항목들은 원래의, 비순차적인 인덱스를 유지합니다. 연속적인 카운팅에 의존하는 모든 것—방사형 메뉴(radial menus), 비례 너비(proportional widths)—은 단순히 숨기는 대신 필터링된 노드를 DOM에서 실제로 제거해야 합니다. 또는 JavaScript로 관리되는 인덱스로 대체해야 합니다.

참고: visibility: hiddenopacity: 0도 카운트되지만, 이 요소들은 여전히 공간을 차지하기 때문에 더 직관적으로 느껴집니다. display: none은 눈에 보이지 않게 사라지면서도 DOM 슬롯을 차지하는 교활한(sneaky) 방식입니다.

커스텀 속성 즉시 평가 (Custom Properties Evaluate Immediately)

이것은 미묘합니다. 만약 부모 요소에서 인덱스를 중앙 집중화하려고 시도한다면:

.parent {
--idx: sibling-index();
}

…이 --idx.parent 자체에서 바로 해석됩니다.

이는 부모의 자신의 형제 인덱스(sibling index)를 가져와 그 숫자에 고정시키고, 모든 자식 요소가 이 단일하고 고정된 값을 상속받게 합니다. 모든 자식이 같은 숫자를 갖습니다. 거의 확실하게 원하거나 예상하는 바는 아닐 것입니다.

해결책은 간단합니다—기능을 필요한 요소에 적용하세요:

.child {
--idx: sibling-index();
animation-delay: calc(var(--idx) * 100ms);
/* ... */
}

CSSWG는 @propertyinherits: declaration 추가를 논의해 왔는데, 이것이 이론적으로 이를 해결할 수 있습니다. 만약 @property를 사용한 적이 없다면, 커스텀 속성의 타입(type), 초기값(initial value), 그리고 상속 동작(inheritance behavior)을 정의할 수 있게 해줍니다—이는 단순한 --변수보다 훨씬 많은 제어력을 제공합니다. 하지만 inherits: declaration 아이디어는 여전히 초기 CSSWG 논의 단계에 있으며, 어떤 사양 초안에도 포함되어 있지 않습니다. 이것이 구현되기까지는 몇 년이 걸릴 수도 있고, 아예 구현되지 않을 수도 있습니다. 오늘날 @property를 사용하더라도 *“아직 평가하지 말고, 자식을 기다려라”*라고 말할 메커니즘은 없습니다. 그러므로 지금은 직접 적용하기만 하세요.

대규모에서의 성능 (Performance at Scale)

DOM을 변경하는 것 — 즉, 자식 요소를 추가, 제거, 재정렬하는 것 — 은 영향을 받는 형제 요소(siblings)들에 대한 스타일 재계산(style recalculation)을 트리거합니다. 브라우저는 이를 캐스케이드(cascade) 단계(레이아웃 및 페인트 전) 동안 처리하므로, JavaScript로 루프를 돌며 인라인 스타일을 찍어내던 과거의 방식보다 빠릅니다.

하지만 이를 과도하게 밀어붙이면 실제 비용이 발생합니다. 10,000개의 자식을 가진 컨테이너의 맨 앞에 요소를 삽입하면, 엔진은 그 뒤에 오는 10,000개 요소 모두에 대해 형제 인덱스(sibling index)를 재계산해야 합니다. 일반적인 요소 — 내비게이션, 카드 그리드, 탭 바 — 의 경우에는 전혀 느낄 수 없을 것입니다. 하지만 수천 개의 노드가 끊임없이 요동치는 실시간 주식 티커(stock ticker)나 무한 스크롤 피드(infinite-scroll feed)의 경우에는, 가상화 윈도우(virtualization window) 내부에서 JavaScript로 관리되는 인덱스를 계속 사용하세요. 이 함수들은 빠르지만, 비용이 전혀 들지 않는(zero-cost) 것은 아닙니다.

브라우저 지원 (Browser Support)

이 글을 쓰는 시점을 기준으로, Chrome/Edge 138은 안정 버전(2025년 6월)에 이 함수들을 포함하여 출시했으며, Safari 26.2가 그 뒤를 이었습니다. Firefox는 아직 안정 버전에 출시하지 않았지만, Mozilla의 스펙 입장은 긍정적이며 구현 작업이 Bugzilla 이슈 #1953973 아래에서 활발히 진행 중입니다. 배포하기 전에 최신 정보를 위해 caniuse를 확인하세요.

Chrome과 Safari를 합치면 전 세계 트래픽의 약 75~80%를 커버합니다. 이는 강력한 다수이지만, Firefox의 부재는 여전히 폴백(fallback)이 필요함을 의미합니다.

오늘날 배포를 위해서는 @supports가 여러분의 친구입니다:

/* 어디서나 작동하는 베이스라인(Baseline) */
.item {
width: 25%;
...

Firefox를 위한 정적 폴백(Static fallback). 그 외 모든 사용자를 위한 수학적 레이아웃(Mathematical layout). 누구도 깨진 페이지를 보게 되지 않습니다.

폴리필(Polyfills)에 대하여:

형제 요소들을 루프 돌며 인라인 스타일을 설정하는 JavaScript 폴리필은 바로 이 함수들이 대체하기 위해 존재하는 바로 그 방식입니다. 하지만 그렇다고 해서 하드코딩된 폴백 값에 갇혀 있어야 한다는 뜻은 아닙니다. Juan Diego Rodríguez는 “sibling-count()sibling-index()를 기다리는 방법”에 대해 견고한 글을 작성했습니다.

그의 글은 네이티브 지원이 Baseline에 도달할 때까지 점진적 향상 (progressive enhancement)을 위한 올바른 모델을 제시합니다. 그의 접근 방식은 완전한 JavaScript 폴리필 (polyfill) 대신, 기존의 CSS 기술(Roman Komarov의 카운팅 해킹과 같은 방식)을 가교로 사용합니다. Firefox가 기능을 지원할 때까지 오늘 당장 프로덕션 환경에 적용 가능한 결과물을 출시해야 한다면 읽어볼 가치가 있습니다.

접근성 참고 사항 (Accessibility Notes)

흥분한 나머지 간과하기 쉽기에 반드시 짚고 넘어가야 할 점이 있습니다: 이 함수들은 순수하게 시각적입니다. 이 함수들은 사물이 어떻게 보이는지를 바꿀 뿐, 사물이 무엇을 의미하는지를 바꾸지는 않습니다.

만약 sibling-index()를 사용하여

order를 통해 목록을 시각적으로 재정렬하거나

그리드 배치 (grid placement)를 위해 수학적 계산을 사용한다면, 스크린 리더 (screen reader)는 여전히 DOM의 소스 순서대로 읽습니다. 키보드 탭 순서 (tab order) 또한 DOM을 따릅니다. 시각적 레이아웃과 의미론적 구조 (semantic structure)가 서로 모순되게 되며, 이는 접근성 실패 (accessibility failure)에 해당합니다.

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0