CSS scroll-snap-type으로 네이티브 앱처럼 자연스러운 스크롤 만들기

모바일 앱에서 이미지를 넘길 때, 손가락을 떼면 딱딱 정확한 위치에 멈춰서는 경험을 해보셨나요? 인스타그램의 스토리나 넷플릭스의 썸네일 스크롤처럼요.

예전에는 이런 효과를 만들려면 JavaScript로 복잡한 로직을 구현해야 했습니다. 스크롤 이벤트를 감지하고, 속도를 계산하고, 애니메이션을 직접 제어하는… 상당히 번거로운 작업이었죠.

하지만 이제는 CSS만으로도 이런 자연스러운 스냅 스크롤을 구현할 수 있습니다. 바로 scroll-snap-type 속성을 사용해서요.

목차

  1. 먼저, 기초부터 이해하기
  2. scroll-snap-type이 왜 필요한가?
  3. 기본 문법과 값들
  4. x mandatory 완벽 이해하기
  5. 실전 예제
  6. mandatory vs proximity 비교
  7. 브라우저 지원 및 호환성
  8. 함정과 주의사항
  9. 모범 사례
  10. 참고 자료

먼저, 기초부터 이해하기

스크롤 스냅(Scroll Snap)이란?

스크롤 스냅은 사용자가 스크롤을 멈췄을 때, 콘텐츠가 특정 위치에 자동으로 정렬되는 기능입니다. 마치 자석처럼 가장 가까운 “스냅 포인트”로 콘텐츠가 달라붙는 것이죠.

왜 이게 필요할까요?

터치 기기에서 사용자가 스크롤할 때, 중간 어정쩡한 위치에 멈춰버리면 콘텐츠가 반쯤 잘려 보이는 경우가 많습니다. 특히 카드 UI나 이미지 갤러리에서 이런 문제가 두드러집니다.

스크롤 스냅은 이런 문제를 해결해, 항상 완전한 콘텐츠가 보이도록 보장합니다.

CSS Scroll Snap이 동작하는 원리

CSS Scroll Snap은 두 부분으로 구성됩니다:

┌──────────────────────────────────────┐
│  스크롤 컨테이너 (부모)                  │
│  scroll-snap-type 설정                │
│  ┌────────┐  ┌────────┐  ┌────────┐  │
│  │ 아이템1  │  │ 아이템2 │  │ 아이템3 │  │
│  │scroll- │  │scroll- │  │scroll- │  │
│  │snap-   │  │snap-   │  │snap-   │  │
│  │align   │  │align   │  │align   │  │
│  └────────┘  └────────┘  └────────┘  │
└──────────────────────────────────────┘
  1. 부모 요소: scroll-snap-type으로 스냅 동작을 활성화
  2. 자식 요소: scroll-snap-align으로 어디에 스냅될지 지정

scroll-snap-type이 왜 필요한가?

문제 상황

여러분이 모바일 쇼핑몰에서 상품 카드를 가로 스크롤로 보여준다고 가정해봅시다.

/* ❌ 스냅 없이 기본 스크롤만 */
.product-list {
  display: flex;
  overflow-x: auto;
  gap: 16px;
}

.product-card {
  flex: 0 0 280px;
  height: 400px;
}

문제점:

  • 사용자가 스크롤을 멈추면 카드가 반쯤 잘려 보임
  • 어느 카드를 보고 있는지 불명확함
  • 모바일에서 특히 답답한 사용자 경험

해결책

/* ✅ 스크롤 스냅 적용 */
.product-list {
  display: flex;
  overflow-x: auto;
  gap: 16px;
  scroll-snap-type: x mandatory; /* 이 한 줄 추가 */
}

.product-card {
  flex: 0 0 280px;
  height: 400px;
  scroll-snap-align: start; /* 자식에 정렬 위치 지정 */
}

개선된 점:

  • 항상 카드의 시작점에 정렬됨
  • 명확한 시각적 경계
  • 네이티브 앱 같은 부드러운 경험

기본 문법과 값들

기본 문법

