Chrometober 빌드

이번 Chrometober에 재미있고 무서운 팁과 트릭을 공유하기 위해 스크롤북이 어떻게 탄생했는지 알아보세요.

Google은 Designcember에 이어 올해는 커뮤니티와 Chrome팀의 웹 콘텐츠를 강조하고 공유할 수 있는 Chrometober를 마련하고자 합니다. Designcember에서는 컨테이너 쿼리의 사용을 선보였지만 올해는 CSS 스크롤 연결 애니메이션 API를 선보입니다.

web.dev/chrometober-2022에서 스크롤 도서 환경을 확인하세요.

개요

이 프로젝트의 목표는 스크롤 연결 애니메이션 API를 강조하는 기발한 환경을 제공하는 것이었습니다. 하지만 기발하면서도 반응이 빠르고 액세스하기 쉬워야 했습니다. 이 프로젝트는 활발하게 개발 중인 API 폴리필을 테스트하고 다양한 기법과 도구를 조합하여 시도해 볼 수 있는 좋은 방법이기도 합니다. 모두 핼러윈 테마로 진행됩니다.

팀 구조는 다음과 같습니다.

스크롤텔링 환경 초안 작성

Chrometober에 대한 아이디어는 2022년 5월에 열린 첫 번째 팀 오프사이트에서 시작되었습니다. 스케치 모음을 통해 사용자가 어떤 형태의 스토리보드로 스크롤할 수 있는지 생각해 보았습니다. 비디오 게임에서 영감을 얻어 묘지, 으스스한 집과 같은 장면을 통한 스크롤 환경을 고려했습니다.

책상 위에 프로젝트와 관련된 다양한 낙서가 있는 공책이 놓여 있습니다.

첫 번째 Google 프로젝트를 예상치 못한 방향으로 이끌 수 있는 창의적인 자유를 누릴 수 있어 기뻤습니다. 사용자가 콘텐츠를 탐색하는 방법을 보여주는 초기 프로토타입입니다.

사용자가 옆으로 스크롤하면 블록이 회전하고 크기가 조절됩니다. 하지만 모든 크기의 기기에서 사용자에게 훌륭한 환경을 제공할 수 있는 방법을 고민한 결과 이 아이디어를 접기로 했습니다. 대신 이전에 만든 디자인을 사용했습니다. 2020년에 GreenSock의 ScrollTrigger를 사용하여 출시 데모를 빌드할 수 있었습니다.

제가 만든 데모 중 하나는 스크롤할 때 페이지가 전환되는 3D CSS 책이었는데, 이 책이 Chrometober에 우리가 원하는 바를 더 잘 표현하는 것 같았습니다. 스크롤 연결 애니메이션 API는 이 기능을 대체하는 데 적합합니다. scroll-snap와도 잘 작동합니다.

프로젝트의 일러스트레이터인 타일러 리드는 아이디어가 바뀔 때마다 디자인을 잘 변경해 주었습니다. 타일러는 주어진 모든 창의적인 아이디어를 실현하는 데 탁월한 역할을 했습니다. 함께 아이디어를 브레인스토밍하는 것은 정말 즐거웠습니다. 이 기능을 작동시키기 위해 가장 중요한 부분은 기능을 격리된 블록으로 분할하는 것이었습니다. 이렇게 하면 장면을 구성한 후 실행할 장면을 선택할 수 있습니다.

뱀, 팔이 튀어나온 관, 가마에 지팡이를 든 여우, 소름 끼치는 얼굴이 있는 나무, 호박 랜턴을 들고 있는 가고일 등이 등장하는 구성 장면 중 하나입니다.

기본 아이디어는 사용자가 책을 읽으면서 콘텐츠 블록에 액세스할 수 있다는 것이었습니다. 또한 환경에 빌드된 이스터 에그를 비롯한 기발한 요소와 상호작용할 수도 있습니다. 예를 들어 귀신의 집에 있는 초상화의 눈이 사용자의 포인터를 따라가거나 미디어 쿼리로 트리거되는 미묘한 애니메이션이 있습니다. 이러한 아이디어와 기능은 스크롤 시 애니메이션이 적용됩니다. 초기 아이디어는 사용자가 스크롤할 때 x축을 따라 올라가고 이동하는 좀비 토끼였습니다.

API 숙지하기

개별 기능과 이스터 에그를 사용하기 전에 책이 필요했습니다. 이에 Google은 이 기회를 빌어 신규 CSS scroll-linked animations API의 기능 세트를 테스트하기로 했습니다. 스크롤 연결 애니메이션 API는 현재 어떤 브라우저에서도 지원되지 않습니다. 하지만 API를 개발하는 동안 상호작용팀의 엔지니어들은 폴리필을 개발하고 있었습니다. 이를 통해 API가 개발되는 동안 API의 형식을 테스트할 수 있습니다. 즉, 지금 이 API를 사용할 수 있으며, 이와 같은 재미있는 프로젝트는 실험용 기능을 사용해 보고 의견을 제공하기에 좋은 장소입니다. 도움말 뒷부분에서 확인할 수 있습니다.

