Đang xây dựng Chrometober!

Cách cuốn sách có chức năng cuộn trở nên sống động khi chia sẻ các mẹo và thủ thuật thú vị và đáng sợ trên Chrometober này.

Tiếp nối Designcember, chúng tôi muốn xây dựng Chrometober cho bạn trong năm nay để làm nổi bật và chia sẻ nội dung web của cộng đồng và nhóm Chrome. Designcember đã giới thiệu cách sử dụng Truy vấn vùng chứa, nhưng năm nay chúng tôi sẽ giới thiệu API ảnh động liên kết với thao tác cuộn CSS.

Hãy xem trải nghiệm cuộn sách tại web.dev/chrometober-2022.

Tổng quan

Mục tiêu của dự án là mang đến trải nghiệm thú vị, làm nổi bật API ảnh động liên kết với thao tác cuộn. Tuy nhiên, mặc dù mang tính chất kỳ quặc, nhưng trải nghiệm này cũng cần phải thích ứng và dễ tiếp cận. Dự án này cũng là một cách tuyệt vời để thử nghiệm API polyfill đang trong quá trình phát triển; cũng như thử kết hợp nhiều kỹ thuật và công cụ. Tất cả đều có chủ đề lễ hội Halloween!

Cơ cấu nhóm của chúng ta như sau:

  • Tyler Reed: Hình minh hoạ và thiết kế
  • Jhey Tompkins: Trưởng nhóm kiến trúc và sáng tạo
  • Una Kravets: Trưởng nhóm dự án
  • Bramus Van Damme: Cộng tác viên trang web
  • Adam Argyle: Bài đánh giá về khả năng hỗ trợ tiếp cận
  • Aaron Forinton: Viết nội dung quảng cáo

Soạn thảo trải nghiệm kể chuyện cuộn

Ý tưởng về Chrometober bắt đầu xuất hiện tại buổi họp ngoài trời đầu tiên của nhóm vào tháng 5 năm 2022. Một tập hợp các nét vẽ nguệch ngoạc đã khiến chúng tôi nghĩ đến những cách người dùng có thể cuộn theo một số dạng bảng phân cảnh. Lấy cảm hứng từ trò chơi điện tử, chúng tôi đã cân nhắc trải nghiệm cuộn qua các cảnh như nghĩa trang và nhà ma.

Một cuốn sổ nằm trên bàn với nhiều hình vẽ nguệch ngoạc liên quan đến dự án.

Tôi rất vui khi có được sự tự do sáng tạo để đưa dự án đầu tiên của mình tại Google theo hướng không ngờ. Đây là nguyên mẫu ban đầu về cách người dùng có thể di chuyển qua nội dung.

Khi người dùng cuộn sang một bên, các khối sẽ xoay và thu nhỏ. Nhưng tôi quyết định từ bỏ ý tưởng này vì lo ngại về cách chúng tôi có thể mang lại trải nghiệm tuyệt vời cho người dùng trên các thiết bị có kích thước khác nhau. Thay vào đó, tôi đã nghiêng về thiết kế của một thứ mà tôi đã tạo ra trong quá khứ. Năm 2020, tôi rất may mắn được sử dụng ScrollTrigger của GreenSock để tạo bản minh hoạ bản phát hành.

Một trong những bản minh hoạ mà tôi đã tạo là một cuốn sách 3D-CSS, trong đó các trang sẽ lật khi bạn cuộn. Điều này phù hợp hơn nhiều với những gì chúng tôi muốn cho Chrometober. API ảnh động liên kết với thao tác cuộn là một sự thay thế hoàn hảo cho chức năng đó. Phương thức này cũng hoạt động tốt với scroll-snap, như bạn sẽ thấy!

