CSS 심층 분석 - 완벽한 프레임의 맞춤 스크롤바를 위한 trix3d()

맞춤 스크롤바는 매우 드뭅니다. 이는 스크롤바가 웹에서 스타일을 지정할 수 없는 거의 유일한 요소 중 하나이기 때문입니다 (날짜 선택 도구를 예로 들 수 있음). JavaScript를 사용하여 직접 빌드할 수도 있지만 비용이 많이 들고, 화질이 낮으며, 지연이 발생할 수 있습니다. 이 도움말에서는 몇 가지 색다른 CSS 행렬을 활용하여 스크롤하는 동안 JavaScript가 아닌 설정 코드만 필요한 맞춤 스크롤러를 빌드합니다.

요약

사소한 일에 신경 쓰지 않나요? Nyan cat 데모를 보고 라이브러리를 가져오고 싶으신가요? 데모 코드는 GitHub 저장소에서 확인할 수 있습니다.

LAM;WRA (길고 수학적이며 어쨌든 읽음)

얼마 전에 우리는 시차 스크롤러를 빌드했습니다. 이 도움말을 읽어 보셨나요? 시간을 내어 확인해 보세요. CSS 3D 변환을 사용하여 요소를 뒤로 푸시하면 요소가 실제 스크롤 속도보다 느리게 이동했습니다.

요약

먼저 시차 스크롤러의 작동 방식을 요약해 보겠습니다.

애니메이션에서 볼 수 있듯이 Z축을 따라 3D 공간에서 요소를 '뒤로' 밀어서 시차 효과를 얻었습니다. 문서를 스크롤하는 것은 사실상 Y축을 따라 이동하는 것입니다. 따라서 예를 들어 100px 아래로 스크롤하면 모든 요소가 100px 위로 변환됩니다. 이는 '더 뒤에 있는' 요소를 포함한 모든 요소에 적용됩니다. 하지만 카메라에서 더 멀리 있기 때문에 화면에서 관찰되는 움직임은 100px 미만이 되어 원하는 시차 효과를 얻을 수 있습니다.

물론 공간에서 요소를 뒤로 이동하면 더 작게 표시되며, 요소의 크기를 다시 조정하여 이를 수정합니다. 시차 스크롤러를 빌드할 때 정확한 수학을 알아냈으므로 모든 세부정보를 반복하지는 않겠습니다.

0단계: 무엇을 할 것인가요?

스크롤바 이 모델을 빌드할 것입니다. 하지만 그들이 하는 일을 진지하게 생각해 본 적이 있나요? 물론 아닙니다. 스크롤바는 현재 표시되는 사용 가능한 콘텐츠의 과 독자가 읽은 진행률을 나타내는 지표입니다. 아래로 스크롤하면 스크롤바도 아래로 이동하여 끝까지 진행 중임을 나타냅니다. 모든 콘텐츠가 표시 영역에 들어맞으면 일반적으로 스크롤바가 숨겨집니다. 콘텐츠의 높이가 뷰포트의 2배인 경우 스크롤바가 뷰포트 높이의 절반을 채웁니다. 뷰포트 높이의 3배에 해당하는 콘텐츠는 스크롤바를 뷰포트의 1/3로 조정합니다. 패턴을 확인할 수 있습니다. 스크롤하는 대신 스크롤바를 클릭하고 드래그하여 사이트를 더 빠르게 이동할 수도 있습니다. 이와 같이 눈에 잘 띄지 않는 요소에 놀라운 양의 동작이 있습니다. 한 번에 하나씩 해결해 보겠습니다.

1단계: 역전하기

이제 시차 스크롤 도움말에 설명된 대로 CSS 3D 변환을 사용하여 요소가 스크롤 속도보다 느리게 움직이도록 할 수 있습니다. 방향을 반대로 할 수도 있나요? 실제로 가능하며, 이를 통해 프레임이 완벽한 맞춤 스크롤바를 빌드할 수 있습니다. 작동 방식을 이해하려면 먼저 몇 가지 CSS 3D 기본사항을 알아야 합니다.

수학적 의미에서 어떤 종류의 원근 투영이든 얻으려면 유니폼 좌표를 사용하는 것이 가장 좋습니다. 쿼리 좌표가 무엇이고 왜 작동하는지 자세히 설명하지는 않겠지만, w라는 추가 4번째 좌표가 있는 3D 좌표라고 생각하면 됩니다. 원근 왜곡을 적용하려는 경우가 아니라면 이 좌표는 1이어야 합니다. 1이 아닌 다른 값을 사용하지 않으므로 w의 세부정보에 관해 걱정할 필요가 없습니다. 따라서 이제 모든 점은 4차원 벡터[x, y, z, w=1] 이므로 행렬도 4x4여야 합니다.