scroll-snap-type: <axis> <strictness>;

축(Axis) 값

스크롤 스냅을 적용할 방향을 지정합니다.

의미 사용 사례
x 수평(가로) 축만 가로 이미지 갤러리, 카드 스와이프
y 수직(세로) 축만 풀페이지 스크롤, 세로 섹션
both 양방향 모두 그리드 형태의 달력, 지도
block 블록 방향 (LTR: 세로) 언어 방향 고려한 세로 스크롤
inline 인라인 방향 (LTR: 가로) 언어 방향 고려한 가로 스크롤

엄격도(Strictness) 값

스냅을 얼마나 강제할지 결정합니다.

의미 동작
mandatory 필수, 강제 항상 가장 가까운 스냅 포인트로 이동
proximity 근접 시에만 스냅 포인트 근처일 때만 적용
/* 다양한 조합 예시 */
scroll-snap-type: none;          /* 스냅 비활성화 */
scroll-snap-type: x mandatory;   /* 가로 필수 스냅 */
scroll-snap-type: y proximity;   /* 세로 근접 스냅 */
scroll-snap-type: both mandatory; /* 양방향 필수 스냅 */

x mandatory 완벽 이해하기

scroll-snap-type: x mandatory는 가장 많이 사용되는 조합입니다. 하나씩 뜯어볼까요?

x의 의미

수평 축(horizontal axis)에서만 스냅을 적용한다는 뜻입니다.

.horizontal-scroll {
  scroll-snap-type: x mandatory;
  overflow-x: auto;  /* x축 스크롤 활성화 */
  overflow-y: hidden; /* y축 스크롤 비활성화 */
}

가로로 스크롤하는 컨텐츠에 적합합니다:

  • 이미지 캐러셀
  • 스토리 뷰어
  • 탭 네비게이션
  • 제품 카드 슬라이더

mandatory의 의미

“반드시” 스냅 포인트로 정렬되어야 합니다.

사용자가 스크롤을 멈추면:

  1. 가장 가까운 스냅 포인트를 찾음
  2. 해당 위치로 자동 스크롤
  3. 중간 위치에는 절대 멈출 수 없음
/* mandatory 예시 */
.strict-snap {
  scroll-snap-type: x mandatory;
}

.strict-snap > .item {
  scroll-snap-align: start;
}

/* 결과: 스크롤을 어디서 멈추든 항상 item의 시작점에 정렬됨 */

언제 x mandatory를 사용하나?

✅ 사용하면 좋은 경우:

  • 페이지네이션이 명확한 컨텐츠 (슬라이드쇼)
  • 한 번에 하나씩만 보여줘야 하는 UI (스토리)
  • 터치 기반 네비게이션
  • 명확한 시작/끝이 있는 아이템 리스트

❌ 피해야 하는 경우:

  • 긴 연속적인 콘텐츠 (아티클 읽기)
  • 자유로운 탐색이 필요한 UI
  • 스크롤 위치가 중요하지 않은 경우

실전 예제

예제 1: 인스타그램 스토리 스타일

실제 인스타그램 스토리처럼 프로그레스 바, 자동 재생, 터치 제스처를 모두 포함한 완전한 예제입니다.

See the Pen [CSS] Instagram Stories with Scroll Snap by deep b (@deep-tsuki) on CodePen.