Người minh hoạ cho dự án của chúng tôi, Tyler Reed, rất xuất sắc trong việc thay đổi thiết kế khi chúng tôi thay đổi ý tưởng. Tyler đã làm rất tốt khi biến tất cả ý tưởng sáng tạo được đưa ra thành hiện thực. Chúng tôi đã rất vui khi cùng nhau động não. Một phần quan trọng trong cách chúng tôi muốn làm việc này là chia các tính năng thành các khối riêng biệt. Bằng cách đó, chúng ta có thể kết hợp các thành phần này thành các cảnh, sau đó chọn những thành phần mà chúng ta đã tạo ra.

Một trong những cảnh trong thành phần hiển thị có hình một con rắn, một chiếc quan tài có cánh tay thò ra, một con cáo cầm một cây đũa phép bên cạnh một cái vạc, một cây có khuôn mặt ma quái và một tượng gargoyle cầm một chiếc đèn lồng bí ngô.

Ý tưởng chính là khi người dùng đọc qua cuốn sách, họ có thể truy cập vào các khối nội dung. Người dùng cũng có thể tương tác với các chi tiết ngẫu hứng, bao gồm cả các quả trứng Phục sinh mà chúng tôi đã tích hợp vào trải nghiệm; ví dụ: một bức chân dung trong một ngôi nhà ma, trong đó đôi mắt theo dõi con trỏ của bạn hoặc ảnh động tinh tế được kích hoạt bằng các truy vấn nội dung nghe nhìn. Những ý tưởng và tính năng này sẽ được tạo hiệu ứng ảnh động khi cuộn. Ý tưởng ban đầu là một chú thỏ zombie sẽ trồi lên và dịch chuyển dọc theo trục x khi người dùng cuộn.

Làm quen với API

Trước khi có thể bắt đầu chơi với các tính năng riêng lẻ và trứng phục sinh, chúng ta cần có một cuốn sách. Vì vậy, chúng tôi quyết định biến đây thành cơ hội để thử nghiệm bộ tính năng cho API ảnh động liên kết với thao tác cuộn CSS mới xuất hiện. API ảnh động liên kết với thao tác cuộn hiện không được hỗ trợ trong bất kỳ trình duyệt nào. Tuy nhiên, trong khi phát triển API, các kỹ sư trong nhóm tương tác đã làm việc trên một polyfill. Điều này cung cấp một cách để kiểm thử hình dạng của API khi API phát triển. Điều đó có nghĩa là chúng ta có thể sử dụng API này ngay hôm nay. Những dự án thú vị như thế này thường là nơi tuyệt vời để thử nghiệm các tính năng và đưa ra ý kiến phản hồi. Hãy tìm hiểu những điều chúng tôi đã học được và ý kiến phản hồi mà chúng tôi có thể cung cấp ở phần sau của bài viết.

Ở cấp độ cao, bạn có thể sử dụng API này để liên kết ảnh động cần cuộn. Điều quan trọng cần lưu ý là bạn không thể kích hoạt ảnh động khi cuộn. Đây là một tính năng có thể được thêm vào sau. Ảnh động liên kết với thao tác cuộn cũng thuộc hai danh mục chính:

  1. Những phản ứng với vị trí cuộn.
  2. Những thành phần phản ứng với vị trí của một phần tử trong vùng chứa cuộn.

Để tạo mã thứ hai, chúng ta sử dụng ViewTimeline được áp dụng thông qua thuộc tính animation-timeline.

Sau đây là một ví dụ về cách sử dụng ViewTimeline trong CSS:

.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;
 }
}

Chúng ta tạo một ViewTimeline bằng view-timeline-name và xác định trục cho ViewTimeline đó. Trong ví dụ này, block đề cập đến block logic. Ảnh động được liên kết với thao tác cuộn bằng thuộc tính animation-timeline. animation-delayanimation-end-delay (tại thời điểm viết bài) là cách chúng tôi xác định các giai đoạn.

