들어가며: 왜 지금 sibling-index()인가?
프론트엔드 개발자라면 한 번쯤 겪어봤을 문제입니다. 카드 그리드에 순차적으로 페이드 인(Fade-in) 애니메이션을 넣고 싶은데, 매번 같은 고민에 빠지곤 하죠.
- Sass 루프로 10개, 20개의
:nth-child()규칙을 하드코딩? 리스트가 50개로 늘어나면 그만큼 CSS 용량도 함께 늘어납니다. - JavaScript로 DOM을 순회하며
style="--index: 3"같은 인라인 스타일을 심는다? 동작은 하지만, 6개월 후 리팩터링할 때 "이 CSS는 어디서 주입되는 거지?" 하고 원인을 찾느라 시간을 허비하게 됩니다.
두 접근법 모두 근본적으로 같은 문제를 안고 있었습니다. 브라우저는 이미 DOM 트리를 알고 있습니다. 어떤 요소가 몇 번째 자식인지, 부모가 총 몇 개의 자식을 가졌는지 모두 알고 있는데, CSS가 그 정보를 활용할 방법이 없었던 겁니다.
이제는 다릅니다. CSS Values and Units Module Level 5 명세 (섹션 9)에 포함된 sibling-index()와 sibling-count() 덕분에, 단 한 줄의 CSS로 이 문제가 해결됩니다.
li {
animation-delay: calc(sibling-index() * 100ms);
}
이 코드 하나면 5개의 아이템이든 5,000개의 아이템이든 문제없이 동작합니다. 별도의 이벤트 리스너, MutationObserver, 리렌더링이 필요 없습니다.
이 글에서는 이 두 함수의 정확한 동작 방식부터 실무에서 바로 써먹을 수 있는 패턴들, 그리고 반드시 알아야 할 주의사항까지 모두 다룹니다.

핵심 개념: sibling-index()와 sibling-count()
두 함수 모두 인자를 받지 않으며, 각각 다음과 같은 값을 반환합니다.
sibling-index(): 부모의 자식 요소 중 현재 요소가 몇 번째인지 1부터 시작하는 정수를 반환합니다. 텍스트 노드, 주석, 공백은 무시하고 오직 요소 노드(Element Node)만 셉니다.sibling-count(): 부모가 가진 총 자식 요소의 개수를 반환합니다. JavaScript의element.parentElement.children.length와 정확히 같은 값이지만, CSS 스타일시트에서 바로 사용할 수 있습니다.
두 함수 모두 <integer> 값으로 평가되므로 calc(), min(), max(), round(), mod(), 그리고 삼각 함수 sin(), cos()와 자유롭게 조합할 수 있습니다.
counter()와의 차이점:counter()는 문자열을 반환하고content속성 안에서만 사용할 수 있습니다. 반면sibling-index()는 숫자이므로 모든 계산 속성에서 사용 가능합니다.
:nth-child()와의 차이점::nth-child()는 선택자(Selector)입니다. 요소를 선택할 뿐 값을 생성하지 않습니다.calc(:nth-child() * 10px)같은 문법은 유효하지 않습니다.sibling-index()는 선언부(Declaration) 안에서 계산에 사용할 숫자를 제공합니다.
실무 패턴 5선
1. 역방향 스태거 (Reverse Stagger)
마지막 아이템이 가장 먼저 애니메이션되도록 하려면 sibling-count()에서 sibling-index()를 빼면 됩니다.
.card {
animation: fade-in 0.4s ease both;
animation-delay: calc((sibling-count() - sibling-index()) * 80ms);
}
마지막 카드는 (N - N) * 80ms = 0ms으로 즉시 실행되고, 첫 번째 카드는 (N - 1) * 80ms로 가장 늦게 실행됩니다. 페이지 로드 시 어색한 대기 시간 없이 바로 애니메이션이 시작되는 효과를 줍니다.
2. 자동 균등 너비 (Automatic Equal Widths)
자식 개수를 세서 퍼센트 값을 하드코딩할 필요가 없습니다.
.tab {
width: calc(100% / sibling-count());
}
탭이 5개면 20%, 6개면 16.66%, 2개면 50%가 자동으로 계산됩니다. 미디어 쿼리, ResizeObserver, JavaScript가 전혀 필요 없습니다.
주의: 아이템이 너무 많아지면 탭 너비가 너무 좁아질 수 있습니다. 이런 경우 Flexbox
flex-wrap을 고려하세요.
3. 색상 분포 (Hue Distribution)
컬러 휠을 따라 고르게 색상을 분배합니다.
.swatch {
background-color: hsl(
calc((360deg / sibling-count()) * sibling-index()) 70% 50%
);
}
아이템이 3개이면 120도 간격, 12개이면 30도 간격으로 색상이 배치됩니다. DOM 구성에 따라 팔레트가 자동으로 조정되므로, JavaScript 색상 라이브러리를 사용할 필요가 없습니다.
4. 원형 메뉴 (Circular Menus)
CSS의 네이티브 sin()과 cos() 함수를 sibling-index()와 결합하면 원형 레이아웃 전체를 순수 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개면 팔각형이 됩니다. 아이템을 추가/제거하면 레이아웃이 자동으로 재계산됩니다. JavaScript로 좌표를 계산할 필요가 전혀 없습니다.
5. Z-Index 스택 (Card Fan)
카드가 겹치는 효과를 한 줄로 구현합니다.
.card {
z-index: calc(sibling-count() - sibling-index());
}
첫 번째 카드가 가장 위에 쌓이고, 마지막 카드가 0이 됩니다. 순서를 반대로 하려면 sibling-index()만 단독으로 사용하면 됩니다.