HTML 구조

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Instagram Stories with Scroll Snap</title>
</head>
<body>
  <div class="stories-container">
    <!-- 프로그레스 바 -->
    <div class="progress-bars">
      <div class="progress-bar">
        <div class="progress-fill" data-index="0"></div>
      </div>
      <div class="progress-bar">
        <div class="progress-fill" data-index="1"></div>
      </div>
      <div class="progress-bar">
        <div class="progress-fill" data-index="2"></div>
      </div>
      <div class="progress-bar">
        <div class="progress-fill" data-index="3"></div>
      </div>
    </div>

    <!-- 스토리 컨테이너 -->
    <div class="stories">
      <div class="story" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
        <div class="story-content">
          <h2>스토리 1</h2>
          <p>좌우로 스와이프하거나 탭하여 이동하세요</p>
        </div>
      </div>
      <div class="story" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
        <div class="story-content">
          <h2>스토리 2</h2>
          <p>자동으로 다음 스토리로 넘어갑니다</p>
        </div>
      </div>
      <div class="story" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
        <div class="story-content">
          <h2>스토리 3</h2>
          <p>화면을 길게 눌러 일시정지</p>
        </div>
      </div>
      <div class="story" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);">
        <div class="story-content">
          <h2>스토리 4</h2>
          <p>마지막 스토리입니다</p>
        </div>
      </div>
    </div>

    <!-- 네비게이션 영역 (투명) -->
    <div class="story-nav">
      <div class="nav-area nav-prev"></div>
      <div class="nav-area nav-next"></div>
    </div>
  </div>
</body>
</html>

CSS 스타일

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  background: #000;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  overflow: hidden;
}

.stories-container {
  position: relative;
  width: 100vw;
  max-width: 500px;
  height: 100vh;
  max-height: 800px;
  background: #000;
  overflow: hidden;
  border-radius: 0;
}

@media (min-width: 768px) {
  .stories-container {
    border-radius: 16px;
    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
  }
}

/* 프로그레스 바 */
.progress-bars {
  position: absolute;
  top: 16px;
  left: 8px;
  right: 8px;
  display: flex;
  gap: 4px;
  z-index: 100;
}

.progress-bar {
  flex: 1;
  height: 3px;
  background: rgba(255, 255, 255, 0.3);
  border-radius: 2px;
  overflow: hidden;
}

.progress-fill {
  width: 0%;
  height: 100%;
  background: white;
  border-radius: 2px;
  transition: width 0.1s linear;
}

.progress-fill.active {
  animation: progress 5s linear forwards;
}

.progress-fill.completed {
  width: 100%;
}

@keyframes progress {
  from {
    width: 0%;
  }
  to {
    width: 100%;
  }
}

/* 스토리 스크롤 컨테이너 */
.stories {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  height: 100%;
  width: 100%;

  /* 스크롤바 숨기기 */
  -ms-overflow-style: none;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}

.stories::-webkit-scrollbar {
  display: none;
}

/* 각 스토리 */
.story {
  flex: 0 0 100%;
  scroll-snap-align: start;
  scroll-snap-stop: always;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  user-select: none;
}

.story-content {
  text-align: center;
  color: white;
  padding: 20px;
}

.story-content h2 {
  font-size: 2.5rem;
  font-weight: 700;
  margin-bottom: 1rem;
  text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}

.story-content p {
  font-size: 1.1rem;
  opacity: 0.9;
  text-shadow: 0 1px 5px rgba(0, 0, 0, 0.3);
}

/* 네비게이션 영역 */
.story-nav {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  z-index: 50;
  pointer-events: none;
}

.nav-area {
  flex: 1;
  pointer-events: auto;
  cursor: pointer;
}

.nav-prev {
  /* 왼쪽 30% */
  flex: 0 0 30%;
}

.nav-next {
  /* 오른쪽 70% */
  flex: 0 0 70%;
}

/* 터치 피드백 (선택사항) */
.nav-area:active {
  background: rgba(255, 255, 255, 0.1);
}

/* 일시정지 상태 */
.stories-container.paused .progress-fill.active {
  animation-play-state: paused;
}

JavaScript 로직

class StoryViewer {
  constructor() {
    this.container = document.querySelector('.stories');
    this.stories = document.querySelectorAll('.story');
    this.progressFills = document.querySelectorAll('.progress-fill');
    this.currentIndex = 0;
    this.duration = 5000; // 5초
    this.isPaused = false;
    this.progressTimer = null;

    this.init();
  }