Các giai đoạn này xác định những điểm mà ảnh động nên được liên kết với vị trí của một phần tử trong vùng chứa cuộn của nó. Trong ví dụ này, chúng ta nói đến việc bắt đầu ảnh động khi phần tử nhập (enter 0%) vùng chứa cuộn. Và hoàn tất khi đã bao phủ 50% (cover 50%) vùng chứa cuộn.

Sau đây là bản minh hoạ đang hoạt động:

Bạn cũng có thể liên kết ảnh động với phần tử đang di chuyển trong khung nhìn. Bạn có thể thực hiện việc này bằng cách đặt animation-timeline thành view-timeline của phần tử. Điều này phù hợp với các tình huống như ảnh động danh sách. Hành vi này tương tự như cách bạn có thể tạo ảnh động cho các phần tử khi nhập bằng 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;
  }
}

Với điều này,"Mover" sẽ mở rộng khi nó đi vào khung nhìn, kích hoạt việc xoay "Spinner".

Qua thử nghiệm, tôi nhận thấy API này hoạt động rất hiệu quả với scroll-snap. Tính năng cuộn nhanh kết hợp với ViewTimeline sẽ rất phù hợp để chụp nhanh các lượt chuyển trang trong sách.

Tạo nguyên mẫu cơ chế

Sau một số thử nghiệm, tôi đã có thể làm cho nguyên mẫu sách hoạt động. Bạn cuộn theo chiều ngang để lật các trang của cuốn sách.

Trong bản minh hoạ, bạn có thể thấy nhiều yếu tố kích hoạt được làm nổi bật bằng đường viền nét đứt.

Mã đánh dấu sẽ có dạng như sau:

<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>

Khi bạn cuộn, các trang của cuốn sách sẽ xoay, nhưng sẽ mở hoặc đóng nhanh. Điều này phụ thuộc vào việc căn chỉnh cuộn-snap của các điều kiện kích hoạt.

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

Lần này, chúng ta không kết nối ViewTimeline trong CSS mà sử dụng Web Animations API trong JavaScript. Điều này bổ sung lợi ích của việc có thể lặp lại một tập hợp các phần tử và tạo ViewTimeline mà chúng ta cần, thay vì phải tạo từng phần tử theo cách thủ công.

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);

Đối với mỗi điều kiện kích hoạt, chúng ta tạo một ViewTimeline. Sau đó, chúng ta tạo ảnh động cho trang liên kết của trình kích hoạt bằng ViewTimeline đó. Thao tác này sẽ liên kết ảnh động của trang với thao tác cuộn. Đối với ảnh động, chúng ta đang xoay một phần tử của trang trên trục y để lật trang. Chúng ta cũng dịch chính trang này trên trục z để trang hoạt động giống như một cuốn sách.

Kết hợp kiến thức đã học

Sau khi tìm hiểu cơ chế cho cuốn sách, tôi có thể tập trung vào việc biến các bức tranh minh hoạ của Tyler trở nên sống động.

Astro

Nhóm của tôi đã sử dụng Astro cho Designcember vào năm 2021 và tôi rất muốn sử dụng lại ứng dụng này cho Chrometober. Trải nghiệm của nhà phát triển khi có thể chia nhỏ các thành phần phù hợp với dự án này.

Bản thân cuốn sách là một thành phần. Đây cũng là một tập hợp các thành phần trang. Mỗi trang có hai mặt và có phông nền. Các thành phần con của một bên trang là các thành phần có thể dễ dàng thêm, xoá và định vị.

Tạo sách

Điều quan trọng là tôi phải làm cho các khối dễ quản lý. Tôi cũng muốn giúp các thành viên khác trong nhóm dễ dàng đóng góp.

Các trang ở cấp cao được xác định bởi một mảng cấu hình. Mỗi đối tượng trang trong mảng xác định nội dung, phông nền và siêu dữ liệu khác cho một trang.

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

Các đối tượng này được truyền đến thành phần Book.

<Book pages={pages} />