주의사항과 함정 (Gotchas)
이 함수들은 강력하지만, 몇 가지 반드시 알아야 할 제약이 있습니다.
1. Shadow DOM 스코핑
sibling-index()와 sibling-count()는 DOM 트리를 기준으로 동작하며, **플랫된 시각적 트리(Flattened Visual Tree)**를 기준으로 하지 않습니다. 이 차이는 Web Components에서 결정적으로 작용합니다.
- 커스텀 엘리먼트의 Shadow DOM 내부에서
sibling-index()를 사용하면, 항상 Shadow DOM 내부의 자식만 셉니다. Light DOM에서 프로젝션된 콘텐츠는 카운트에 포함되지 않습니다. - Light DOM 스타일시트가
::part()를 통해 컴포넌트 내부에 접근해sibling-index()를 사용하면, 브라우저는0을 반환합니다. 이는 의도적인 보안 조치로, 외부 CSS가 서드파티 컴포넌트의 내부 구조를 조사하는 것을 막습니다.
2. display: none은 여전히 카운트된다
이 부분이 가장 헷갈리기 쉽습니다. display: none은 레이아웃 트리에서 사라지고, 화면에 보이지 않으며, 스크린 리더도 읽지 않지만, 여전히 DOM에는 존재합니다.
sibling-index()는 DOM 트리를 읽기 때문에 display: none인 요소도 카운트에 포함됩니다.
<ul>
<li>Apple</li>
<li style="display: none;">Banana</li> <!-- 숨겨졌지만 DOM에는 있음 -->
<li>Cherry</li>
</ul>
이 경우 Cherry의 sibling-index()는 2가 아니라 3입니다. 검색 필터처럼 display: none으로 비일치 항목을 숨기는 경우, 스태거 애니메이션이나 원형 레이아웃에 **빈자리(Gap)**가 생깁니다.
해결책: 연속적인 카운팅이 필요한 경우(원형 메뉴, 비례적 너비 등)에는
display: none대신 실제로 DOM에서 노드를 제거하거나, JavaScript로 인덱스를 관리하는 폴백을 사용하세요.
3. 사용자 정의 속성(Custom Properties)은 즉시 평가된다
부모 요소에 --idx: sibling-index();라고 선언하면, --idx는 부모 자신의 형제 인덱스로 즉시 평가되어 고정됩니다. 자식 요소가 이 값을 상속받으면 모든 자식이 동일한 숫자를 가지게 되어 원하는 대로 동작하지 않습니다.
/* 잘못된 예 */
.parent {
--idx: sibling-index(); /* 부모의 인덱스로 고정됨 */
}
/* 올바른 예: 함수를 사용할 요소에 직접 적용 */
.child {
--idx: sibling-index();
animation-delay: calc(var(--idx) * 100ms);
}
4. 성능: 대규모 DOM에서의 비용
DOM을 변경(자식 추가/제거/순서 변경)하면 영향을 받는 형제 요소들의 스타일 재계산이 트리거됩니다. 브라우저는 이 과정을 레이아웃과 페인트 이전의 캐스케이드 단계에서 처리하므로, JavaScript로 인라인 스타일을 찍는 방식보다는 빠릅니다.
하지만 10,000개의 자식을 가진 컨테이너의 맨 앞에 요소를 삽입하면, 엔진은 이후 10,000개 모든 요소의 형제 인덱스를 재계산해야 합니다. 일반적인 내비게이션, 카드 그리드, 탭 바에서는 체감하기 어렵지만, 실시간 주식 티커나 무한 스크롤 피드처럼 수천 개의 노드가 끊임없이 변경되는 환경에서는 JavaScript로 인덱스를 관리하는 편이 여전히 더 나을 수 있습니다.
브라우저 지원 및 폴백 전략
- Chrome/Edge 138: 2025년 6월부터 안정화 버전에서 지원
- Safari 26.2: 지원
- Firefox: 아직 안정화 버전에 포함되지 않았으나, Mozilla의 명세 입장은 긍정적이며 Bugzilla 이슈 #1953973에서 구현 작업이 진행 중입니다.
Chrome과 Safari의 점유율을 합치면 전역 트래픽의 약 75~80%를 커버합니다. 하지만 Firefox 사용자를 위한 폴백은 필수입니다.
/* 모든 브라우저에서 동작하는 기본 스타일 */
.item {
width: 25%;
animation-delay: 0ms;
}
/* 지원하는 브라우저에서만 점진적 향상 */
@supports (z-index: sibling-index()) {
.item {
width: calc(100% / sibling-count());
animation-delay: calc(sibling-index() * 80ms);
}
}
참고 자료: 이 글의 원문은 Smashing Magazine의 원본 기사에서 확인할 수 있습니다.
다음 단계 학습 방향
- CSS
@property와inherits:sibling-index()를 사용자 정의 속성에 할당할 때의 동작 방식을 더 깊이 이해하려면 CSS Houdini의@property규칙을 학습해보세요. - CSS 삼각 함수(
sin(),cos()): 원형 레이아웃 외에도 다양한 기하학적 패턴에 활용할 수 있습니다. aria-posinset와aria-setsize: 시각적 순서와 접근성 트리의 순서가 다를 때, ARIA 속성을 어떻게 동기화할지 고민해보세요.

결론: 이제는 CSS가 DOM을 읽는다
<div>를 10개 나열하고 각각에 :nth-child() 규칙을 10개 작성하던 시대는 끝났습니다. sibling-index()와 sibling-count()는 브라우저가 이미 알고 있는 정보를 CSS가 활용할 수 있게 해주는, 오랫동안 기다려온 기능입니다.
이 함수들은 특별한 라이브러리나 프레임워크 없이도 반응형 레이아웃, 동적 애니메이션, 자동 색상 분포 등을 순수 CSS로 구현할 수 있게 해줍니다.
다만, Shadow DOM에서의 스코핑, display: none의 카운팅, 대규모 DOM에서의 성능 비용 등 몇 가지 주의사항을 반드시 숙지해야 합니다. @supports를 활용한 점진적 향상 전략으로 Firefox 사용자까지 커버하는 것을 잊지 마세요.
지금 당장 사용할 수 있는 것은 아니지만, 이미 Chrome과 Safari에서 지원하기 시작했고, Firefox도 구현 중입니다. 2025년 하반기부터 본격적으로 실무에 도입할 준비를 시작해도 좋을 때입니다.