Chrometober 빌드

이 Chrometober에서 재미있고 무서운 팁과 요령을 공유하여 생동감 넘치는 스크롤 도서를 만들 수 있습니다.

Designcember의 발표에 이어 올해도 Chrometober를 빌드하여 커뮤니티와 Chrome팀의 웹 콘텐츠를 강조하고 공유해 드리고자 합니다. Designcember는 컨테이너 쿼리의 사용을 보여주었지만 올해는 CSS 스크롤 연결 애니메이션 API를 선보입니다.

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

개요

이 프로젝트의 목표는 스크롤에 연결된 Animation API를 강조하는 기발한 경험을 제공하는 것이었습니다. 그러나 기발하기는 했지만 응답과 접근성을 갖춘 경험이 필요했습니다. 또한 이 프로젝트는 현재 개발 중인 API 폴리필을 테스트하는 좋은 방법이기도 하며, 다양한 기술과 도구를 조합하여 테스트해 보는 것도 좋은 방법입니다. 연말연시 핼러윈 테마로 꾸며져 있습니다.

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

스크롤하기 위한 환경 초안 작성

Chrometober에 대한 아이디어는 2022년 5월부터 Google의 첫 번째 오프사이트 팀에서 활용되기 시작했습니다. 우리는 낙서 모음을 통해 사용자가 어떤 형태로든 스토리보드를 따라 스크롤할 수 있는 방법을 생각하게 했습니다. 우리는 비디오 게임에서 영감을 받아 묘지나 유령의 집과 같은 장면을 스크롤하는 경험을 고려했습니다.

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

창작의 자유를 누리며 첫 Google 프로젝트를 예상치 못한 방향으로 이끌어 갈 수 있어서 무척 흥미로웠습니다. 이는 사용자가 콘텐츠를 탐색하는 방법의 초기 프로토타입입니다.

사용자가 옆으로 스크롤하면 블록이 회전하고 축소됩니다. 하지만 모든 크기의 기기를 사용하는 사용자에게 이 경험을 어떻게 개선할 수 있을지에 대한 우려에서 이 아이디어에서 벗어나기로 했습니다. 대신 예전에 만들었던 디자인에 의지했습니다. 2020년에 저는 운 좋게도 GreenSock's ScrollTrigger에 액세스하여 출시 데모를 빌드할 수 있었습니다.

제가 만든 데모 중 하나는 스크롤에 따라 페이지가 바뀌는 3D CSS 책으로, Chrometober의 목적에 훨씬 더 적합했습니다. 스크롤에 연결된 애니메이션 API를 사용하면 이 기능을 완벽하게 전환할 수 있습니다. 아래에서 볼 수 있듯이 scroll-snap에서도 잘 작동합니다.

이 프로젝트의 일러스트레이터인 Tyler Reed는 아이디어를 바꾸면서 디자인을 멋지게 바꿨습니다. 타일러는 자신에게 떠오른 모든 창의적인 아이디어를 바탕으로 실행에 옮겼습니다. 함께 아이디어를 브레인스토밍하는 것이 재미있었습니다. 특성이 격리된 블록으로 분해되는 것이 작업 방식에서 큰 부분을 차지했습니다. 이런 식으로 장면으로 구성한 다음 생동감 넘치는 요소를 고를 수 있습니다.

뱀, 팔이 튀어나온 관, 가마솥에 지팡이가 달린 여우, 으스스한 얼굴의 나무, 호박등을 들고 있는 가고일이 등장하는 작곡 장면 중 하나입니다.

주된 아이디어는 사용자가 책을 읽다보면 콘텐츠 블록에 액세스할 수 있는 것이었습니다. 또한 이 실험 환경에 구축된 이스터 에그와 같이 기발한 방식으로 반응할 수도 있습니다. 예를 들어 귀신의 눈이 포인터를 따라 움직이는 귀신의 집의 초상화, 미디어 쿼리에 의해 트리거되는 섬세한 애니메이션 등을 예로 들 수 있습니다. 스크롤 시 이러한 아이디어와 기능이 애니메이션으로 표시됩니다. 초기 아이디어는 사용자가 스크롤하면 x축을 따라 떠다니는 좀비 토끼였습니다.