Thành phần Book là nơi áp dụng cơ chế cuộn và tạo các trang của sách. Chúng ta sử dụng cùng một cơ chế từ nguyên mẫu; nhưng chia sẻ nhiều thực thể của ViewTimeline được tạo trên toàn cầu.

window.CHROMETOBER_TIMELINES.push(viewTimeline);

Bằng cách này, chúng ta có thể chia sẻ dòng thời gian sẽ được sử dụng ở những nơi khác thay vì tạo lại chúng. Chúng ta sẽ nói thêm về điều này ở phần sau.

Cấu trúc trang

Mỗi trang là một mục danh sách bên trong một danh sách:

<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>

Và cấu hình đã xác định sẽ được truyền đến từng thực thể Page. Những trang này sử dụng tính năng ô của Astro để chèn nội dung vào mỗi trang.

<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>

Mã này chủ yếu dùng để thiết lập cấu trúc. Trong hầu hết trường hợp, cộng tác viên có thể làm việc trên nội dung của cuốn sách mà không cần phải đụng đến mã này.

Phông nền

Sự chuyển đổi sáng tạo sang một cuốn sách giúp việc chia nhỏ các phần trở nên dễ dàng hơn và mỗi phần mở ra của cuốn sách là một cảnh được lấy từ thiết kế ban đầu.

Hình minh hoạ trang trải ra từ cuốn sách có hình cây táo trong một nghĩa trang. Nghĩa trang này có nhiều bia mộ, và có một con dơi trên bầu trời trước mặt trăng lớn.

Vì chúng ta đã quyết định tỷ lệ khung hình cho cuốn sách, nên phông nền cho mỗi trang có thể có một phần tử hình ảnh. Bạn có thể đặt phần tử đó thành chiều rộng 200% và sử dụng object-position dựa trên cạnh trang.

.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;
}

Nội dung trang

Hãy cùng xem cách tạo một trong các trang. Trang ba có hình một con cú xuất hiện trên cây.

Thành phần này được điền sẵn thành phần PageThree, như được xác định trong cấu hình. Đây là một thành phần Astro (PageThree.astro). Các thành phần này trông giống như tệp HTML nhưng có hàng bảo vệ mã ở đầu tương tự như phần đầu sách. Điều này cho phép chúng ta làm những việc như nhập các thành phần khác. Thành phần cho trang ba có dạng như sau:

---
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>

Xin nhắc lại, các trang có bản chất nguyên tử. Các lớp này được tạo từ một tập hợp các tính năng. Trang ba có một khối nội dung và một con cú tương tác, vì vậy, mỗi trang sẽ có một thành phần.

Khối nội dung là các đường liên kết đến nội dung xuất hiện trong sách. Các đối tượng này cũng được điều khiển bởi một đối tượng cấu hình.

{
 "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
  ]
}

Cấu hình này được nhập khi yêu cầu chặn nội dung. Sau đó, cấu hình khối liên quan sẽ được truyền đến thành phần ContentBlock.

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

Ngoài ra, còn có một ví dụ về cách chúng ta sử dụng thành phần của trang làm vị trí để định vị nội dung. Tại đây, một khối nội dung được định vị.

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

Tuy nhiên, các kiểu chung cho một khối nội dung được đặt cùng với mã thành phần.

.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%;
}

Còn về con cú, đó là một tính năng tương tác – một trong nhiều tính năng trong dự án này. Đây là một ví dụ nhỏ thú vị để xem cách chúng ta sử dụng ViewTimeline dùng chung mà chúng ta đã tạo.

Ở cấp độ cao, thành phần cú của chúng ta sẽ nhập một số SVG và nội tuyến bằng cách sử dụng Mảnh của Astro.

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

Và các kiểu để định vị con cú của chúng ta được đặt cùng với mã thành phần.

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

Có một phần định kiểu bổ sung xác định hành vi transform cho con cú.

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