  init() {
    // 초기 프로그레스 시작
    this.startProgress(0);

    // 스크롤 이벤트 감지
    this.container.addEventListener('scroll', () => {
      this.handleScroll();
    });

    // 네비게이션 클릭
    document.querySelector('.nav-prev')?.addEventListener('click', () => {
      this.goToPrevious();
    });

    document.querySelector('.nav-next')?.addEventListener('click', () => {
      this.goToNext();
    });

    // 길게 눌러 일시정지
    let pressTimer;
    const storiesContainer = document.querySelector('.stories-container');

    storiesContainer.addEventListener('mousedown', () => {
      pressTimer = setTimeout(() => this.pause(), 200);
    });

    storiesContainer.addEventListener('mouseup', () => {
      clearTimeout(pressTimer);
      this.resume();
    });

    storiesContainer.addEventListener('mouseleave', () => {
      clearTimeout(pressTimer);
      this.resume();
    });

    // 터치 이벤트 (모바일)
    storiesContainer.addEventListener('touchstart', () => {
      pressTimer = setTimeout(() => this.pause(), 200);
    });

    storiesContainer.addEventListener('touchend', () => {
      clearTimeout(pressTimer);
      this.resume();
    });

    // 키보드 네비게이션
    document.addEventListener('keydown', (e) => {
      if (e.key === 'ArrowLeft') this.goToPrevious();
      if (e.key === 'ArrowRight') this.goToNext();
    });
  }

  handleScroll() {
    // 현재 보이는 스토리 인덱스 계산
    const scrollLeft = this.container.scrollLeft;
    const itemWidth = this.stories[0].offsetWidth;
    const newIndex = Math.round(scrollLeft / itemWidth);

    if (newIndex !== this.currentIndex) {
      this.currentIndex = newIndex;
      this.updateProgress();
    }
  }

  startProgress(index) {
    // 이전 타이머 제거
    if (this.progressTimer) {
      clearTimeout(this.progressTimer);
    }

    // 현재 프로그레스 바 활성화
    this.progressFills.forEach((fill, i) => {
      fill.classList.remove('active', 'completed');
      if (i < index) {
        fill.classList.add('completed');
      } else if (i === index) {
        fill.classList.add('active');
      }
    });

    // 자동 넘김 타이머
    this.progressTimer = setTimeout(() => {
      if (!this.isPaused) {
        this.goToNext();
      }
    }, this.duration);
  }

  updateProgress() {
    this.startProgress(this.currentIndex);
  }

  goToNext() {
    if (this.currentIndex < this.stories.length - 1) {
      this.currentIndex++;
      this.scrollToStory(this.currentIndex);
    }
  }

  goToPrevious() {
    if (this.currentIndex > 0) {
      this.currentIndex--;
      this.scrollToStory(this.currentIndex);
    }
  }

  scrollToStory(index) {
    const story = this.stories[index];
    story.scrollIntoView({ behavior: 'smooth', inline: 'start' });
  }

  pause() {
    this.isPaused = true;
    document.querySelector('.stories-container').classList.add('paused');
  }

  resume() {
    this.isPaused = false;
    document.querySelector('.stories-container').classList.remove('paused');
  }
}

// 초기화
document.addEventListener('DOMContentLoaded', () => {
  new StoryViewer();
});

완성된 모습

주요 기능:

  1. scroll-snap-type: x mandatory - 정확한 스냅 포인트
  2. 프로그레스 바 - 현재 진행 상황 표시
  3. 자동 재생 - 5초마다 다음 스토리로
  4. 터치 네비게이션 - 좌우 탭으로 이동
  5. 일시정지 - 길게 눌러 일시정지
  6. 키보드 지원 - 화살표 키로 네비게이션
  7. 부드러운 전환 - scroll-behavior: smooth

작동 원리:

1. 사용자가 스토리를 본다
2. 프로그레스 바가 5초간 채워진다
3. 자동으로 다음 스토리로 스크롤
4. 스크롤 이벤트 감지 → 프로그레스 업데이트
5. scroll-snap-type이 정확한 위치에 스냅

모바일 최적화:

  • -webkit-overflow-scrolling: touch - iOS 네이티브 스크롤
  • scroll-snap-stop: always - 빠른 스와이프에도 각 스토리 정지
  • 터치 이벤트 지원
  • 반응형 디자인 (데스크톱에서는 둥근 모서리)