CSS가 내부적으로 균질 좌표를 사용하는 것을 볼 수 있는 한 가지 경우는 matrix3d() 함수를 사용하여 변환 속성에서 자체 4x4 행렬을 정의하는 경우입니다. matrix3d는 행렬이 4x4이므로 16개의 인수를 사용하여 열을 차례로 지정합니다. 따라서 이 함수를 사용하여 회전, 변환 등을 수동으로 지정할 수 있습니다. 하지만 이 함수를 사용하면 w 좌표를 조작할 수도 있습니다.

matrix3d()를 사용하려면 먼저 3D 컨텍스트가 필요합니다. 3D 컨텍스트가 없으면 원근 왜곡이 발생하지 않으며, 정규 좌표가 필요하지 않기 때문입니다. 3D 컨텍스트를 만들려면 perspective가 포함된 컨테이너와 새로 만든 3D 공간에서 변환할 수 있는 몇 가지 요소가 필요합니다. :

CSS의 원근 속성을 사용하여 div를 왜곡하는 CSS 코드입니다.

원근 컨테이너 내의 요소는 CSS 엔진에서 다음과 같이 처리됩니다.

  • 요소의 각 모서리 (꼭짓점)를 원근 컨테이너를 기준으로 한 균질 좌표 [x,y,z,w]로 변환합니다.
  • 모든 요소의 변환을 오른쪽에서 왼쪽으로 행렬로 적용합니다.
  • 원근감 요소를 스크롤할 수 있는 경우 스크롤 매트릭스를 적용합니다.
  • 원근 매트릭스를 적용합니다.

스크롤 행렬은 y축을 따라 이동하는 행렬입니다. 400픽셀 아래로 스크롤하면 모든 요소를 400픽셀 위로 이동해야 합니다. 원근 행렬은 3D 공간에서 점의 거리가 멀수록 소실점에 점점 더 가까워지도록 '당기는' 행렬입니다. 이렇게 하면 물체가 더 멀리 있을 때 더 작게 보이고 변환될 때 '더 느리게 움직이는' 두 가지 효과를 모두 얻을 수 있습니다. 따라서 요소가 뒤로 푸시되면 400픽셀의 변환으로 인해 요소가 화면에서 300픽셀만 이동합니다.

세부정보를 모두 알고 싶다면 CSS의 변환 렌더링 모델에 관한 사양을 읽어야 하지만 이 도움말에서는 위의 알고리즘을 단순화했습니다.

상자는 perspective 속성 값이 p인 원근 컨테이너 내에 있으며 컨테이너는 스크롤 가능하며 n픽셀 아래로 스크롤된다고 가정해 보겠습니다.

원근 행렬과 스크롤 행렬과 요소 변환 행렬의 곱은 4x4 단위 행렬(네 번째 행 세 번째 열에 -1/p)과 4x4 단위 행렬(두 번째 행 네 번째 열에 -n)과 요소 변환 행렬의 곱과 같습니다.

첫 번째 행렬은 원근 행렬이고 두 번째 행렬은 스크롤 행렬입니다. 요약하면 스크롤 매트릭스의 역할은 아래로 스크롤할 때 요소를 위로 이동시키는 것이므로 음의 부호가 사용됩니다.

그러나 스크롤바의 경우 반대가 필요합니다. 즉, 아래로 스크롤할 때 요소가 아래로 이동해야 합니다. 여기서 트릭을 사용할 수 있습니다. 상자 모서리의 w 좌표를 반전합니다. w 좌표가 -1이면 모든 변환이 반대 방향으로 적용됩니다. 그렇다면 어떻게 해야 할까요? CSS 엔진은 상자의 모서리를 균질 좌표로 변환하고 w를 1로 설정합니다. matrix3d()가 빛을 발할 때입니다.

.box {
  transform:
    matrix3d(
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, -1
    );
}

이 행렬은 w를 음수화하는 것 외에는 아무것도 하지 않습니다. 따라서 CSS 엔진이 각 모서리를 [x,y,z,1] 형식의 벡터로 변환하면 행렬은 이를 [x,y,z,-1]로 변환합니다.

네 번째 행 세 번째 열에 p를 1로 나눈 값에서 1을 뺀 4x4 정사각형 행렬을 두 번째 행 네 번째 열에 n을 1로 나눈 값에서 1을 뺀 4x4 정사각형 행렬로 곱한 다음 네 번째 행 네 번째 열에 1을 뺀 4x4 정사각형 행렬을 4차원 벡터 x, y, z, 1로 곱하면 네 번째 행 세 번째 열에 p를 1로 나눈 값에서 1을 뺀 4x4 정사각형 행렬, 두 번째 행 네 번째 열에 n을 1로 나눈 값에서 1을 뺀 4x4 정사각형 행렬, 네 번째 행 네 번째 열에 1을 뺀 4x4 정사각형 행렬과 같으며, 이는 4차원 벡터 x, y + n, z, p - 1에서 z를 뺀 값과 같습니다.