Việc sử dụng transform-box sẽ ảnh hưởng đến transform-origin. Tỷ lệ này tương ứng với hộp giới hạn của đối tượng trong SVG. Con cú được điều chỉnh theo tỷ lệ từ chính giữa dưới cùng, do đó, bạn cần sử dụng transform-origin: 50% 100%.

Phần thú vị là khi chúng ta liên kết con cú với một trong các ViewTimeline đã tạo:

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()

Trong khối mã này, chúng ta làm hai việc:

  1. Kiểm tra lựa chọn ưu tiên về chuyển động của người dùng.
  2. Nếu người dùng không có lựa chọn ưu tiên, hãy liên kết ảnh động của con cú để cuộn.

Đối với phần thứ hai, con cú sẽ tạo ảnh động trên trục y bằng API Ảnh động trên web. Thuộc tính biến đổi riêng lẻ translate được sử dụng và được liên kết với một ViewTimeline. Tài sản đó được liên kết với CHROMETOBER_TIMELINES[1] thông qua thuộc tính timeline. Đây là ViewTimeline được tạo cho các lượt chuyển trang. Thao tác này liên kết ảnh động của cú với thao tác lật trang bằng cách sử dụng giai đoạn enter. Định nghĩa này xác định rằng khi trang đã được lật 80%, hãy bắt đầu di chuyển con cú. Khi đạt 90%, cú sẽ hoàn tất bản dịch.

Tính năng của sách

Giờ đây, bạn đã biết phương pháp tạo trang và cách hoạt động của cấu trúc dự án. Bạn có thể thấy cách công cụ này cho phép cộng tác viên tham gia và làm việc trên một trang hoặc tính năng mà họ chọn. Nhiều tính năng trong sách có ảnh động liên kết với thao tác lật trang sách; ví dụ: con dơi bay vào và bay ra khi lật trang.

Tệp này cũng có các phần tử được ảnh động CSS hỗ trợ.

Sau khi các khối nội dung đã có trong sách, đã đến lúc bạn có thể sáng tạo với các tính năng khác. Điều này đã tạo cơ hội để tạo ra một số hoạt động tương tác khác nhau và thử nhiều cách để triển khai.

Duy trì khả năng thích ứng của mọi thứ

Các đơn vị khung nhìn thích ứng sẽ xác định kích thước sách và các tính năng của sách. Tuy nhiên, việc duy trì khả năng thích ứng của phông chữ là một thách thức thú vị. Đơn vị truy vấn vùng chứa rất phù hợp ở đây. Tuy nhiên, tính năng này chưa được hỗ trợ ở mọi nơi. Kích thước của sách đã được thiết lập sẵn, nên chúng ta không cần truy vấn vùng chứa. Bạn có thể tạo một đơn vị truy vấn vùng chứa nội tuyến bằng CSS calc() và sử dụng để định cỡ phông chữ.


.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);
}

Bí ngô phát sáng vào ban đêm

Những người tinh mắt có thể đã nhận thấy cách sử dụng phần tử <source> khi thảo luận trước đó về phông nền của trang. Una muốn có một hoạt động tương tác phản ứng với lựa chọn ưu tiên về bảng phối màu. Do đó, phông nền hỗ trợ cả chế độ sáng và tối với nhiều biến thể. Vì bạn có thể sử dụng truy vấn nội dung nghe nhìn với phần tử <picture>, nên đây là một cách tuyệt vời để cung cấp hai kiểu phông nền. Phần tử <source> truy vấn lựa chọn ưu tiên về bảng phối màu và hiển thị phông nền thích hợp.

<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>

Bạn có thể đưa ra các thay đổi khác dựa trên lựa chọn ưu tiên về bảng phối màu đó. Bí ngô trên trang hai phản ứng với lựa chọn ưu tiên về bảng phối màu của người dùng. SVG được sử dụng có các vòng tròn tượng trưng cho ngọn lửa, phóng to và tạo hiệu ứng động trong chế độ tối.