mandatory (강제)

.strict {
  scroll-snap-type: x mandatory;
}

동작 방식:

  • 스크롤이 멈추면 무조건 가장 가까운 스냅 포인트로 이동
  • 아무리 조금만 스크롤해도 다음 스냅 포인트로 넘어갈 수 있음
  • 중간 위치에는 절대 멈출 수 없음

장점:

  • 명확한 페이지네이션
  • 예측 가능한 동작
  • 깔끔한 UI

단점:

  • 때로는 너무 강제적
  • 사용자가 의도하지 않은 이동 발생 가능
  • 긴 리스트에서는 답답할 수 있음

적합한 경우:

/* ✅ 좋은 사용 사례 */
.image-slider {
  scroll-snap-type: x mandatory; /* 이미지는 완전히 보여야 함 */
}

.fullpage-sections {
  scroll-snap-type: y mandatory; /* 섹션 단위 네비게이션 */
}

proximity (근접)

.gentle {
  scroll-snap-type: x proximity;
}

동작 방식:

  • 스냅 포인트 근처에 있을 때만 스냅
  • 멀리 떨어진 곳에서 멈추면 스냅하지 않음
  • 더 자연스럽고 덜 강제적

장점:

  • 자연스러운 스크롤 경험
  • 사용자 의도 존중
  • 긴 리스트에 적합

단점:

  • 때로는 스냅이 예상대로 작동하지 않을 수 있음
  • 명확한 페이지네이션이 필요한 경우 부적합

적합한 경우:

/* ✅ 좋은 사용 사례 */
.article-sections {
  scroll-snap-type: y proximity; /* 자유로운 읽기 허용 */
}

.timeline {
  scroll-snap-type: x proximity; /* 특정 이벤트에만 스냅 */
}

Before & After 비교

/* ❌ Before: mandatory로 인한 문제 */
.long-article {
  scroll-snap-type: y mandatory;
  overflow-y: auto;
}

.paragraph {
  scroll-snap-align: start;
  min-height: 200px;
}

/* 문제: 조금만 스크롤해도 다음 문단으로 강제 이동
   사용자가 천천히 읽을 수 없음 */
/* ✅ After: proximity로 개선 */
.long-article {
  scroll-snap-type: y proximity;
  overflow-y: auto;
}

.paragraph {
  scroll-snap-align: start;
  min-height: 200px;
}

/* 개선: 자유롭게 스크롤 가능
   문단 시작점 근처에서만 부드럽게 스냅 */

브라우저 지원 및 호환성

지원 현황

scroll-snap-typeBaseline Widely available 상태입니다.

지원 브라우저:

  • ✅ Chrome 69+ (2018년 9월)
  • ✅ Firefox 68+ (2019년 7월)
  • ✅ Safari 11+ (2017년 9월)
  • ✅ Edge 79+ (2020년 1월)

2022년 4월부터 대다수 브라우저에서 안정적으로 사용 가능합니다.

모바일 지원

.mobile-friendly {
  scroll-snap-type: x mandatory;
  -webkit-overflow-scrolling: touch; /* iOS 부드러운 스크롤 */
}

모바일 브라우저:

  • ✅ iOS Safari 11+
  • ✅ Chrome Android
  • ✅ Samsung Internet

Fallback 전략

구형 브라우저를 지원해야 한다면:

.carousel {
  overflow-x: auto;

  /* 기본 동작 (fallback) */
  /* scroll-snap-type이 지원되지 않으면 일반 스크롤로 작동 */

  /* 모던 브라우저 */
  scroll-snap-type: x mandatory;
}

/* Feature detection with CSS */
@supports (scroll-snap-type: x mandatory) {
  .carousel {
    /* 스냅이 지원되는 경우에만 적용할 스타일 */
    scroll-padding: 0 24px;
  }
}

JavaScript Feature Detection

