들어가며: '이걸 왜 지금까지 못 했을까?' 싶은 순간

카드 그리드에 하나둘씩 페이드 인하는 효과, 보기엔 멋지지만 만들 때마다 찝찝함이 남았습니다. 10개 아이템에 계단식 지연 애니메이션(staggered animation)을 넣으려면 보통 두 가지 선택지뿐이었죠.

선택지 1: nth-child 하드코딩

/* 아이템 하나당 하나의 규칙. 리스트가 늘어나길 바라며 기도 */
li:nth-child(1) { --idx: 1; }
li:nth-child(2) { --idx: 2; }
li:nth-child(3) { --idx: 3; }
/* ... 7개 더 ... */
li {
  animation-delay: calc(var(--idx) * 100ms);
}

10개면 10줄, 50개면? Sass 루프로 빌드 타임에 50개 룰을 찍어내거나, 그냥 10개로 타협하고 '이쯤에서 끊자' 했죠.

선택지 2: 자바스크립트로 인라인 스타일 주입

items.forEach((el, i) => el.style.setProperty('--index', i));

잘 동작하지만, 6개월 후 누군가 컴포넌트를 리팩터링하면서 CSS가 JS 변수에 의존하고 있다는 사실을 잊어버리면 조용히 깨집니다.

두 접근 모두 같은 근본 문제를 공유합니다: 브라우저는 이미 DOM 트리를 알고 있는데, 우리는 CSS가 그 정보에 접근하지 못해서 우회하고 있었던 것입니다.

이제 그 벽이 사라졌습니다.

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

한 줄. 5개 아이템이든 5000개든 동작합니다. 이벤트 리스너도, MutationObserver도, 리렌더링도 필요 없습니다.

CSS code snippet showing sibling-index and sibling-count functions in a code editor Developer Related Image

sibling-index()sibling-count() 완전 정복

기본 개념

이 함수들은 CSS Values and Units Module Level 5 스펙(Section 9)의 일부입니다. CSSWG 이슈 #4559에서 오랜 논의를 거쳐 승인되었죠.

  • sibling-index(): 부모의 자식 중 현재 요소의 1-based 위치를 반환합니다. 첫 번째 자식은 1, 다섯 번째는 5. 오직 요소 노드만 카운트하며, 텍스트 노드나 주석은 무시합니다.
  • sibling-count(): 부모가 가진 총 자식 요소 수를 반환합니다. 자바스크립트의 element.parentElement.children.length와 동일한 값입니다.

두 함수 모두 <number> 타입으로 리졸브되므로 calc(), min(), max(), round(), sin(), cos() 등 어디에든 바로 사용할 수 있습니다.

⚠️ 혼동 주의: :nth-child()는 **선택자(selector)**입니다. 요소를 선택할 뿐 값을 반환하지 않습니다. calc(:nth-child() * 10px)는 유효하지 않은 CSS입니다. 반면 sibling-index()값 생성 함수로, 선언부 안에서 숫자를 제공합니다.

실전 패턴 6선

1. 계단식 애니메이션 (Staggered Fade-in)

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

2. 역방향 계단식 (Reverse Stagger) - 마지막 아이템부터

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

마지막 카드가 가장 먼저 나타나고, 첫 번째 카드가 가장 늦게 나타납니다.

3. 자동 균등 너비 (Equal Widths)

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

탭이 5개면 20%, 6개면 16.66%, 4개면 25%. 미디어 쿼리나 JS 없이 자동 조정됩니다.

4. 색상 고르게 분포 (Hue Distribution)

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

3개 아이템이면 120° 간격, 12개면 30° 간격. DOM에 따라 팔레트가 자동으로 적응합니다.

5. 원형 메뉴 (Circular Menu) - 순수 CSS로 삼각함수까지

.radial-item {
  --angle: calc((360deg / sibling-count()) * sibling-index());
  --radius: 120px;
  position: absolute;
  left: calc(50% + var(--radius) * cos(var(--angle)));
  top: calc(50% + var(--radius) * sin(var(--angle)));
  transform: rotate(calc(var(--angle) * -1));
}

6개 아이템이면 육각형, 8개면 팔각형. 추가/제거 시 레이아웃이 자동 재계산됩니다.

6. Z-Index 스택 (Card Fan)

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

첫 번째 카드가 가장 위, 마지막 카드가 0. 순서를 뒤집고 싶다면 sibling-index()만 사용하세요.

Developer testing staggered animation delay using pure CSS on a grid of cards Dev Environment Setup

주의사항 & 함정 (이걸 모르면 디버깅 지옥)

Shadow DOM 스코핑

sibling-index()sibling-count()DOM 트리를 기준으로 동작하며, 플랫 비주얼 트리는 보지 않습니다. 웹 컴포넌트에서 이 차이는 결정적입니다.

<!-- 커스텀 요소의 Shadow DOM -->
<host-element>
  #shadow-root
    <style></style>
    <div class="internal"></div>

.internalsibling-index()를 적용하면 항상 2를 반환합니다. <style>.internal 두 개의 자식만 있기 때문이죠. 프로젝트에 300개의 <project-item>이 light DOM에 투영되어 있어도, shadow 트리 안에서는 sibling-count()2입니다.

또한, ::part()를 통해 외부 스타일시트가 컴포넌트 내부를 탐색하려 하면 sibling-index()0을 반환합니다. 이는 의도적인 보안 장벽입니다.

display: none도 카운트된다