.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;
     }
   }
 }

Ảnh chân dung này có đang nhìn bạn không?

Nếu xem trang 10, bạn có thể nhận thấy điều gì đó. Bạn đang được theo dõi! Mắt của chân dung sẽ di chuyển theo con trỏ khi bạn di chuyển trên trang. Mẹo ở đây là ánh xạ vị trí con trỏ đến một giá trị dịch và truyền giá trị đó đến 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)
 }

Mã này lấy các dải ô đầu vào và đầu ra, đồng thời liên kết các giá trị đã cho. Ví dụ: cách sử dụng này sẽ trả về giá trị 625.

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

Đối với ảnh dọc, giá trị nhập là điểm giữa của mỗi mắt, cộng hoặc trừ đi khoảng cách pixel. Phạm vi đầu ra là lượng mắt có thể dịch sang pixel. Sau đó, vị trí con trỏ trên trục x hoặc y sẽ được truyền dưới dạng giá trị. Để lấy điểm giữa của mắt trong khi di chuyển mắt, mắt sẽ được sao chép. Các bản gốc không di chuyển, có độ trong suốt và được dùng để tham khảo.

Sau đó, bạn chỉ cần liên kết các phần này với nhau và cập nhật giá trị thuộc tính tuỳ chỉnh CSS trên mắt để mắt có thể di chuyển. Một hàm được liên kết với sự kiện pointermove so với window. Khi sự kiện này kích hoạt, các giới hạn của mỗi mắt sẽ được dùng để tính toán các điểm trung tâm. Sau đó, vị trí con trỏ được liên kết với các giá trị được đặt làm giá trị thuộc tính tuỳ chỉnh trên mắt.

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)
     })
 }

Sau khi các giá trị được truyền đến CSS, các kiểu có thể làm những gì mình muốn với các giá trị đó. Điểm hay ở đây là sử dụng CSS clamp() để tạo ra hành vi khác nhau cho mỗi mắt, nhờ đó, bạn có thể tạo ra hành vi khác nhau cho mỗi mắt mà không cần chạm lại vào 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);
 }

Truyền phép

Khi xem trang sáu, bạn có cảm thấy bị mắc lỗi chính tả không? Trang này thể hiện thiết kế của chú cáo huyền bí tuyệt vời. Nếu di chuyển con trỏ xung quanh, bạn có thể thấy hiệu ứng vệt con trỏ tuỳ chỉnh. Ảnh động này sử dụng ảnh động trên canvas. Phần tử <canvas> nằm phía trên phần nội dung còn lại của trang bằng pointer-events: none. Điều này có nghĩa là người dùng vẫn có thể nhấp vào các khối nội dung bên dưới.

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

Giống như cách ảnh dọc của chúng ta theo dõi sự kiện pointermove trên window, phần tử <canvas> cũng vậy. Tuy nhiên, mỗi khi sự kiện kích hoạt, chúng ta sẽ tạo một đối tượng để tạo ảnh động trên phần tử <canvas>. Các đối tượng này đại diện cho các hình dạng được sử dụng trong vệt con trỏ. Các điểm này có toạ độ và màu sắc ngẫu nhiên.

Hàm mapRange trước đó được sử dụng lại vì chúng ta có thể dùng hàm này để liên kết delta con trỏ với sizerate. Các đối tượng được lưu trữ trong một mảng được lặp lại khi các đối tượng được vẽ vào phần tử <canvas>. Các thuộc tính của mỗi đối tượng cho phần tử <canvas> biết vị trí cần vẽ đối tượng.

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)

Để vẽ lên canvas, một vòng lặp được tạo bằng requestAnimationFrame. Dấu vết con trỏ chỉ nên hiển thị khi trang đang hiển thị. Chúng ta có một IntersectionObserver cập nhật và xác định những trang đang hiển thị. Nếu một trang đang hiển thị, các đối tượng sẽ được kết xuất dưới dạng hình tròn trên canvas.

