들어가며: '이걸 왜 지금까지 못 했을까?' 싶은 순간
카드 그리드에 하나둘씩 페이드 인하는 효과, 보기엔 멋지지만 만들 때마다 찝찝함이 남았습니다. 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도, 리렌더링도 필요 없습니다.

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()만 사용하세요.

주의사항 & 함정 (이걸 모르면 디버깅 지옥)
Shadow DOM 스코핑
sibling-index()와 sibling-count()는 DOM 트리를 기준으로 동작하며, 플랫 비주얼 트리는 보지 않습니다. 웹 컴포넌트에서 이 차이는 결정적입니다.
<!-- 커스텀 요소의 Shadow DOM -->
<host-element>
#shadow-root
<style></style>
<div class="internal"></div>
.internal에 sibling-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-posinset과 aria-setsize를 동기화해야 합니다.

한국 개발 생태계에서의 적용 맥락
국내에서는 아직 sibling-index()와 sibling-count()에 대한 정보가 많지 않습니다. 대부분의 CSS 강의나 블로그는 여전히 :nth-child() 선택자와 Sass 루프를 이용한 방식으로 계단식 애니메이션을 설명하고 있어요.
실무 적용 팁:
- 대시보드 UI: KPI 카드 그리드에 계단식 등장 애니메이션을 넣을 때 진가를 발휘합니다. 데이터 양이 동적으로 변해도 CSS만으로 대응 가능합니다.
- 쇼핑몰 상품 목록: 필터링 결과가 실시간으로 바뀌는 환경에서, 상품 카드의 너비를 자동 조정하는 데 활용하세요.
- SI 프로젝트: 레거시 프로젝트에 점진적으로 도입하려면
@supports폴백을 꼭 사용하세요. 아직 모든 브라우저가 지원하지 않으므로, '깨지지 않는' 점진적 향상이 핵심입니다.
이 기술의 한계
display: none필터링과의 궁합이 좋지 않습니다. 검색/필터 기능이 있는 리스트에서는 자바스크립트로 실제 DOM을 제거하는 방식을 고려해야 합니다.- Shadow DOM 내부에서는 예상과 다르게 동작할 수 있습니다. 웹 컴포넌트를 사용 중이라면 반드시 테스트하세요.
- 10,000개 이상의 대규모 동적 리스트에서는 성능 비용이 발생할 수 있습니다.
다음 단계 학습 방향
- CSS
@property학습: 커스텀 프로퍼티의 타입과 상속을 제어하는 방법을 익히면,sibling-index()와의 조합이 더 강력해집니다. - CSS 삼각함수 (
sin(),cos()) 마스터하기: 원형 레이아웃뿐 아니라 파형 애니메이션, 곡선형 패스 등 다양한 표현이 가능합니다. - CSS 컨테이너 쿼리와의 조합:
sibling-count()로 얻은 개수에 따라 컨테이너 스타일을 동적으로 변경하는 패턴을 연구해보세요.
함께 보면 좋은 글
참고: 이 글은 Smashing Magazine의 원문을 기반으로 한국 개발자 맥락에 맞게 재구성했습니다.