자바스크립트 없이 구현하는 CSS 캐러셀: 5가지 패턴
요약
자바스크립트 없이 최신 CSS 기능만을 활용하여 성능과 접근성을 높인 캐러셀 구현 패턴 5가지를 소개합니다. scroll-snap, scroll-marker-group 등을 사용하여 의존성을 제거하고 레이아웃 시프트를 방지하는 방법을 다룹니다.
핵심 포인트
- JS 없이 CSS만으로 14KB 규모의 의존성 제거 가능
- scroll-snap을 활용한 기본 스크롤 및 스냅 기능 구현
- scroll-marker-group으로 접근성 높은 도트 내비게이션 구축
- ::scroll-button을 이용한 자동 비활성화 버튼 구현
- 의미론적 마크업을 통한 브라우저 기본 기능 활용
-
스크롤 스냅 (scroll-snap) 트랙과 새로운 가상 요소 (pseudo-elements)를 사용하여 UI에서 14KB 크기의 캐러셀 의존성을 제거했습니다.
-
scroll-marker-group과 ::scroll-marker를 사용하면 스크립트 없이도 점 형태의 내비게이션 (dot navigation)을 구현할 수 있으며, 무료로 키보드 지원을 제공합니다.
-
::scroll-button()은 양 끝단에서 자동으로 비활성화되는 작동 가능한 이전 및 다음 버튼을 그려냅니다.
-
번호가 매겨진 마커 (Numbered markers), 피킹 레이아웃 (peeking layouts), 그리고 @supports 쿼리를 통해 폴백 (fallback)이 안전한 캐러셀을 완성합니다.
지난주 저는 쇼핑몰 프런트엔드에서 14KB 크기의 캐러셀 의존성을 제거하고 이를 약 40줄의 CSS로 대체했습니다. 자바스크립트 (JavaScript)도 없고, 하이드레이션 (hydration) 단계도 없으며, 스크립트가 부팅되는 동안 발생하는 레이아웃 시프트 (layout shift)도 없습니다. 이제 브라우저가 점(dots), 화살표, 키보드 핸들링을 직접 담당하며, 이 세 가지 모두 기존 플러그인보다 더 접근성 (accessibility) 있게 처리합니다. 여기에는 2025년과 2026년에 걸쳐 출시된 CSS 기능들을 기반으로 제가 사용한 다섯 가지 패턴이 있습니다.
패턴 1: 핵심 역할을 수행하는 스크롤 스냅 (Scroll-Snap) 트랙
모든 캐러셀은 스크롤 가능하고 스냅 기능이 있는 트랙에서 시작해야 합니다. 이 부분은 수년 동안 작동해 왔으며, 접근 가능한 기본값입니다. 즉, 터치 시 스와이프할 수 있고, 트랙패드로 스크롤할 수 있으며, 키보드로 탭 (tab) 할 수 있는 영역입니다.
.carousel {
display: flex;
...
scroll-snap-type: x mandatory는 각 아이템이 제자리에 안착하도록 강제합니다. 사용자가 가장자리에 가까이 도달했을 때만 스냅이 되기를 원한다면 proximity로 전환하세요. 이는 길고 자유로운 스크롤이 필요한 갤러리에 더 적합한 느낌을 줍니다. scroll-snap-align: center는 각 슬라이드가 멈출 위치를 결정합니다. 이는 단 하나의 컨트롤이 추가되기 전에도 이미 완전하고 사용 가능한 캐러셀이며, 지난 10년 내에 만들어진 어떤 브라우저에서도 일반 스크롤 영역으로 폴백 (degrade) 됩니다. 2026년의 기능들은 모두 이 동일한 트랙에 연결되므로, 장식보다 기초를 제대로 잡는 것이 더 중요합니다. 스크롤 동작에 관심이 있다면, 스크롤 구동 애니메이션 (scroll-driven animations)에서 더 자세히 다루었습니다.
컨트롤을 다루기 전에 구조적인 참고 사항이 하나 있습니다. 마커(marker)와 버튼(button) 의사 요소(pseudo-elements)는 스크롤 컨테이너(scroll container)와 그 직계 자식 요소에 부착되므로, 슬라이드(slides)를 리스트(list)로 마크업하십시오. <ul>과 <li>를 사용한 슬라이드 구성은 적절한 의미론(semantics)을 제공하며, ::scroll-marker가 연결될 수 있는 올바른 요소를 제공합니다. 또한 CSS가 제거되거나 로드되지 않더라도 마크업은 여전히 의미를 유지합니다.
패턴 2: scroll-marker-group을 이용한 도트 내비게이션 (Dot Navigation)
과거에 도트(dots)는 자바스크립트(JavaScript)의 영역이었습니다. 활성 인덱스(active index)를 추적하고, 슬라이드당 버튼을 렌더링하며, 각 클릭을 스크롤로 연결해야 했습니다. 이제는 스크롤 컨테이너가 이를 자동으로 생성해 줍니다.
.carousel { scroll-marker-group: after; }
...
scroll-marker-group: after는 컨테이너에 콘텐츠 이후에 마커 그룹을 생성하도록 지시합니다. 각 자식 요소는 ::scroll-marker 의사 요소를 갖게 되며, :target-current는 현재 뷰(view)에 보이는 슬라이드에 매핑되는 마커와 일치합니다. 따라서 별도의 상태(state) 관리 없이도 활성화된 도트에 불이 들어옵니다. 마커를 클릭하면 해당 항목으로 스크롤됩니다. 화살표 키를 사용하면 단일 포커스 그룹(focus group)으로서 마커 사이를 이동할 수 있으며, 그룹 자체는 하나의 탭 정지(tab stop) 지점이 됩니다. 이는 접근성 가이드라인(accessibility guidelines)에서 요구하는 키보드 모델과 정확히 일치합니다. 브라우저가 이미 어떤 슬라이드가 표시되고 있는지 알고 있기 때문에, 인덱스 변수도, 이벤트 리스너(event listener)도, 직접 구현해야 하는 ARIA도 필요하지 않습니다.
배치는 한 줄의 결정으로 끝납니다. scroll-marker-group: after는 도트를 트랙(track) 아래에 배치하고, before는 위에 배치합니다. 그리고 ::scroll-marker-group을 일반적인 플렉스 컨테이너(flex container)처럼 스타일링하여 위치와 간격을 조정할 수 있습니다. 그룹이 화살표 키 이동이 가능한 단일 포커스 정지 지점이므로, 보조 기술(assistive tech)이 기대하는 키보드 패턴을 얻을 수 있습니다. 이는 직접 구현한 캐러셀(carousels)이 거의 항상 실수하는 부분입니다.
패턴 3: ::scroll-button()을 이용한 이전 및 다음 버튼
::scroll-button()은 스크롤 위치에 바인딩된 실제 버튼을 생성합니다. 방향을 전달하면, 브라우저가 해당 방향으로 더 이상 스크롤할 수 없을 때 버튼을 자동으로 비활성화(disable)합니다. 이 단일 동작만으로도 마지막 슬라이드를 지나쳤음에도 다음 화살표가 계속 클릭 가능한 상태로 남아 트랙이 가장자리에 부딪히며 흔들리는 가장 흔한 캐러셀 버그를 해결할 수 있습니다.
.carousel::scroll-button(inline-start) {
content: "\2039" / "Previous slide";
...
content 속성에서 슬래시(/) 뒤에 오는 텍스트는 접근 가능한 이름(accessible name)이므로, 스크린 리더(screen reader)가 정체 모를 글리프(glyph) 대신 "Previous slide"라고 읽어줍니다. inline-start 및 inline-end와 같은 논리적 방향(logical directions)은 쓰기 모드(writing mode)를 따르므로, 오른쪽에서 왼쪽으로 읽는 레이아웃(RTL)에서도 추가 코드 없이 화살표가 올바르게 반전됩니다. 이 버튼들은 생성되는 즉시 포커스(focusable)가 가능하고 키보드로 조작할 수 있으며, :disabled 상태는 클래스를 토글하는 방식이 아닌 실제 상태로 존재합니다.
만약 버튼을 흐리게 만드는 대신 양 끝에서 숨기고 싶다면, 불투명도(opacity)를 변경하는 대신 비활성화 상태를 display: none으로 타겟팅하세요. 어떤 방식이든 이 동작은 브라우저에 속해 있으므로, 런타임(runtime)에 슬라이드가 추가되거나 삭제되어도 올바른 상태를 유지합니다. 이는 기존의 인덱스 추적(index-tracking) 코드가 동기화에서 벗어나곤 했던 바로 그 상황에서 유용합니다.
패턴 4: 번호 및 썸네일 마커
기본적인 점(dot) 형태는 시작점일 뿐, 한계가 아닙니다. ::scroll-marker는 content 값을 허용하기 때문에, 마커에 번호를 매기거나 각 마커에 썸네일(thumbnail)을 넣을 수 있습니다. 번호가 매겨진 마커는 트랙에 카운터(counter)를 사용합니다.
.carousel { counter-reset: slide; }
.carousel > li { counter-increment: slide; }
...
썸네일 스트립 (thumbnail strip)의 경우, 카운터 (counter) 대신 각 마커 (marker)에 background-image를 설정하고 취향에 맞게 크기를 조절하세요. 이 패턴은 과거에 가장 많은 JavaScript를 요구하던 방식이었습니다. 커스텀 썸네일 네비게이터 (navigator)를 구현하려면 두 개의 스크롤러 (scroller)를 수동으로 동기화해야 했기 때문입니다. 여기서는 마커가 슬라이드 (slide)와 별도의 작업 없이도 완벽하게 일치하며, :target-current를 통해 여전히 활성화된 항목을 강조할 수 있습니다. 이는 플랫폼이 하나의 컴포넌트 카테고리 전체를 흡수하고 있음을 보여주는 가장 명확한 사례입니다.
패턴 5: 더 많은 콘텐츠가 있음을 암시하는 피킹 레이아웃 (Peeking Layouts)
다음 슬라이드가 가장자리에서 살짝 보이는 피킹 레이아웃 (peeking layout)을 사용할 때 캐러셀 (carousel)은 더 잘 읽힙니다. 부분적으로 보이는 슬라이드가 사용자에게 해당 행이 스크롤된다는 어포던스 (affordance, 행동 유도성) 역할을 하기 때문입니다. 슬라이드 크기를 100% 미만으로 설정하고, 스냅 (snap)된 항목이 벽에 딱 붙지 않도록 스크롤 패딩 (scroll padding)을 추가하면 이를 구현할 수 있습니다.
.carousel {
scroll-padding-inline: 24px;
...
이제 각 슬라이드는 트랙 (track)의 80%를 차지하며, 양쪽 가장자리에서 이웃 슬라이드들이 보입니다. scroll-padding-inline은 스냅 지점 (snap point)을 안쪽으로 유지하여, 활성화된 슬라이드가 컨테이너에 밀착되지 않고 여유 공간을 가진 채 중앙에 위치하도록 합니다. 이를 앞선 패턴의 마커 (marker) 및 버튼 (button)과 결합하면, 렌더링을 위해 메인 스레드 (main thread)를 차단하지 않는 현대적이고 앱 같은 캐러셀을 완성할 수 있습니다.
점진적 향상 (Progressive Enhancement) 및 접근성 (Accessibility)
향상된 스타일링을 기능 쿼리 (feature query)로 감싸서, 지원되지 않는 브라우저에서는 제어 기능이 깨지는 대신 일반적인 스크롤 트랙 (scrolling track)으로 폴백 (fallback)되도록 하세요.
@supports selector(::scroll-marker) {
/* dot, number, and button styling lives here */
...
진정한 핵심은 접근성(Accessibility) 이야기입니다. JavaScript 캐러셀은 보통 수동으로 작성된 ARIA가 포함된 div들의 집합이며, 상태가 변하는 순간 동기화가 어긋나기 마련입니다. 반면, 이러한 가상 요소(Pseudo-elements)들은 브라우저가 보조 기술(Assistive technology)과 키보드에 기본적으로 노출하는 진정한 컨트롤입니다. 따라서 별도의 코드를 작성하지 않고도 초점 순서(Focus order), 음성 안내(Announcements), 비활성화 상태(Disabled states)를 그대로 상속받을 수 있습니다. 이러한 흐름은 앵커 포지셔닝 (Anchor positioning)에서도 나타나는데, 과거에는 라이브러리가 필요했던 작업들을 플랫폼이 직접 흡수하고 있습니다.
브라우저 지원 및 마이그레이션 방법
지원 범위에 대해 솔직해진다면, 여러분은 오늘 바로 이 기능을 배포할 수 있습니다. 캐러셀 가상 요소(::scroll-marker, scroll-marker-group, ::scroll-button())는 2025년 Chrome에 도입되었으며 다른 엔진들로 확대 적용되는 중입니다. 반면 스크롤 스냅(Scroll-snap) 자체는 이미 수년 전부터 모든 브라우저에서 보편적으로 사용되어 왔습니다. 이러한 차이 덕분에 폴백(Fallback) 방식이 안전합니다. 마커(Markers)를 이해하지 못하는 브라우저라도 여전히 깔끔하게 스와이프(Swipe)와 스냅(Snapping)이 가능한 트랙을 렌더링하기 때문입니다. 장식적인 요소는 기능이 저하될 수 있지만, 핵심 기능은 유지됩니다.
마이그레이션에는 오후 한나절이 걸렸습니다. 라이브러리 임포트와 초기화 호출을 삭제하고, 슬라이더 마크업을 일반 <div>로 교체했으며, 점(Dots)과 화살표를 스타일시트로 옮겼습니다. 그리고 플러그인이 레이아웃을 재계산하기 위해 필요했던 리사이즈 핸들러(Resize handler)를 제거했습니다. 그 결과, 전송되는 JavaScript 양이 줄어들었고, 스크립트가 부팅되는 동안 레이아웃 시프트(Layout shift)가 발생하지 않으며, 하이드레이션(Hydration)이 완료되기 전에도 작동하는 캐러셀을 얻었습니다. 배포하기 전에 탭(Tab) 키로 한 번 훑으며 초점 순서를 확인하고, 동작 줄이기(Reduced motion) 설정이 켜진 상태에서 테스트한 뒤, 의존성을 완전히 삭제하세요.
결론
스크롤 스냅 트랙 (scroll-snap track), 점(dots)을 위한 ::scroll-marker, 화살표를 위한 ::scroll-button(), 그리고 번호가 매겨진 마커와 피킹 레이아웃 (peeking layout)을 활용하면, 스크립트 없이도 대부분의 캐러셀 (carousel)을 구현할 수 있으며 기존 플러그인들보다 눈에 띄게 향상된 접근성 (accessibility)을 제공합니다. 이제 저는 자동 재생 (autoplay), 무한 루프 (infinite loop), 또는 슬라이드 조회에 대한 분석 (analytics) 기능이 필요할 때만 JavaScript를 사용하며, 그 경우에도 JavaScript는 네이티브 트랙을 대체하는 것이 아니라 그 위에 얹혀 작동하도록 합니다.
의존성을 제거하고, 스냅 (snap) 기능은 유지하며, 나머지는 브라우저가 처리하도록 하세요. CSS가 조용히 라이브러리를 대체한 더 많은 패턴을 확인하려면 the Lab을 살펴보세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기