Sau đó, chúng ta lặp lại mảng blocks và vẽ từng phần của đường nhỏ. Mỗi khung sẽ giảm kích thước và thay đổi vị trí của đối tượng bằng rate. Điều này tạo ra hiệu ứng giảm và mở rộng. Nếu đối tượng thu nhỏ hoàn toàn, đối tượng đó sẽ bị xoá khỏi mảng 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)
 }

Nếu trang bị ẩn, trình nghe sự kiện sẽ bị xoá và vòng lặp khung ảnh động sẽ bị huỷ. Mảng blocks cũng được xoá.

Dưới đây là dấu vết của con trỏ trong thực tế!

Xem xét khả năng hỗ trợ tiếp cận

Việc tạo ra trải nghiệm thú vị để khám phá là điều tốt, nhưng sẽ không tốt nếu người dùng không thể truy cập vào trải nghiệm đó. Kiến thức chuyên môn của Adam trong lĩnh vực này đã đóng vai trò vô cùng quan trọng trong việc chuẩn bị cho Chrometober để được xem xét khả năng hỗ trợ tiếp cận trước khi phát hành.

Một số lĩnh vực đáng chú ý được đề cập:

  • Đảm bảo rằng HTML được sử dụng có ngữ nghĩa. Điều này bao gồm các phần tử điểm đánh dấu thích hợp như <main> cho cuốn sách; cũng như việc sử dụng phần tử <article> cho mỗi khối nội dung và phần tử <abbr> nơi giới thiệu từ viết tắt. Việc tính toán trước khi xây dựng cuốn sách đã giúp mọi thứ trở nên dễ tiếp cận hơn. Việc sử dụng tiêu đề và đường liên kết giúp người dùng dễ dàng di chuyển hơn. Việc sử dụng danh sách cho các trang cũng có nghĩa là số lượng trang được công nghệ hỗ trợ thông báo.
  • Đảm bảo rằng tất cả hình ảnh đều sử dụng các thuộc tính alt phù hợp. Đối với các SVG cùng dòng, phần tử title sẽ có mặt khi cần.
  • Sử dụng các thuộc tính aria để cải thiện trải nghiệm. Việc sử dụng aria-label cho các trang và các cạnh của trang sẽ cho người dùng biết họ đang ở trang nào. Việc sử dụng aria-describedBy trên đường liên kết "Đọc thêm" sẽ truyền đạt văn bản của khối nội dung. Điều này giúp người dùng không bị nhầm lẫn về nơi liên kết sẽ đưa họ đến.
  • Đối với chủ đề của khối nội dung, người dùng có thể nhấp vào toàn bộ thẻ chứ không chỉ đường liên kết "Đọc thêm".
  • Việc sử dụng IntersectionObserver để theo dõi những trang đang hiển thị đã được đề cập trước đó. Việc này mang lại nhiều lợi ích không chỉ liên quan đến hiệu suất. Các trang không có trong chế độ xem sẽ tạm dừng mọi hoạt ảnh hoặc hoạt động tương tác. Tuy nhiên, các trang này cũng áp dụng thuộc tính inert. Điều này có nghĩa là người dùng sử dụng trình đọc màn hình có thể khám phá cùng một nội dung như người dùng nhìn thấy. Tiêu điểm vẫn nằm trong trang đang hiển thị và người dùng không thể chuyển sang trang khác bằng phím tab.
  • Cuối cùng nhưng không kém phần quan trọng, chúng ta sử dụng truy vấn nội dung nghe nhìn để tuân theo lựa chọn ưu tiên của người dùng về chuyển động.

Dưới đây là ảnh chụp màn hình trong bài đánh giá nêu bật một số biện pháp hiện có.

