스크롤 가능성 어포던스 힌트

개요

그림자나 그라데이션과 같은 시각적 힌트는 사용자가 스크롤하여 더 많은 콘텐츠를 볼 수 있음을 이해하는 데 도움이 됩니다. 이 가이드에서는 CSS container-scroll-state-queries를 사용하여 이러한 힌트를 빌드하는 방법을 보여줍니다. 이를 통해 JavaScript 스크롤 리스너나 관찰자에 의존하지 않고 컨테이너의 스크롤 가능한 상태에 따라 요소의 스타일을 지정할 수 있습니다.

구현

1. 스크롤 컨테이너 설정

스크롤 컨테이너는 스크롤 상태 쿼리 컨테이너로 선언해야 합니다.

.scroller {
  overflow-y: auto;
  /* Establish this element as a scroll-state query container */
  container-type: scroll-state;
  position: relative;
}

2. 표시기 스타일 지정

컨테이너 내부에 표시기 요소 (예: 그림자, 그라데이션, 화살표)를 배치하고 스타일을 지정합니다. 기본적으로 표시되지 않아야 합니다. 표시될 때 pointer-events: none를 설정하여 상호작용이 불가능해야 합니다.

.indicator-top, .indicator-bottom {
  position: sticky;
  left: 0;
  right: 0;
  height: 20px;
  opacity: 0;
  transition: opacity 0.2s;
  pointer-events: none; /* Let clicks pass through */
}

.indicator-top {
  top: 0;
  background: linear-gradient(to bottom, rgba(0,0,0,0.2), transparent); /* Example: Shadow */
}

.indicator-bottom {
  bottom: 0;
  background: linear-gradient(to top, rgba(0,0,0,0.2), transparent); /* Example: Shadow */
}

3. 스크롤 상태 쿼리

scroll-state 함수와 함께 @container 규칙을 사용합니다. 컨테이너를 위 또는 아래로 스크롤하여 각 표시기를 표시할 수 있는지 확인합니다.

/* Show top indicator when the user can scroll up */
@container scroll-state(scrollable: top) {
  .indicator-top {
    opacity: 1;
  }
}

/* Show bottom indicator when the user can scroll down */
@container scroll-state(scrollable: bottom) {
  .indicator-bottom {
    opacity: 1;
  }
}

대체 전략

기본 대체

이 기능이 지원되지 않으면 표시기가 표시되지 않습니다. 이러한 힌트는 기능에 중요하지 않으므로 지원되지 않는 브라우저에서는 생략해도 됩니다.

고급 대체 (Intersection Observer)

힌트가 필요한 경우 IntersectionObserver를 사용하여 스크롤러 상단과 하단의 센티널 요소가 스크롤포트 안팎으로 이동할 때 클래스를 전환합니다.

<!-- Sentinel elements placed at the ends of the scroller -->
<div class="sentinel-top"></div>
<!-- Content goes here -->
<div class="sentinel-bottom"></div>
/* Marker styling to ensure it does not affect layout */
.sentinel-top, .sentinel-bottom {
  height: 0;
  width: 0;
  visibility: hidden;
}

.scroller.scrolled-down .indicator-top {
  opacity: 1;
}

.scroller.can-scroll-down .indicator-bottom {
  opacity: 1;
}
if (!CSS.supports('container-type', 'scroll-state')) {
  const topSentinel = document.querySelector('.sentinel-top');
  const bottomSentinel = document.querySelector('.sentinel-bottom');
  const scroller = document.querySelector('.scroller');

  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.target === topSentinel) {
        // If top sentinel is not intersecting, we have scrolled down
        scroller.classList.toggle('scrolled-down', !entry.isIntersecting);
      }
      if (entry.target === bottomSentinel) {
        // If bottom sentinel is intersecting, we reached the bottom
        scroller.classList.toggle('can-scroll-down', !entry.isIntersecting);
      }
    });
  }, { root: scroller });

  observer.observe(topSentinel);
  observer.observe(bottomSentinel);
}