대략적으로 이 API를 사용하여 애니메이션을 스크롤에 연결할 수 있습니다. 스크롤 시 애니메이션을 트리거할 수는 없습니다. 나중에 트리거할 수 있습니다. 스크롤 연결 애니메이션도 크게 두 가지 카테고리로 나뉩니다.

  1. 스크롤 위치에 반응하는 요소입니다.
  2. 스크롤 컨테이너 내의 요소 위치에 반응하는 애니메이션입니다.

후자를 만들려면 animation-timeline 속성을 통해 적용된 ViewTimeline를 사용합니다.

다음은 CSS에서 ViewTimeline를 사용하는 예입니다.

.element-moving-in-viewport {
  view-timeline-name: foo;
  view-timeline-axis: block;
}

.element-scroll-linked {
  animation: rotate both linear;
  animation-timeline: foo;
  animation-delay: enter 0%;
  animation-end-delay: cover 50%;
}

@keyframes rotate {
 to {
   rotate: 360deg;
 }
}

view-timeline-nameViewTimeline를 만들고 축을 정의합니다. 이 예에서 block논리적 block를 나타냅니다. 애니메이션은 animation-timeline 속성으로 스크롤에 연결됩니다. animation-delayanimation-end-delay (작성 시점)는 단계를 정의하는 방법입니다.

이러한 단계는 스크롤 컨테이너의 요소 위치와 관련하여 애니메이션이 연결되어야 하는 지점을 정의합니다. 이 예에서는 요소가 스크롤 컨테이너에 진입 (enter 0%)할 때 애니메이션을 시작합니다. 스크롤 컨테이너의 50% (cover 50%)를 덮으면 완료됩니다.

다음은 데모의 작동 방식입니다.

뷰포트에서 움직이는 요소에 애니메이션을 연결할 수도 있습니다. animation-timeline를 요소의 view-timeline로 설정하면 됩니다. 이는 목록 애니메이션과 같은 시나리오에 적합합니다. 이 동작은 IntersectionObserver를 사용하여 진입 시 요소에 애니메이션을 적용하는 방법과 유사합니다.

element-moving-in-viewport {
  view-timeline-name: foo;
  view-timeline-axis: block;
  animation: scale both linear;
  animation-delay: enter 0%;
  animation-end-delay: cover 50%;
  animation-timeline: foo;
}

@keyframes scale {
  0% {
    scale: 0;
  }
}

이렇게 하면 '이동기'가 표시 영역에 들어갈 때 크기가 조정되어 '스피너'의 회전을 트리거합니다.

실험을 통해 API가 scroll-snap과 매우 잘 작동한다는 사실을 확인했습니다. 스크롤 스냅을 ViewTimeline와 결합하면 책에서 페이지를 넘기는 데 적합합니다.

메커니즘 프로토타입 제작

몇 가지 실험 끝에 책 프로토타입을 작동시킬 수 있었습니다. 가로로 스크롤하여 책의 페이지를 넘깁니다.

데모에서는 다양한 트리거가 점선 테두리로 강조 표시되어 있습니다.

마크업은 다음과 같이 표시됩니다.

<body>
  <div class="book-placeholder">
    <ul class="book" style="--count: 7;">
      <li
        class="page page--cover page--cover-front"
        data-scroll-target="1"
        style="--index: 0;"
      >
        <div class="page__paper">
          <div class="page__side page__side--front"></div>
          <div class="page__side page__side--back"></div>
        </div>
      </li>
      <!-- Markup for other pages here -->
    </ul>
  </div>
  <div>
    <p>intro spacer</p>
  </div>
  <div data-scroll-intro>
    <p>scale trigger</p>
  </div>
  <div data-scroll-trigger="1">
    <p>page trigger</p>
  </div>
  <!-- Markup for other triggers here -->
</body>

스크롤하면 책의 페이지가 전환되지만 갑자기 열리거나 닫힙니다. 이는 트리거의 스크롤 스냅 정렬에 따라 다릅니다.

html {
  scroll-snap-type: x mandatory;
}

body {
  grid-template-columns: repeat(var(--trigger-count), auto);
  overflow-y: hidden;
  overflow-x: scroll;
  display: grid;
}

body > [data-scroll-trigger] {
  height: 100vh;
  width: clamp(10rem, 10vw, 300px);
}

body > [data-scroll-trigger] {
  scroll-snap-align: end;
}

이번에는 CSS에서 ViewTimeline를 연결하지 않고 JavaScript에서 Web Animations API를 사용합니다. 이렇게 하면 요소 세트를 반복하고 각각 수동으로 만드는 대신 필요한 ViewTimeline를 생성할 수 있다는 추가 이점이 있습니다.