được xác định là xung quanh toàn bộ cuốn sách, cho biết đây phải là điểm mốc chính để người dùng công nghệ hỗ trợ tìm thấy. Bạn có thể xem thêm trong ảnh chụp màn hình." width="800" height="465">

Ảnh chụp màn hình cuốn sách đang mở trên Chrometober. Các hộp được viền màu xanh lục được cung cấp xung quanh nhiều khía cạnh của giao diện người dùng, mô tả chức năng hỗ trợ tiếp cận dự kiến và kết quả trải nghiệm người dùng mà trang sẽ mang lại. Ví dụ: hình ảnh có văn bản thay thế. Một ví dụ khác là nhãn hỗ trợ tiếp cận khai báo rằng các trang nằm ngoài chế độ xem là không hoạt động. Bạn có thể xem thêm trong ảnh chụp màn hình.

Điều chúng tôi học được

Mục đích của Chrometober không chỉ là để làm nổi bật nội dung web của cộng đồng, mà còn là một cách để chúng tôi thử nghiệm polyfill API ảnh động liên kết với thao tác cuộn đang trong quá trình phát triển.

Chúng tôi đã dành một buổi trong hội nghị toàn đội ở New York để kiểm thử dự án và giải quyết các vấn đề phát sinh. Đóng góp của nhóm là vô giá. Đây cũng là cơ hội tuyệt vời để liệt kê tất cả những việc cần giải quyết trước khi chúng tôi có thể phát hành.

Nhóm CSS, giao diện người dùng và DevTools ngồi quanh bàn trong phòng họp. Una đứng trước một bảng trắng được dán đầy ghi chú. Những thành viên khác trong nhóm ngồi quanh bàn, bên cạnh đồ uống và máy tính xách tay.

Ví dụ: việc kiểm thử sách trên thiết bị đã gây ra vấn đề về kết xuất. Cuốn sách của chúng tôi không hiển thị như mong đợi trên thiết bị iOS. Các đơn vị khung nhìn sẽ định kích thước trang, nhưng khi có một vết khía, vết cắt sẽ ảnh hưởng đến sách. Giải pháp là sử dụng viewport-fit=cover trong khung nhìn meta:

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

Phiên này cũng nêu ra một số vấn đề với API polyfill. Bramus đã nêu các vấn đề này trong kho lưu trữ polyfill. Sau đó, anh đã tìm ra giải pháp cho những vấn đề đó và hợp nhất chúng vào polyfill. Ví dụ: yêu cầu kéo này đã cải thiện hiệu suất bằng cách thêm tính năng lưu vào bộ nhớ đệm vào một phần của polyfill.

Ảnh chụp màn hình của một bản minh hoạ đang mở trong Chrome. Công cụ cho nhà phát triển hiện đang mở và hiển thị kết quả đo lường hiệu suất cơ sở.

Ảnh chụp màn hình của một bản minh hoạ đang mở trong Chrome. Công cụ dành cho nhà phát triển đang mở và cho thấy kết quả đo lường hiệu suất được cải thiện.

Vậy là xong!

Đây là một dự án thú vị để làm việc, mang đến trải nghiệm cuộn ngẫu hứng làm nổi bật nội dung tuyệt vời của cộng đồng. Không chỉ vậy, công cụ này còn rất hữu ích để kiểm thử polyfill, cũng như cung cấp ý kiến phản hồi cho nhóm kỹ thuật để giúp cải thiện polyfill.

Chrometober 2022 đã kết thúc.

Chúng tôi hy vọng bạn sẽ thích! Bạn thích tính năng nào nhất? Hãy twitt cho tôi và cho chúng tôi biết nhé!

Jhey đang cầm một tờ hình dán gồm các nhân vật trong Chrometober.

Bạn thậm chí có thể lấy một số hình dán từ một thành viên trong nhóm nếu bạn gặp chúng tôi tại sự kiện.

Ảnh chính của David Menidrey trên Unsplash