// scroll-snap-type 지원 여부 확인
if ('scrollSnapType' in document.documentElement.style) {
  console.log('CSS Scroll Snap 지원됨');
} else {
  console.log('폴백 솔루션 필요');
  // 대체 JavaScript 스크롤 라이브러리 로드
}

함정과 주의사항

함정 1: overflow 속성 누락

/* ❌ 작동하지 않음: overflow가 없음 */
.container {
  scroll-snap-type: x mandatory;
  display: flex;
  /* overflow-x가 없어서 스냅이 작동하지 않음! */
}
/* ✅ 올바른 사용 */
.container {
  scroll-snap-type: x mandatory;
  overflow-x: auto; /* 필수! */
  display: flex;
}

원인: scroll-snap-type스크롤 가능한 컨테이너에만 적용됩니다. overflow: auto 또는 overflow: scroll이 반드시 필요합니다.

함정 2: 자식 요소에 scroll-snap-align 누락

/* ❌ 부모만 설정 */
.parent {
  scroll-snap-type: x mandatory;
  overflow-x: auto;
}

.child {
  /* scroll-snap-align이 없어서 스냅이 작동하지 않음! */
}
/* ✅ 자식에도 설정 */
.parent {
  scroll-snap-type: x mandatory;
  overflow-x: auto;
}

.child {
  scroll-snap-align: start; /* 필수! */
}

원인: 부모의 scroll-snap-type만으로는 부족합니다. 자식 요소에 scroll-snap-align이 있어야 스냅 포인트가 생성됩니다.

함정 3: 콘텐츠 크기 계산 실수

/* ❌ 스냅이 예상대로 작동하지 않음 */
.container {
  scroll-snap-type: x mandatory;
  overflow-x: auto;
  width: 100%;
  display: flex;
  gap: 16px; /* 간격을 고려하지 않음! */
}

.item {
  flex: 0 0 calc(100% / 3); /* gap을 빼지 않아서 잘림 */
  scroll-snap-align: start;
}
/* ✅ gap을 고려한 정확한 계산 */
.container {
  scroll-snap-type: x mandatory;
  overflow-x: auto;
  width: 100%;
  display: flex;
  gap: 16px;
}

.item {
  flex: 0 0 calc((100% - 32px) / 3); /* gap 2개를 뺌 */
  scroll-snap-align: start;
}

/* 또는 더 간단하게 */
.item {
  flex: 0 0 300px; /* 고정 너비 사용 */
  scroll-snap-align: start;
}

함정 4: mandatory로 인한 접근성 문제

/* ❌ 긴 텍스트에 mandatory 사용 */
.article {
  scroll-snap-type: y mandatory;
  overflow-y: auto;
  height: 100vh;
}

.section {
  scroll-snap-align: start;
  min-height: 100vh; /* 섹션이 너무 길 수 있음 */
}

/* 문제: 사용자가 섹션 중간을 읽을 수 없음 */
/* ✅ proximity로 개선 */
.article {
  scroll-snap-type: y proximity; /* 더 자연스러움 */
  overflow-y: auto;
  height: 100vh;
}

.section {
  scroll-snap-align: start;
  min-height: 100vh;
}

함정 5: 동적 콘텐츠 변경 시 재정렬

// ⚠️ 주의: 콘텐츠가 변경되면 자동으로 재스냅됨
const container = document.querySelector('.snap-container');

// 아이템 추가
const newItem = document.createElement('div');
newItem.className = 'snap-item';
container.appendChild(newItem);

// 브라우저가 자동으로 이전 스냅 위치로 재정렬 시도
// 사용자가 보던 위치가 바뀔 수 있음!

해결책:

// ✅ 현재 스크롤 위치 저장 후 복원
const container = document.querySelector('.snap-container');
const scrollPos = container.scrollLeft;

// 아이템 추가
const newItem = document.createElement('div');
container.appendChild(newItem);

// 위치 복원
requestAnimationFrame(() => {
  container.scrollLeft = scrollPos;
});

함정 6: scroll-padding 미고려

/* ❌ 헤더가 콘텐츠를 가림 */
.page {
  scroll-snap-type: y mandatory;
  overflow-y: auto;
}