const triggers = document.querySelectorAll("[data-scroll-trigger]")

const commonProps = {
  delay: { phase: "enter", percent: CSS.percent(0) },
  endDelay: { phase: "enter", percent: CSS.percent(100) },
  fill: "both"
}

const setupPage = (trigger, index) => {
  const target = document.querySelector(
    `[data-scroll-target="${trigger.getAttribute("data-scroll-trigger")}"]`
  );

  const viewTimeline = new ViewTimeline({
    subject: trigger,
    axis: 'inline',
  });

  target.animate(
    [
      {
        transform: `translateZ(${(triggers.length - index) * 2}px)`
      },
      {
        transform: `translateZ(${(triggers.length - index) * 2}px)`,
        offset: 0.75
      },
      {
        transform: `translateZ(${(triggers.length - index) * -1}px)`
      }
    ],
    {
      timeline: viewTimeline,
      commonProps,
    }
  );
  target.querySelector(".page__paper").animate(
    [
      {
        transform: "rotateY(0deg)"
      },
      {
        transform: "rotateY(-180deg)"
      }
    ],
    {
      timeline: viewTimeline,
      commonProps,
    }
  );
};

const triggers = document.querySelectorAll('[data-scroll-trigger]')
triggers.forEach(setupPage);

트리거마다 ViewTimeline이 생성됩니다. 그런 다음 이 ViewTimeline를 사용하여 트리거의 연결된 페이지에 애니메이션을 적용합니다. 이렇게 하면 페이지의 애니메이션이 스크롤에 연결됩니다. 이 애니메이션에서는 페이지를 회전하기 위해 페이지의 요소를 y축으로 회전합니다. 또한 책처럼 작동하도록 z축에서 페이지 자체를 변환합니다.

요약 정리

책의 메커니즘을 완성한 후에는 타일러의 삽화를 실감 나게 표현하는 데 집중할 수 있었습니다.

Astro

팀에서는 2021년에 Designcember에 Astro를 사용했으며 저는 Chrometober에 다시 사용하고 싶었습니다. 구성요소로 분할할 수 있는 개발자 환경은 이 프로젝트에 적합합니다.

도서 자체가 구성요소입니다. 페이지 구성요소의 모음입니다. 각 페이지에는 두 면이 있으며 배경이 있습니다. 페이지 측면의 하위 요소는 쉽게 추가, 삭제, 배치할 수 있는 구성요소입니다.

사진첩 만들기

차단을 쉽게 관리할 수 있도록 하는 것이 중요했습니다. 또한 다른 팀원들이 쉽게 참여할 수 있도록 하고 싶었습니다.

대략적인 페이지는 구성 배열로 정의됩니다. 배열의 각 페이지 객체는 페이지의 콘텐츠, 배경 및 기타 메타데이터를 정의합니다.

const pages = [
  {
    front: {
      marked: true,
      content: PageTwo,
      backdrop: spreadOne,
      darkBackdrop: spreadOneDark
    },
    back: {
      content: PageThree,
      backdrop: spreadTwo,
      darkBackdrop: spreadTwoDark
    },
    aria: `page 1`
  },
  /* Obfuscated page objects */
]

이러한 항목은 Book 구성요소에 전달됩니다.

<Book pages={pages} />

Book 구성요소는 스크롤 메커니즘이 적용되고 책의 페이지가 생성되는 곳입니다. 프로토타입의 동일한 메커니즘이 사용되지만 전역에서 생성된 여러 개의 ViewTimeline 인스턴스를 공유합니다.

window.CHROMETOBER_TIMELINES.push(viewTimeline);

이렇게 하면 타임라인을 다시 만드는 대신 다른 곳에서 사용할 수 있도록 공유할 수 있습니다. 이건 나중에 다시 설명하죠

페이지 구성

각 페이지는 목록 내의 목록 항목입니다.

<ul class="book">
  {
    pages.map((page, index) => {
      const FrontSlot = page.front.content
      const BackSlot = page.back.content
      return (
        <Page
          index={index}
          cover={page.cover}
          aria={page.aria}
          backdrop={
            {
              front: {
                light: page.front.backdrop,
                dark: page.front.darkBackdrop
              },
              back: {
                light: page.back.backdrop,
                dark: page.back.darkBackdrop
              }
            }
          }>
          {page.front.content && <FrontSlot slot="front" />}    
          {page.back.content && <BackSlot slot="back" />}    
        </Page>
      )
    })
  }
</ul>

정의된 구성은 각 Page 인스턴스에 전달됩니다. 페이지는 Astro의 슬롯 기능을 사용하여 각 페이지에 콘텐츠를 삽입합니다.

<li
  class={className}
  data-scroll-target={target}
  style={`--index:${index};`}
  aria-label={aria}