요소 변환 행렬의 효과를 보여주는 중간 단계를 나열했습니다. 행렬 수학에 익숙하지 않아도 괜찮습니다. 중요한 점은 마지막 줄에서 스크롤 오프셋 n을 y 좌표에서 빼는 대신 더한다는 것입니다. 아래로 스크롤하면 요소가 아래로 이동합니다.

하지만 이 행렬을 에 넣기만 하면 요소가 표시되지 않습니다. CSS 사양에 따라 w가 0보다 작은 모든 정점이 요소가 렌더링되는 것을 차단해야 하기 때문입니다. z 좌표가 현재 0이고 p가 1이므로 w는 -1입니다.

다행히 z 값을 선택할 수 있습니다. w=1이 되도록 하려면 z = -2를 설정해야 합니다.

.box {
  transform:
    matrix3d(
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, -1
    )
    translateZ(-2px);
}

드디어 상자가 다시 돌아왔습니다.

2단계: 움직이게 만들기

이제 상자가 표시되고 변환 없이 표시되는 것과 동일하게 보입니다. 현재 원근 컨테이너는 스크롤할 수 없으므로 볼 수 없지만 스크롤하면 요소가 다른 방향으로 이동한다는 것을 알 수 있습니다. 컨테이너를 스크롤해 보겠습니다. 공간을 차지하는 스페이서 요소를 추가하면 됩니다.

<div class="container">
    <div class="box"></div>
    <span class="spacer"></span>
</div>

<style>
/* … all the styles from the previous example … */
.container {
    overflow: scroll;
}
.spacer {
    display: block;
    height: 500px;
}
</style>

이제 상자를 스크롤합니다. 빨간색 상자가 아래로 이동합니다.

3단계: 크기 지정

페이지를 아래로 스크롤하면 아래로 이동하는 요소가 있습니다. 이제 어려운 부분은 해결되었습니다. 이제 스크롤바처럼 보이도록 스타일을 지정하고 상호작용을 강화해야 합니다.

스크롤바는 일반적으로 'thumb'과 'track'으로 구성되지만 트랙은 항상 표시되지는 않습니다. 썸네일의 높이는 표시되는 콘텐츠의 양에 정비례합니다.

<script>
    const scroller = document.querySelector('.container');
    const thumb = document.querySelector('.box');
    const scrollerHeight = scroller.getBoundingClientRect().height;
    thumb.style.height = /* ??? */;
</script>

scrollerHeight은 스크롤 가능한 요소의 높이이고 scroller.scrollHeight은 스크롤 가능한 콘텐츠의 총 높이입니다. scrollerHeight/scroller.scrollHeight는 표시되는 콘텐츠의 비율입니다. 썸네일이 차지하는 수직 공간의 비율은 표시되는 콘텐츠의 비율과 같아야 합니다.

엄지손가락 점 스타일 점 높이가 스크롤러 높이에 대한 스크롤러 점 스크롤 높이와 같으면 엄지손가락 점 스타일 점 높이가 스크롤러 높이에 대한 스크롤러 높이 곱하기 스크롤러 점 스크롤 높이와 같을 때만 스크롤러 높이에 대한 엄지손가락 점 스타일 점 높이가 스크롤러 점 스크롤 높이와 같습니다.
<script>
    // …
    thumb.style.height =
    scrollerHeight * scrollerHeight / scroller.scrollHeight + 'px';
    // Accommodate for native scrollbars
    thumb.style.right =
    (scroller.clientWidth - scroller.getBoundingClientRect().width) + 'px';
</script>

썸네일 크기는 좋게 보이지만 너무 빠르게 움직입니다. 여기에서 시차 스크롤러에서 기법을 가져올 수 있습니다. 요소를 더 뒤로 이동하면 스크롤하는 동안 더 느리게 이동합니다. 크기를 조정하여 수정할 수 있습니다. 하지만 정확히 얼마나 뒤로 밀어야 하나요? 계산을 해 보겠습니다. 마지막입니다. 약속합니다.

중요한 정보는 스크롤이 완전히 아래로 내려갔을 때 엄지손가락의 하단 가장자리가 스크롤 가능한 요소의 하단 가장자리와 정렬되도록 해야 한다는 것입니다. 즉, scroller.scrollHeight - scroller.height픽셀을 스크롤했다면 엄지손가락이 scroller.height - thumb.height만큼 변환되어야 합니다. 스크롤러의 모든 픽셀에 대해 엄지손가락이 픽셀의 일부를 움직이도록 합니다.