API 익히기

개별 기능과 이스터 에그를 실험해 보려면 책이 필요했습니다. 그래서 Google은 이 기능을 새롭게 등장한 CSS 스크롤 연결 애니메이션 API의 기능을 테스트하는 기회로 활용하기로 했습니다. 스크롤 연결된 애니메이션 API는 현재 어떤 브라우저에서도 지원되지 않습니다. 하지만 API를 개발하는 동안 상호작용팀의 엔지니어가 polyfill 작업을 해왔습니다. 이를 통해 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-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;
  }
}

이렇게 하면 '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를 연결하지 않고 자바스크립트의 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년에 Astro를 Designcember에 사용했으며 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 파일처럼 보이지만, 맨 위에 frontmatter와 비슷한 코드 펜스가 있습니다. 이를 통해 다른 구성요소를 가져오는 등의 작업을 할 수 있습니다. 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을 어떻게 사용했는지 보여주는 훌륭한 간단한 예입니다.

개략적으로 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> 요소가 사용된 것을 보았을 수 있습니다. 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>

이러한 색 구성표 환경설정에 따라 다른 변경사항을 도입할 수 있습니다. 2페이지의 호박은 사용자의 색 구성표 환경설정에 반응합니다. 사용된 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 맞춤 속성 값을 함께 묶고 업데이트합니다. 함수는 windowpointermove 이벤트에 결합됩니다. 이 이벤트가 실행되면 중심점을 계산하는 데 각 눈의 경계가 사용됩니다. 그러면 포인터 위치가 눈에 맞춤 속성 값으로 설정된 값에 매핑됩니다.

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를 사용하여 루프를 만듭니다. 커서 흔적은 페이지가 표시되고 있을 때만 렌더링됩니다. Google에는 표시되는 페이지를 업데이트하고 결정하는 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 폴리필을 테스트할 수 있는 방법이기도 했습니다.

우리는 프로젝트를 테스트하고 발생한 문제를 해결하기 위해 뉴욕에서 열린 팀 서밋에서 세션을 따로 예약했습니다. 이 팀의 기여는 매우 소중했습니다. 또한 라이브를 진행하기 전에 해결해야 할 사항을 모두 나열할 수 있는 좋은 기회이기도 했습니다.

CSS, UI, DevTools 팀이 회의실의 테이블 주위에 앉아 있습니다. 스티커 메모로 덮인 화이트보드 앞에 서 있는 우나 다른 팀원들은 식탁에 둘러앉아 다과와 노트북을 들고 있습니다.

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

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

이 세션에서는 API 폴리필과 관련된 몇 가지 문제도 발생했습니다. Bramus는 polyfill 저장소에서 이러한 문제를 제기했습니다. 그런 다음 이러한 문제에 대한 해결 방법을 찾아 폴리필에 병합했습니다. 예를 들어 이 pull 요청은 폴리필의 일부에 캐싱을 추가하여 성능을 향상했습니다.

Chrome에서 열리는 데모의 스크린샷 개발자 도구가 열려 있으며 기준 실적 측정치가 표시되어 있습니다.

Chrome에서 열리는 데모의 스크린샷 개발자 도구가 열려 있으며 개선된 실적 측정 결과를 보여줍니다.

작업이 끝났습니다.

이 프로젝트는 정말 재미있는 작업이었기 때문에 커뮤니티의 멋진 콘텐츠를 강조하는 기발한 스크롤 경험을 선사할 수 있었습니다. 그뿐 아니라 폴리필을 테스트하는 데도 유용했으며 엔지니어링팀에 폴리필 개선을 위한 피드백을 제공하는 데 큰 도움이 되었습니다.

Chrometober 2022가 마무리되었습니다.

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

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

이벤트에서 저희 팀을 방문하시면 팀원 중 한 곳에서 스티커를 받아 보실 수도 있습니다.

히어로 사진: David Menidrey, Unsplash