>
  <div class="page__paper">
    <div
      class="page__side page__side--front"
      aria-label={`Right page of ${index}`}
    >
      <picture>
        <source
          srcset={darkFront}
          media="(prefers-color-scheme: dark)"
          height="214"
          width="150"
        >
        <img
          src={lightFront}
          class="page__background page__background--right"
          alt=""
          aria-hidden="true"
          height="214"
          width="150"
        >
      </picture>
      <div class="page__content">
        <slot name="front" />
      </div>
    </div>
    <!-- Markup for back page -->
  </div>
</li>

이 코드는 주로 구조를 설정하는 데 사용됩니다. 참여자는 이 코드를 건드리지 않고도 대부분의 책 콘텐츠를 작업할 수 있습니다.

백드롭

책으로의 창의적 전환으로 섹션을 훨씬 더 쉽게 분할할 수 있었으며, 책의 각 펼침은 원래 디자인에서 가져온 장면입니다.

묘지에 있는 사과나무가 그려진 책의 펼쳐진 페이지 그림 묘지에 비석이 여러 개 있고 큰 달 앞 하늘에 박쥐가 있습니다.

책의 가로세로 비율을 결정했으므로 각 페이지의 배경에는 그림 요소가 있을 수 있습니다. 이 요소의 너비를 200% 로 설정하고 페이지 측면에 따라 object-position를 사용하면 됩니다.

.page__background {
  height: 100%;
  width: 200%;
  object-fit: cover;
  object-position: 0 0;
  position: absolute;
  top: 0;
  left: 0;
}

.page__background--right {
  object-position: 100% 0;
}

페이지 콘텐츠

페이지 중 하나를 빌드해 보겠습니다. 3페이지에는 나무에 숨어 있다가 튀어나오는 올빼기가 있습니다.

구성에 정의된 대로 PageThree 구성요소로 채워집니다. Astro 구성요소 (PageThree.astro)입니다. 이러한 구성요소는 HTML 파일처럼 보이지만 상단에 프런트마터와 유사한 코드 울타리가 있습니다. 이를 통해 다른 구성요소를 가져오는 등의 작업을 할 수 있습니다. 3페이지의 구성요소는 다음과 같습니다.

---
import TreeOwl from '../TreeOwl/TreeOwl.astro'
import { contentBlocks } from '../../assets/content-blocks.json'
import ContentBlock from '../ContentBlock/ContentBlock.astro'
---
<TreeOwl/>
<ContentBlock {...contentBlocks[3]} id="four" />

<style is:global>
  .content-block--four {
    left: 30%;
    bottom: 10%;
  }
</style>

다시 한번 말씀드리지만 페이지는 본질적으로 원자적입니다. 지형지물 컬렉션으로 빌드됩니다. 3페이지에는 콘텐츠 블록과 양방향 올빼기가 있으므로 각각의 구성요소가 있습니다.

콘텐츠 블록은 책 내에서 표시되는 콘텐츠 링크입니다. 이는 구성 객체에 의해 구동됩니다.

{
 "contentBlocks": [
    {
      "id": "one",
      "title": "New in Chrome",
      "blurb": "Lift your spirits with a round up of all the tools and features in Chrome.",
      "link": "https://www.youtube.com/watch?v=qwdN1fJA_d8&list=PLNYkxOF6rcIDfz8XEA3loxY32tYh7CI3m"
    },
    …otherBlocks
  ]
}

이 구성은 콘텐츠 블록이 필요한 위치에서 가져옵니다. 그러면 관련 블록 구성이 ContentBlock 구성요소에 전달됩니다.

<ContentBlock {...contentBlocks[3]} id="four" />

페이지의 구성요소를 콘텐츠를 배치하는 위치로 사용하는 방법의 예도 있습니다. 여기에서 콘텐츠 블록이 배치됩니다.

<style is:global>
  .content-block--four {
    left: 30%;
    bottom: 10%;
  }
</style>

그러나 콘텐츠 블록의 일반적인 스타일은 구성요소 코드와 함께 배치됩니다.

.content-block {
  background: hsl(0deg 0% 0% / 70%);
  color: var(--gray-0);
  border-radius:  min(3vh, var(--size-4));
  padding: clamp(0.75rem, 2vw, 1.25rem);
  display: grid;
  gap: var(--size-2);
  position: absolute;
  cursor: pointer;
  width: 50%;
}

올빼미는 이 프로젝트의 여러 상호작용 기능 중 하나입니다. 다음은 만든 공유 ViewTimeline을 사용한 방법을 보여주는 좋은 예시입니다.

대략적으로 올빼미 구성요소는 일부 SVG를 가져와 Astro의 Fragment를 사용하여 인라인 처리합니다.

---
import { default as Owl } from '../Features/Owl.svg?raw'
---
<Fragment set:html={Owl} />

올빼미 배치 스타일은 구성요소 코드와 함께 배치됩니다.

.owl {
  width: 34%;
  left: 10%;
  bottom: 34%;
}

올빼미의 transform 동작을 정의하는 추가 스타일이 하나 있습니다.