계수는 스크롤러 점 높이에서 엄지손가락 점 높이를 뺀 값을 스크롤러 점 스크롤 높이에서 스크롤러 점 높이를 뺀 값으로 나눈 값입니다.

이것이 Google의 확장 계수입니다. 이제 배율을 z축을 따라 이동하는 것으로 변환해야 합니다. 이는 이미 시차 스크롤 도움말에서 다뤘습니다. 사양의 관련 섹션에 따르면 배율 인수는 p/(p − z)와 같습니다. 이 방정식에서 z를 풀면 z축을 따라 엄지를 이동해야 하는 거리를 알 수 있습니다. 그러나 w 좌표의 꼼수로 인해 z를 따라 -2px를 추가로 변환해야 합니다. 또한 요소의 변환은 오른쪽에서 왼쪽으로 적용됩니다. 즉, 특수 행렬 앞에 있는 모든 변환은 반전되지 않지만 특수 행렬 뒤의 모든 변환은 반전됩니다. 이를 코딩해 보겠습니다.

<script>
    // ... code from above...
    const factor =
    (scrollerHeight - thumbHeight)/(scroller.scrollHeight - scrollerHeight);
    thumb.style.transform = `
    matrix3d(
        1, 0, 0, 0,
        0, 1, 0, 0,
        0, 0, 1, 0,
        0, 0, 0, -1
    )
    scale(${1/factor})
    translateZ(${1 - 1/factor}px)
    translateZ(-2px)
    `;
</script>

스크롤바가 있습니다. 원하는 대로 스타일을 지정할 수 있는 DOM 요소일 뿐입니다. 접근성 측면에서 중요한 한 가지는 많은 사용자가 스크롤바와 상호작용하는 데 익숙하므로 클릭 및 드래그에 반응하도록 엄지손가락을 만드는 것입니다. 이 블로그 게시물이 더 길어지지 않도록 이 부분에 관한 세부정보는 설명하지 않겠습니다. 자세한 내용은 라이브러리 코드를 참고하세요.

iOS는 어떨까요?

오랜 친구 iOS Safari. 시차 스크롤과 마찬가지로 여기에서 문제가 발생합니다. 요소에서 스크롤하므로 -webkit-overflow-scrolling: touch를 지정해야 하지만 그러면 3D 평탄화가 발생하고 전체 스크롤 효과가 작동하지 않습니다. 우리는 iOS Safari를 감지하고 position: sticky를 해결 방법으로 사용하여 이 문제를 시차 스크롤러에서 해결했으며 여기서도 똑같이 할 것입니다. 시차 도움말을 참고하여 기억을 되살려 보세요.

브라우저 스크롤바는 어떨까요?

일부 시스템에서는 영구적인 네이티브 스크롤바를 처리해야 합니다. 이전에는 비표준 가상 선택자를 제외하고 스크롤바를 숨길 수 없었습니다. 따라서 이를 숨기려면 (수학이 없는) 해킹에 의존해야 합니다. overflow-x: hidden를 사용하여 스크롤 요소를 컨테이너에 래핑하고 스크롤 요소를 컨테이너보다 넓게 만듭니다. 이제 브라우저의 기본 스크롤바가 표시되지 않습니다.

이제 모든 것을 종합하여 Nyan cat 데모와 같은 프레임 완벽한 맞춤 스크롤바를 빌드할 수 있습니다.

Nyan Cat이 표시되지 않으면 이 데모를 빌드하는 동안 Google에서 발견하여 신고한 버그가 발생한 것입니다 (썸네일을 클릭하면 Nyan Cat이 표시됨). Chrome은 화면 밖에 있는 항목을 페인트하거나 애니메이션 처리하는 등 불필요한 작업을 방지하는 데 매우 능숙합니다. 안타까운 점은 매트릭스 조작으로 인해 Chrome에서 Nyan Cat GIF가 실제로 화면 밖에 있다고 생각한다는 것입니다. 곧 해결될 수 있기를 바랍니다.

완료되었습니다. 많은 작업이었습니다. 전체 내용을 읽어 주셔서 감사합니다. 이렇게 하려면 상당히 교묘한 방법을 사용해야 하며 맞춤 스크롤바가 환경의 필수적인 부분이 아닌 한 그럴 만한 가치가 거의 없습니다. 하지만 가능하다는 사실을 알고 있으면 좋겠죠? 맞춤 스크롤바를 구현하기가 이렇게 어렵다는 사실은 CSS 측에서 해야 할 일이 있음을 보여줍니다. 하지만 걱정하지 마세요. 향후 HoudiniAnimationWorklet을 사용하면 이와 같이 프레임 정확한 스크롤 연결 효과를 훨씬 쉽게 만들 수 있습니다.