.section {
  scroll-snap-align: start; /* 헤더 아래에 숨음! */
}

.fixed-header {
  position: fixed;
  top: 0;
  height: 60px;
  background: white;
}
/* ✅ scroll-padding으로 헤더 공간 확보 */
.page {
  scroll-snap-type: y mandatory;
  overflow-y: auto;
  scroll-padding-top: 60px; /* 헤더 높이만큼 */
}

.section {
  scroll-snap-align: start;
}

.fixed-header {
  position: fixed;
  top: 0;
  height: 60px;
  background: white;
}

함정 7: both mandatory의 잘못된 사용

/* ❌ 대부분의 경우 불필요하게 제한적 */
.grid {
  scroll-snap-type: both mandatory;
  overflow: auto;
}

/* 문제: 대각선으로 스크롤할 수 없음
   항상 가로/세로 축에 정렬되어야 함 */
/* ✅ 대부분은 한 축만으로 충분 */
.grid {
  scroll-snap-type: x mandatory; /* 가로만 */
  overflow: auto;
}

/* 또는 정말 필요한 경우 proximity 사용 */
.grid {
  scroll-snap-type: both proximity;
  overflow: auto;
}

모범 사례

1. scroll-behavior와 함께 사용

.smooth-snap {
  scroll-snap-type: x mandatory;
  overflow-x: auto;
  scroll-behavior: smooth; /* 부드러운 스크롤 */
}

.smooth-snap .item {
  scroll-snap-align: start;
}

효과: 프로그래밍 방식으로 스크롤할 때 부드러운 애니메이션 적용

// JavaScript로 특정 아이템으로 스크롤
document.querySelector('.item:nth-child(3)').scrollIntoView({
  behavior: 'smooth', // scroll-behavior: smooth와 동일
  block: 'nearest',
  inline: 'start'
});

2. scroll-margin으로 간격 조정

.snap-container {
  scroll-snap-type: x mandatory;
  overflow-x: auto;
  padding: 0 20px;
}

.snap-item {
  scroll-snap-align: start;
  scroll-margin-left: 20px; /* 왼쪽 여백만큼 띄워서 스냅 */
}

효과: 스냅 위치를 미세 조정하여 시각적으로 균형잡힌 배치

3. 접근성 고려

/* 키보드 네비게이션 지원 */
.accessible-snap {
  scroll-snap-type: x mandatory;
  overflow-x: auto;
}

.accessible-snap:focus-within {
  outline: 2px solid blue;
  outline-offset: 4px;
}

.snap-item:focus {
  outline: 2px solid blue;
  outline-offset: 2px;
}

HTML에 tab index 추가:

<div class="accessible-snap" tabindex="0">
  <div class="snap-item" tabindex="0">아이템 1</div>
  <div class="snap-item" tabindex="0">아이템 2</div>
  <div class="snap-item" tabindex="0">아이템 3</div>
</div>

4. 반응형 스냅 포인트

.responsive-snap {
  scroll-snap-type: x mandatory;
  overflow-x: auto;
  display: flex;
  gap: 16px;
}

.item {
  scroll-snap-align: start;

  /* 모바일: 전체 너비 */
  flex: 0 0 100%;
}

/* 태블릿: 2개씩 */
@media (min-width: 768px) {
  .item {
    flex: 0 0 calc(50% - 8px);
  }
}

/* 데스크톱: 3개씩 */
@media (min-width: 1024px) {
  .item {
    flex: 0 0 calc(33.333% - 11px);
  }
}

5. 인디케이터와 연동

<div class="carousel-wrapper">
  <div class="carousel" id="carousel">
    <div class="slide">슬라이드 1</div>
    <div class="slide">슬라이드 2</div>
    <div class="slide">슬라이드 3</div>
  </div>
  <div class="dots" id="dots">
    <button class="dot active" data-index="0"></button>
    <button class="dot" data-index="1"></button>
    <button class="dot" data-index="2"></button>
  </div>