.owl__owl {
  transform-origin: 50% 100%;
  transform-box: fill-box;
}

transform-box를 사용하면 transform-origin에 영향을 미칩니다. SVG 내 객체의 경계 상자에 상대적으로 설정됩니다. 올빼미는 하단 중앙에서 크기가 커지므로 transform-origin: 50% 100%를 사용합니다.

재미있는 부분은 올빼미를 생성된 ViewTimeline 중 하나에 연결하는 것입니다.

const setUpOwl = () => {
   const owl = document.querySelector('.owl__owl');

   owl.animate([
     {
       translate: '0% 110%',
     },
     {
       translate: '0% 10%',
     },
   ], {
     timeline: CHROMETOBER_TIMELINES[1],
     delay: { phase: "enter", percent: CSS.percent(80) },
     endDelay: { phase: "enter", percent: CSS.percent(90) },
     fill: 'both' 
   });
 }

 if (window.matchMedia('(prefers-reduced-motion: no-preference)').matches)
   setUpOwl()

이 코드 블록에서는 두 가지 작업을 실행합니다.

  1. 사용자의 모션 환경설정을 확인합니다.
  2. 선호사항이 없는 경우 올빼미 애니메이션을 스크롤에 연결합니다.

두 번째 부분에서는 Web Animations API를 사용하여 올빼기가 y축에서 애니메이션됩니다. 개별 변환 속성 translate가 사용되며 하나의 ViewTimeline에 연결됩니다. timeline 속성을 통해 CHROMETOBER_TIMELINES[1]에 연결됩니다. 페이지 전환을 위해 생성된 ViewTimeline입니다. 이렇게 하면 enter 단계를 사용하여 올빼미의 애니메이션이 페이지 전환에 연결됩니다. 페이지가 80% 전환되면 올빼미를 움직이기 시작하도록 정의합니다. 90%가 되면 올빼기가 번역을 완료합니다.

도서 기능

이제 페이지를 빌드하는 접근 방식과 프로젝트 아키텍처의 작동 방식을 알아봤습니다. 참여자가 참여하여 원하는 페이지 또는 기능을 작업하는 방식을 확인할 수 있습니다. 책의 다양한 기능에는 책의 페이지 전환에 연결된 애니메이션이 있습니다. 예를 들어 페이지를 넘길 때 날아오르고 날아가는 박쥐가 있습니다.

CSS 애니메이션을 사용하는 요소도 있습니다.

콘텐츠 블록이 책에 포함되면 다른 기능을 사용하여 창의력을 발휘할 수 있었습니다. 이를 통해 다양한 상호작용을 생성하고 다양한 구현 방법을 시도할 수 있었습니다.

응답성 유지

반응형 뷰포트 단위는 사진첩과 사진첩의 기능 크기를 지정합니다. 하지만 글꼴의 반응성을 유지하는 것은 흥미로운 과제였습니다. 컨테이너 쿼리 단위가 여기에 적합합니다. 하지만 아직 일부 지역에서는 지원되지 않습니다. 사진첩의 크기가 설정되어 있으므로 컨테이너 쿼리는 필요하지 않습니다. 인라인 컨테이너 쿼리 단위는 CSS calc()로 생성하여 글꼴 크기 조절에 사용할 수 있습니다.


.book-placeholder {
  --size: clamp(12rem, 72vw, 80vmin);
  --aspect-ratio: 360 / 504;
  --cqi: calc(0.01 * (var(--size) * (var(--aspect-ratio))));
}

.content-block h2 {
  color: var(--gray-0);
  font-size: clamp(0.6rem, var(--cqi) * 4, 1.5rem);
}

.content-block :is(p, a) {
  font-size: clamp(0.6rem, var(--cqi) * 3, 1.5rem);
}

밤에 빛나는 호박

눈이 밝은 사용자라면 앞서 페이지 배경을 설명할 때 <source> 요소가 사용된 것을 눈치챘을 것입니다. Una는 색 구성표 환경설정에 반응하는 상호작용을 원했습니다. 따라서 배경은 다양한 변형을 통해 밝은 모드와 어두운 모드를 모두 지원합니다. <picture> 요소와 함께 미디어 쿼리를 사용할 수 있으므로 두 가지 배경 스타일을 제공하는 데 적합합니다. <source> 요소는 색 구성표 환경설정을 쿼리하고 적절한 배경을 표시합니다.

<picture>
  <source srcset={darkFront} media="(prefers-color-scheme: dark)" height="214" width="150">
  <img src={lightFront} class="page__background page__background--right" alt="" aria-hidden="true" height="214" width="150">
</picture>

색 구성표 환경설정에 따라 다른 변경사항을 적용할 수 있습니다. 두 번째 페이지의 호박은 사용자의 색 구성표 환경설정에 반응합니다. 사용된 SVG에는 불꽃을 나타내는 원이 있으며, 이 원은 어두운 모드에서 크기가 조절되고 애니메이션이 적용됩니다.

