Chrometober 빌드

이번 Chrometober에 재미있고 무서운 도움말과 유용한 정보를 공유하기 위해 스크롤 북이 어떻게 탄생했는지 알아보세요.

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와도 잘 작동합니다.

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

뱀, 팔이 튀어나온 관, 가마솥 옆에서 지팡이를 든 여우, 으스스한 얼굴의 나무, 호박 랜턴을 든 가고일이 등장하는 구성 장면 중 하나입니다.

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

API 숙지하기

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

상위 수준에서 이 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-name를 사용하여 ViewTimeline를 만들고 축을 정의합니다. 이 예시에서 block은 논리적 block를 나타냅니다. 애니메이션은 animation-timeline 속성을 사용하여 스크롤에 연결됩니다. animation-delay 및 animation-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;
  }
}

이렇게 하면 'Mover'가 표시 영역에 들어올 때 확장되어 'Spinner'의 회전을 트리거합니다.

실험을 통해 이 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축으로 변환하여 책처럼 작동하도록 합니다.

요약 정리

책의 메커니즘을 파악한 후에는 타일러의 삽화에 생동감을 불어넣는 데 집중할 수 있었습니다.

천체

팀은 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;
}

페이지 콘텐츠

페이지 중 하나를 빌드하는 방법을 살펴보겠습니다. 세 번째 페이지에는 나무에서 튀어나오는 부엉이가 있습니다.

구성에 정의된 대로 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>

다시 말하지만 페이지는 본질적으로 원자적입니다. 특성 모음으로 빌드됩니다. 세 번째 페이지에는 콘텐츠 블록과 대화형 부엉이가 있으므로 각각에 해당하는 구성요소가 있습니다.

콘텐츠 블록은 책 내에서 볼 수 있는 콘텐츠에 대한 링크입니다. 이러한 요소도 구성 객체에 의해 구동됩니다.

{
 "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을 어떻게 사용했는지 보여주는 좋은 예시입니다.

간단히 말해, owl 구성요소는 일부 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> 요소가 사용된 것을 눈치채셨을 것입니다. 우나는 색 구성표 환경설정에 반응하는 상호작용을 원했습니다. 따라서 배경은 다양한 변형으로 밝은 모드와 어두운 모드를 모두 지원합니다. <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페이지를 보면 무언가를 발견할 수 있습니다. 누군가 지켜보고 있습니다. 페이지를 이동하면 초상화의 눈이 포인터를 따라 움직입니다. 여기서 핵심은 포인터 위치를 변환 값에 매핑하고 이를 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 함수를 다시 사용하여 포인터 델타를 size 및 rate에 매핑할 수 있습니다. 객체는 <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의 동기는 커뮤니티의 웹 콘텐츠를 강조하는 것뿐만 아니라 개발 중인 스크롤 연결 Animation API 폴리필을 테스트하는 방법이기도 했습니다.

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

CSS, UI, DevTools팀이 회의실 테이블에 둘러앉아 있습니다. 우나는 메모지가 붙어 있는 화이트보드 앞에 서 있습니다. 다른 팀원들은 다과와 노트북을 가지고 테이블에 둘러앉아 있습니다.

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

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

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

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

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

작업이 끝났습니다.

이 프로젝트는 정말 재미있게 진행되었으며, 커뮤니티의 멋진 콘텐츠를 강조하는 기발한 스크롤 환경을 제공합니다. 뿐만 아니라 폴리필을 테스트하고 엔지니어링팀에 의견을 제공하여 폴리필을 개선하는 데도 큰 도움이 되었습니다.

2022년 10월 Chrome의 달이 종료되었습니다.

유익한 시간이었기를 바랍니다. 가장 마음에 드는 기능은 무엇인가요? 트윗으로 알려주세요.

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

이벤트에서 YouTube팀을 만나면 스티커를 받을 수도 있습니다.