</div>
.carousel {
  scroll-snap-type: x mandatory;
  overflow-x: auto;
  display: flex;
  scroll-behavior: smooth;
}

.slide {
  flex: 0 0 100%;
  scroll-snap-align: start;
}

.dots {
  display: flex;
  justify-content: center;
  gap: 8px;
  margin-top: 16px;
}

.dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  border: none;
  background: rgba(0, 0, 0, 0.2);
  cursor: pointer;
  transition: background 0.3s ease;
}

.dot.active {
  background: rgba(0, 0, 0, 0.8);
}
const carousel = document.getElementById('carousel');
const dots = document.querySelectorAll('.dot');

// 인디케이터 클릭 시 해당 슬라이드로 이동
dots.forEach(dot => {
  dot.addEventListener('click', () => {
    const index = parseInt(dot.dataset.index);
    const slide = carousel.children[index];
    slide.scrollIntoView({ behavior: 'smooth', inline: 'start' });
  });
});

// 스크롤 시 인디케이터 업데이트
carousel.addEventListener('scroll', () => {
  const scrollLeft = carousel.scrollLeft;
  const itemWidth = carousel.children[0].offsetWidth;
  const currentIndex = Math.round(scrollLeft / itemWidth);

  dots.forEach((dot, index) => {
    dot.classList.toggle('active', index === currentIndex);
  });
});

6. 모바일 최적화

.mobile-optimized {
  scroll-snap-type: x mandatory;
  overflow-x: auto;

  /* iOS 네이티브 스크롤 느낌 */
  -webkit-overflow-scrolling: touch;

  /* 스크롤바 숨기기 (모바일) */
  scrollbar-width: none;
  -ms-overflow-style: none;
}

.mobile-optimized::-webkit-scrollbar {
  display: none;
}

.mobile-optimized .item {
  scroll-snap-align: center; /* 모바일에서는 중앙 정렬이 자연스러움 */
  flex: 0 0 90%; /* 다음 아이템 살짝 보이기 */
}

7. 성능 최적화

.optimized-snap {
  scroll-snap-type: x mandatory;
  overflow-x: auto;

  /* GPU 가속 */
  transform: translateZ(0);
  will-change: scroll-position;
}

.optimized-snap .item {
  scroll-snap-align: start;

  /* 레이아웃 thrashing 방지 */
  contain: layout style paint;
}

8. scroll-snap-stop으로 강제 정지

.pagination-snap {
  scroll-snap-type: x mandatory;
  overflow-x: auto;
}

.page {
  scroll-snap-align: start;
  scroll-snap-stop: always; /* 각 페이지에서 반드시 멈춤 */
}

효과: 빠르게 스크롤해도 중간 아이템들을 건너뛰지 않음

참고 자료

공식 문서

브라우저 호환성

접근성 가이드라인

결론

scroll-snap-type은 JavaScript 없이도 네이티브 앱 수준의 스크롤 경험을 만들 수 있는 강력한 CSS 속성입니다.

핵심 요점:

  1. 필수 조합: scroll-snap-type (부모) + scroll-snap-align (자식)
  2. x mandatory: 가로 스크롤에서 강제 스냅 (가장 흔한 패턴)
  3. overflow 필수: overflow: auto 또는 scroll이 반드시 필요
  4. mandatory vs proximity: 사용 목적에 따라 선택
  5. 접근성: 키보드 네비게이션과 스크린리더 고려
  6. 모바일 최적화: -webkit-overflow-scrolling: touch 추가

언제 사용하나요?

  • 이미지 갤러리, 캐러셀
  • 제품 카드 슬라이더
  • 스토리 뷰어
  • 풀페이지 스크롤
  • 탭 네비게이션

언제 피하나요?

  • 긴 텍스트 콘텐츠
  • 자유로운 탐색이 필요한 경우
  • 연속적인 데이터 피드

CSS Scroll Snap을 적절히 활용하면, 성능과 사용자 경험 모두를 향상시킬 수 있습니다. 직접 사용해보며 여러분의 프로젝트에 맞는 최적의 설정을 찾아보세요!

댓글