.pumpkin__flame,
 .pumpkin__flame circle {
   transform-box: fill-box;
   transform-origin: 50% 100%;
 }

 .pumpkin__flame {
   scale: 0.8;
 }

 .pumpkin__flame circle {
   transition: scale 0.2s;
   scale: 0;
 }

@media(prefers-color-scheme: dark) {
   .pumpkin__flame {
     animation: pumpkin-flicker 3s calc(var(--index, 0) * -1s) infinite linear;
   }

   .pumpkin__flame circle {
     scale: 1;
   }

   @keyframes pumpkin-flicker {
     50% {
       scale: 1;
     }
   }
 }

이 초상화가 나를 보고 있나요?

10페이지를 확인해 보면 알 수 있습니다. 시청 중입니다. 페이지를 탐색할 때 초상화의 눈이 포인터를 따라 움직입니다. 여기서 중요한 점은 포인터 위치를 translate 값에 매핑하고 CSS에 전달하는 것입니다.

const mapRange = (inputLower, inputUpper, outputLower, outputUpper, value) => {
   const INPUT_RANGE = inputUpper - inputLower
   const OUTPUT_RANGE = outputUpper - outputLower
   return outputLower + (((value - inputLower) / INPUT_RANGE) * OUTPUT_RANGE || 0)
 }

이 코드는 입력 및 출력 범위를 사용하고 지정된 값을 매핑합니다. 예를 들어 이 사용법은 값 625를 제공합니다.

mapRange(0, 100, 250, 1000, 50) // 625

인물 사진의 경우 입력 값은 각 눈의 중심점과 약간의 픽셀 거리를 더하거나 뺀 값입니다. 출력 범위는 눈이 픽셀로 변환할 수 있는 양입니다. 그런 다음 x축 또는 y축의 포인터 위치가 값으로 전달됩니다. 눈을 움직이는 동안 눈의 중심점을 가져오기 위해 눈이 복제됩니다. 원본은 움직이지 않고 투명하며 참조용으로 사용됩니다.

그런 다음 눈이 움직일 수 있도록 눈을 연결하고 CSS 맞춤 속성 값을 업데이트합니다. 함수가 window에 대해 pointermove 이벤트에 바인딩됩니다. 이 함수가 실행되면 각 눈의 경계가 중앙 점을 계산하는 데 사용됩니다. 그런 다음 포인터 위치가 눈에 맞춤 속성 값으로 설정된 값에 매핑됩니다.

const RANGE = 15
const LIMIT = 80
const interact = ({ x, y }) => {
   // map a range against the eyes and pass in via custom properties
   const LEFT_EYE_BOUNDS = LEFT_EYE.getBoundingClientRect()
   const RIGHT_EYE_BOUNDS = RIGHT_EYE.getBoundingClientRect()

   const CENTERS = {
     lx: LEFT_EYE_BOUNDS.left + LEFT_EYE_BOUNDS.width * 0.5,
     rx: RIGHT_EYE_BOUNDS.left + RIGHT_EYE_BOUNDS.width * 0.5,
     ly: LEFT_EYE_BOUNDS.top + LEFT_EYE_BOUNDS.height * 0.5,
     ry: RIGHT_EYE_BOUNDS.top + RIGHT_EYE_BOUNDS.height * 0.5,
   }

   Object.entries(CENTERS)
     .forEach(([key, value]) => {
       const result = mapRange(value - LIMIT, value + LIMIT, -RANGE, RANGE)(key.indexOf('x') !== -1 ? x : y)
       EYES.style.setProperty(`--${key}`, result)
     })
 }

값이 CSS에 전달되면 스타일은 원하는 작업을 할 수 있습니다. 여기서 중요한 점은 CSS clamp()를 사용하여 각 눈의 동작을 다르게 만들어 JavaScript를 다시 건드리지 않고도 각 눈의 동작을 다르게 만들 수 있다는 것입니다.

.portrait__eye--mover {
   transition: translate 0.2s;
 }

 .portrait__eye--mover.portrait__eye--left {
   translate:
     clamp(-10px, var(--lx, 0) * 1px, 4px)
     clamp(-4px, var(--ly, 0) * 0.5px, 10px);
 }

 .portrait__eye--mover.portrait__eye--right {
   translate:
     clamp(-4px, var(--rx, 0) * 1px, 10px)
     clamp(-4px, var(--ry, 0) * 0.5px, 10px);
 }

주문 시전

6페이지를 확인해 보세요. 넋을 잃게 되시나요? 이 페이지에서는 환상적인 마법의 여우 디자인을 다룹니다. 포인터를 움직이면 맞춤 커서 트레일 효과가 표시될 수 있습니다. 캔버스 애니메이션을 사용합니다. <canvas> 요소는 pointer-events: none와 함께 나머지 페이지 콘텐츠 위에 배치됩니다. 즉, 사용자는 아래의 콘텐츠 블록을 계속 클릭할 수 있습니다.