이게 가장 교묘한 함정입니다. display: none은 레이아웃 트리에서 사라지지만, DOM 트리에는 남아 있습니다. sibling-index()는 DOM 트리를 읽기 때문에 숨겨진 요소도 인덱스에 포함됩니다.

<ul>
  <li>Apple</li>
  <li style="display: none">Banana</li>  <!-- 보이지 않지만 index는 2 -->
  <li>Cherry</li>  <!-- index는 3, 2가 아님 -->
</ul>

검색 필터로 display: none을 사용한다면, 계단식 애니메이션이나 원형 메뉴에 **빈자리(gap)**가 생깁니다. 해결하려면 필터링된 노드를 실제로 DOM에서 제거하거나, 자바스크립트로 인덱스를 관리해야 합니다.

커스텀 프로퍼티 즉시 평가 문제

/* 잘못된 예 */
.parent {
  --idx: sibling-index();  /* .parent의 자신의 형제 인덱스로 고정됨 */
}
.child {
  animation-delay: calc(var(--idx) * 100ms);  /* 모든 자식이 같은 값 상속 */
}

해결법은 간단합니다. 함수를 적용 대상 요소에 직접 쓰세요.

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

성능: 대규모 DOM에서의 비용

10,000개 자식의 첫 번째 위치에 요소를 삽입하면, 브라우저는 나머지 9,999개 요소의 형제 인덱스를 모두 재계산해야 합니다. 일반적인 네비게이션, 카드 그리드, 탭 바에서는 전혀 문제되지 않지만, 실시간 주식 티커나 무한 스크롤 피드처럼 수천 개 노드가 계속 변하는 환경에서는 자바스크립트 관리 인덱스를 유지하는 편이 안전합니다.

브라우저 지원 현황

  • Chrome/Edge 138 (2025년 6월): 안정화 버전 지원
  • Safari 26.2: 지원
  • Firefox: 아직 미지원 (Bugzilla #1953973에서 구현 진행 중, 긍정적)

Chrome + Safari 조합이 글로벌 트래픽의 약 75~80%를 커버합니다. 파이어폭스 사용자를 위한 폴백은 @supports로 처리하세요.

/* 모든 브라우저에서 동작하는 기본값 */
.item {
  width: 25%;
  animation-delay: 0ms;
}

/* 지원하는 브라우저에서만 수학적 레이아웃 활성화 */
@supports (z-index: sibling-index()) {
  .item {
    width: calc(100% / sibling-count());
    animation-delay: calc(sibling-index() * 80ms);
  }
}

접근성 주의사항

이 함수들은 순수 시각적입니다. sibling-index()order나 그리드 배치를 바꿔도, 스크린 리더는 DOM 소스 순서를 읽고 키보드 탭 순서도 DOM을 따릅니다. 시각적 레이아웃과 의미적 구조가 모순되면 접근성 위반입니다. 대화형 컴포넌트(데이터 그리드, 라디얼 메뉴 등)에서는 여전히 자바스크립트로 aria-posinsetaria-setsize를 동기화해야 합니다.

Chrome DevTools inspecting computed sibling-index value for a list item Coding Session Visual

한국 개발 생태계에서의 적용 맥락

국내에서는 아직 sibling-index()sibling-count()에 대한 정보가 많지 않습니다. 대부분의 CSS 강의나 블로그는 여전히 :nth-child() 선택자와 Sass 루프를 이용한 방식으로 계단식 애니메이션을 설명하고 있어요.

실무 적용 팁:

  • 대시보드 UI: KPI 카드 그리드에 계단식 등장 애니메이션을 넣을 때 진가를 발휘합니다. 데이터 양이 동적으로 변해도 CSS만으로 대응 가능합니다.
  • 쇼핑몰 상품 목록: 필터링 결과가 실시간으로 바뀌는 환경에서, 상품 카드의 너비를 자동 조정하는 데 활용하세요.
  • SI 프로젝트: 레거시 프로젝트에 점진적으로 도입하려면 @supports 폴백을 꼭 사용하세요. 아직 모든 브라우저가 지원하지 않으므로, '깨지지 않는' 점진적 향상이 핵심입니다.

이 기술의 한계

  • display: none 필터링과의 궁합이 좋지 않습니다. 검색/필터 기능이 있는 리스트에서는 자바스크립트로 실제 DOM을 제거하는 방식을 고려해야 합니다.
  • Shadow DOM 내부에서는 예상과 다르게 동작할 수 있습니다. 웹 컴포넌트를 사용 중이라면 반드시 테스트하세요.
  • 10,000개 이상의 대규모 동적 리스트에서는 성능 비용이 발생할 수 있습니다.

다음 단계 학습 방향

  1. CSS @property 학습: 커스텀 프로퍼티의 타입과 상속을 제어하는 방법을 익히면, sibling-index()와의 조합이 더 강력해집니다.
  2. CSS 삼각함수 (sin(), cos()) 마스터하기: 원형 레이아웃뿐 아니라 파형 애니메이션, 곡선형 패스 등 다양한 표현이 가능합니다.
  3. CSS 컨테이너 쿼리와의 조합: sibling-count()로 얻은 개수에 따라 컨테이너 스타일을 동적으로 변경하는 패턴을 연구해보세요.

함께 보면 좋은 글


참고: 이 글은 Smashing Magazine의 원문을 기반으로 한국 개발자 맥락에 맞게 재구성했습니다.

본 콘텐츠는 신뢰할 수 있는 출처를 바탕으로 AI 도구를 활용하여 초안이 작성되었으며, 편집자의 검토를 거쳐 발행되었습니다. 전문가의 조언을 대체하지 않습니다.