.wand-canvas {
  height: 100%;
  width: 200%;
  pointer-events: none;
  right: 0;
  position: fixed;
}

세로 모드가 window에서 pointermove 이벤트를 수신하는 것처럼 <canvas> 요소도 마찬가지입니다. 하지만 이벤트가 실행될 때마다 <canvas> 요소에서 애니메이션을 적용할 객체가 생성됩니다. 이 객체는 커서 트레일에 사용되는 도형을 나타냅니다. 좌표와 무작위 색조가 있습니다.

앞의 mapRange 함수가 다시 사용됩니다. 이 함수를 사용하여 포인터 델타를 sizerate에 매핑할 수 있기 때문입니다. 객체는 <canvas> 요소에 그려질 때 반복되는 배열에 저장됩니다. 각 객체의 속성은 <canvas> 요소에 항목을 그려야 할 위치를 알려줍니다.

const blocks = []
  const createBlock = ({ x, y, movementX, movementY }) => {
    const LOWER_SIZE = CANVAS.height * 0.05
    const UPPER_SIZE = CANVAS.height * 0.25
    const size = mapRange(0, 100, LOWER_SIZE, UPPER_SIZE, Math.max(Math.abs(movementX), Math.abs(movementY)))
    const rate = mapRange(LOWER_SIZE, UPPER_SIZE, 1, 5, size)
    const { left, top, width, height } = CANVAS.getBoundingClientRect()
    
    const block = {
      hue: Math.random() * 359,
      x: x - left,
      y: y - top,
      size,
      rate,
    }
    
    blocks.push(block)
  }
window.addEventListener('pointermove', createBlock)

캔버스에 그리기 위해 requestAnimationFrame로 루프가 생성됩니다. 커서 트레일은 페이지가 표시될 때만 렌더링되어야 합니다. 표시되는 페이지를 업데이트하고 결정하는 IntersectionObserver가 있습니다. 페이지가 표시되면 객체가 캔버스에 원형으로 렌더링됩니다.

그런 다음 blocks 배열을 반복하고 트레일의 각 부분을 그립니다. 각 프레임은 크기를 줄이고 rate만큼 객체의 위치를 변경합니다. 이렇게 하면 떨어지는 효과와 크기 조절 효과가 나타납니다. 객체가 완전히 축소되면 객체가 blocks 배열에서 삭제됩니다.

let wandFrame
const drawBlocks = () => {
   ctx.clearRect(0, 0, CANVAS.width, CANVAS.height)
  
   if (PAGE_SIX.className.indexOf('in-view') === -1 && wandFrame) {
     blocks.length = 0
     cancelAnimationFrame(wandFrame)
     document.body.removeEventListener('pointermove', createBlock)
     document.removeEventListener('resize', init)
   }
  
   for (let b = 0; b < blocks.length; b++) {
     const block = blocks[b]
     ctx.strokeStyle = ctx.fillStyle = `hsla(${block.hue}, 80%, 80%, 0.5)`
     ctx.beginPath()
     ctx.arc(block.x, block.y, block.size * 0.5, 0, 2 * Math.PI)
     ctx.stroke()
     ctx.fill()

     block.size -= block.rate
     block.y += block.rate

     if (block.size <= 0) {
       blocks.splice(b, 1)
     }

   }
   wandFrame = requestAnimationFrame(drawBlocks)
 }

페이지가 표시되지 않으면 이벤트 리스너가 삭제되고 애니메이션 프레임 루프가 취소됩니다. blocks 배열도 삭제됩니다.

다음은 커서 트레일이 작동하는 모습입니다.

접근성 검토

탐색할 수 있는 재미있는 환경을 만드는 것은 좋지만 사용자가 액세스할 수 없는 환경은 좋지 않습니다. Adam의 이 분야 전문 지식은 Chrometober가 출시 전에 접근성 검토를 받을 수 있도록 준비하는 데 큰 도움이 되었습니다.

다루는 주요 영역은 다음과 같습니다.

  • 사용된 HTML이 시맨틱인지 확인합니다. 여기에는 도서에 적절한 랜드마크 요소(예: <main>)와 각 콘텐츠 블록에 <article> 요소를 사용하는 것, 그리고 약어가 소개되는 위치에 <abbr> 요소를 사용하는 것 등이 포함됩니다. 책을 만드는 과정에서 미리 생각해 두면 더 쉽게 접근할 수 있었습니다. 제목과 링크를 사용하면 사용자가 더 쉽게 탐색할 수 있습니다. 페이지에 목록을 사용하면 보조 기술에서 페이지 수를 알려줍니다.
  • 모든 이미지가 적절한 alt 속성을 사용하도록 합니다. 인라인 SVG의 경우 필요한 위치에 title 요소가 있습니다.
  • 환경을 개선하는 위치에 aria 속성을 사용합니다. 페이지와 측면에 aria-label를 사용하면 사용자에게 현재 어느 페이지에 있는지 알릴 수 있습니다. '더보기' 링크에 aria-describedBy를 사용하면 콘텐츠 블록의 텍스트가 전달됩니다. 이렇게 하면 링크가 사용자를 어디로 연결하는지 모호함이 사라집니다.
  • 콘텐츠 차단의 경우 '더보기' 링크뿐만 아니라 전체 카드를 클릭할 수 있는 기능이 제공됩니다.
  • IntersectionObserver를 사용하여 화면에 표시되는 페이지를 추적하는 방법은 앞에서 다뤘습니다. 이렇게 하면 성능뿐만 아니라 다른 여러 가지 이점이 있습니다. 화면에 표시되지 않는 페이지의 애니메이션이나 상호작용은 일시중지됩니다. 하지만 이러한 페이지에는 inert 속성도 적용됩니다. 즉, 스크린 리더를 사용하는 사용자는 정상 시력의 사용자와 동일한 콘텐츠를 탐색할 수 있습니다. 포커스가 표시된 페이지 내에 유지되며 사용자는 다른 페이지로 탭할 수 없습니다.
  • 마지막으로, 미디어 쿼리를 사용하여 사용자의 모션 환경설정을 따릅니다.

다음은 시행 중인 일부 조치를 보여주는 검토 스크린샷입니다.

요소가 전체 책 주위에 있는 것으로 식별되므로 보조 기술 사용자가 찾을 수 있는 주요 랜드마크여야 합니다. 자세한 내용은 스크린샷을 참고하세요." width="800" height="465">

Chrometober 도서가 열려 있는 스크린샷 UI의 다양한 측면 주위에 녹색 윤곽선 상자가 제공되어 의도된 접근성 기능과 페이지에서 제공할 사용자 환경 결과를 설명합니다. 예를 들어 이미지에는 대체 텍스트가 있습니다. 또 다른 예는 화면에 표시되지 않는 페이지가 비활성 상태임을 선언하는 접근성 라벨입니다. 자세한 내용은 스크린샷을 참고하세요.

알게 된 점

Chrometober의 목적은 커뮤니티의 웹 콘텐츠를 소개하는 것뿐만 아니라 개발 중인 스크롤 연결 애니메이션 API 폴리필을 테스트하는 것이었습니다.

YouTube는 뉴욕에서 열린 팀 회의 중에 프로젝트를 테스트하고 발생한 문제를 해결하기 위한 세션을 마련했습니다. 팀의 기여는 매우 소중했습니다. 또한 게시하기 전에 처리해야 할 모든 사항을 나열할 수 있는 좋은 기회였습니다.

회의실의 테이블에 CSS, UI, DevTools팀이 둘러앉아 있습니다. 포스트잇으로 가득 찬 화이트보드 앞에 선 유나 다른 팀원들이 음료와 노트북을 들고 테이블에 둘러앉아 있습니다.

예를 들어 기기에서 책을 테스트할 때 렌더링 문제가 발생했습니다. iOS 기기에서 도서가 예상대로 렌더링되지 않았습니다. 표시 영역 단위는 페이지 크기를 지정하지만 노치가 있으면 사진첩에 영향을 미쳤습니다. 해결 방법은 meta 표시 영역에서 viewport-fit=cover를 사용하는 것이었습니다.

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

이 세션에서는 API 폴리필과 관련된 몇 가지 문제도 제기되었습니다. Bramus가 polyfill 저장소에서 이 문제를 제기했습니다. 이후 이러한 문제의 해결 방법을 찾아 폴리필에 병합했습니다. 예를 들어 이 풀 요청은 폴리필의 일부에 캐싱을 추가하여 성능을 개선했습니다.

Chrome에서 열린 데모의 스크린샷 개발자 도구가 열려 있고 기준 성능 측정이 표시되어 있습니다.

Chrome에서 열린 데모의 스크린샷 개발자 도구가 열려 있으며 개선된 성능 측정이 표시됩니다.

작업이 끝났습니다.

이 프로젝트는 정말 재미있었고, 그 결과 커뮤니티의 멋진 콘텐츠를 강조하는 기발한 스크롤 환경을 만들 수 있었습니다. 또한 폴리필을 테스트하고 엔지니어링팀에 폴리필을 개선하는 데 도움이 되는 의견을 제공하는 데도 유용했습니다.

2022 Chrometober이 종료되었습니다.

유익한 시간이었기를 바랍니다. 가장 좋아하는 기능은 무엇인가요? 트윗하여 알려주세요.

Chrometober 캐릭터의 스티커 시트를 들고 있는 제이

이벤트에서 만나면 팀원에게 스티커를 받아보실 수도 있습니다.

히어로 사진: Unsplash데이비드 메니드레이