Đang xây dựng Chrometober!

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

Tiếp theo từ Designcember, chúng tôi muốn xây dựng Chrometober cho bạn trong năm nay như một cách để làm nổi bật và chia sẻ nội dung trên web của cộng đồng và nhóm Chrome. Designcember đã giới thiệu về việc 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 cuộn của 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 này là mang đến trải nghiệm độc đáo làm nổi bật API Ảnh động liên kết cuộn. Tuy nhiên, mặc dù khác thường, trải nghiệm đó cũng cần phải có khả năng thích ứng và dễ tiếp cận. Dự án này cũng là một cách tuyệt vời để chạy thử polyfill API đang được phát triển, đồng thời thử kết hợp nhiều kỹ thuật và công cụ. Và tất cả đều có chủ đề Halloween lễ hội!

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

Soạn thảo trải nghiệm cuộn dạng cuộn

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

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

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

Khi người dùng cuộn sang một bên, các khối sẽ xoay và điều chỉnh theo tỷ lệ. 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ể làm cho trải nghiệm này trở nên tuyệt vời cho người dùng trên các thiết bị thuộc mọi kích thước. Thay vào đó, tôi nghiêng về thiết kế của một thứ tôi đã làm trước đây. Năm 2020, tôi may mắn được tiếp cận ScrollTrigger của GreenSock để tạo bản minh hoạ cho bản phát hành.

Một trong những bản minh hoạ tôi đã xây dựng là một cuốn sách 3D-CSS trong đó các trang quay khi bạn cuộn và điều này cảm thấ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 cuộn là sự hoán đổi hoàn hảo cho chức năng đó. Như bạn sẽ thấy, cách này cũng hoạt động tốt với scroll-snap!

Hoạ sĩ minh hoạ của chúng tôi cho dự án, Tyler Reed, đã rất giỏi trong việc thay đổi thiết kế khi chúng tôi thay đổi ý tưởng. Tyler đã làm rất tốt công việc của mình là biến mọi ý tưởng sáng tạo thành hiện thực. Có rất nhiều ý tưởng lên ý tưởng cùng nhau thú vị. Một phần quan trọng khi chúng tôi muốn thực hiện điều này là việc chia các tính năng thành các khối riêng biệt. Bằng cách đó, chúng tôi có thể bố trí cảnh quay rồi chọn ra những gì chúng tôi thể hiện sống động.

Một trong những cảnh bố cục có hình ảnh một con rắn, một chiếc quan tài với hai cánh tay dang ra, một con cáo với cây đũa thần ở trên vạc, một cái cây có khuôn mặt ma quái và một gargoy đang cầm đèn lồng bí ngô.

Ý tưởng chính là khi người dùng duyệt qua cuốn sách, họ có thể truy cập các khối nội dung. Người dùng cũng có thể tương tác với những dấu gạch ngang bất thường, bao gồm cả những quả trứng Phục sinh mà chúng tôi đã tích hợp vào trải nghiệm này; ví dụ: ảnh chân dung trong một ngôi nhà ma ám, có mắt nhìn theo con trỏ hoặc hình động tinh tế do các cụm từ tìm kiếm nội dung nghe nhìn kích hoạt. Những ý tưởng và tính năng này sẽ được tạo ảnh động khi cuộn. Ý tưởng ban đầu là một chú thỏ thây ma sẽ mọc lên và dịch 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 tôi cần một cuốn sách. Vì vậy, chúng tôi quyết định biến cơ hội này thành cơ hội để thử nghiệm bộ tính năng cho API ảnh động liên kết cuộn của CSS mới nổi. API ảnh động liên kết 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 này, các kỹ sư trong nhóm tương tác đã nghiên cứu polyfill. Đây là 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, và các dự án thú vị như thế này thường là nơi tuyệt vời để bạn dùng thử các tính năng thử nghiệm cũng như để chia sẻ ý kiến phản hồi. Hãy tìm hiểu những gì chúng tôi học được và phản hồi mà chúng tôi có thể cung cấp ở phần sau của bài viết này.

Ở 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—điều này có thể xuất hiện sau đó. Hoạt ảnh liên kết cuộn cũng thuộc hai danh mục chính:

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

Để tạo thuộc tính 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à 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 ViewTimeline bằng view-timeline-name và xác định trục cho thành phần này. Trong ví dụ này, block tham chiếu đến logic block. Ảnh động này đượ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 các điểm mà ảnh động cần được liên kết, xét về vị trí của một phần tử trong vùng chứa cuộn. Trong ví dụ này, chúng ta nói bắt đầu ảnh động khi phần tử nhập (enter 0%) vùng chứa cuộn. Và kết thúc khi đã bao phủ 50% (cover 50%) vùng chứa cuộn.

Dưới đây là ví dụ minh hoạ cách hoạt động của chúng tôi:

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ử. Việc này phù hợp với các trường hợp như ảnh động dạ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 sử dụ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 thao tác này,"Mover" sẽ mở rộng khi xuất hiện trong khung nhìn, kích hoạt chế độ xoay của "Spinner".

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

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

Sau một vài thử nghiệm, tôi đã có được một 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 các điều kiện kích hoạt được đánh dấu 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ẽ lật ngược, nhưng mở ra hoặc đóng lại. Điều này phụ thuộc vào cách căn chỉnh chụp nhanh của trình 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 mang đến thêm một lợi ích là 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ì 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 tôi sẽ tạo một ViewTimeline. Sau đó, chúng ta tạo ảnh động cho trang được liên kết của điều kiện kích hoạt bằng ViewTimeline đó. Liên kết ảnh động của trang để 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 tôi cũng dịch trang đó trên trục z để trang hoạt động như một cuốn sách.

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

Sau khi tìm ra cơ chế của cuốn sách, tôi có thể tập trung vào việc đưa hình minh hoạ của Tyler vào thực tế.

Phi hành gia

Nhóm đã sử dụng Astro cho Designcember vào năm 2021 và tôi rất muốn sử dụng lại Astro này cho Chrometober. Trải nghiệm của nhà phát triển về việc chia nhỏ mọi thứ thành nhiều thành phần là điều rất 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 phía trang là các thành phần có thể thêm, xoá và định vị một cách dễ dàng.

Tạo sách

Điều quan trọng là tôi phải làm cho các quy tắc trở nên dễ quản lý. Tôi cũng muốn giúp những người còn lại trong nhóm dễ dàng đóng góp ý kiến.

Các trang ở cấp cao được xác định bằng một mảng cấu hình. Mỗi đối tượng trang trong mảng này xác định nội dung, phông nền và các 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 toán tử này sẽ đượ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. Cơ chế sử dụng cùng một nguyên mẫu được sử dụng; nhưng chúng tôi dùng chung nhiều thực thể của ViewTimeline được tạo trên toàn hệ thống.

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 ở nơi khác thay vì tạo lại. Chúng ta sẽ nói thêm về điều này ở phần sau.

Thành phần của 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. Các trang này sử dụng tính năng vị trí của Astro để chèn nội dung vào từng 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. Cộng tác viên có thể làm việc trên phần lớn nội dung của cuốn sách mà không cần phải chạm vào mã này.

Phông nền

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

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

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

.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 tìm hiểu việc xây dựng một trong các trang này. Trang ba mô tả một con cú bật lên trên cây.

Lớp này được điền sẵn thành phần PageThree, như được xác định trong cấu hình. Đó 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ó một hàng rào mã ở trên cùng tương tự như trình giao diện trước. Đ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 3 sẽ 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 rằng về bản chất các trang là không thể phân chia. Các ứng dụng này được xây dựng từ một tập hợp các tính năng. Trang ba có một khối nội dung và con cú tương tác, do đó có một thành phần cho mỗi khối.

Khối nội dung là các đường liên kết đến nội dung hiển thị trong sách. Các toán tử 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 tôi sử dụng thành phần của trang làm nơi đặt 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, kiểu chung của một khối nội dung được đặt cùng vị trí 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%;
}

Đối với ows của chúng tôi, đây là một tính năng tương tác — một trong số nhiều tính năng trong dự án này. Đây là một ví dụ nhỏ minh hoạ cách chúng tôi sử dụng ViewDòng thời gian được chia sẻ mà chúng tôi đã tạo.

Ở cấp độ cao, thành phần con cú của chúng ta sẽ nhập một số tệp SVG và nội tuyến nó bằ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ị trí với mã thành phần.

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

Có thêm một phần định kiểu 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 ảnh hưởng đến transform-origin. Giá trị này so với hộp giới hạn của đối tượng trong SVG. Con cú mở rộng từ chính giữa dưới cùng, do đó sử dụng transform-origin: 50% 100%.

Điều 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 sẽ thực hiện 2 việc:

  1. Kiểm tra các lựa chọn ưu tiên về chuyển động của người dùng.
  2. Nếu các ảnh độ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.

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

Tính năng sách

Giờ bạn đã biết phương pháp xây dựng một trang cũng như cách thức hoạt động của cấu trúc dự án. Bạn có thể xem cách Chế độ 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 quá trình lật trang sách; ví dụ: con dơi bay vào và bay ra khi lật trang.

Lớp này cũng có các phần tử được hỗ trợ bởi ảnh động CSS.

Sau khi các khối nội dung xuất hiện trong sách, bạn có thời gian để sáng tạo với các tính năng khác. Việc này đã mang đến cơ hội tạo ra một số lượt tương tác và thử nhiều cách để triển khai.

Duy trì khả năng thích ứng

Đơn vị khung nhìn thích ứng kích thước sách và các tính năng của sách. Tuy nhiên, việc duy trì tính 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 là lựa chọn phù hợp ở đây. Tuy nhiên, chúng chưa được hỗ trợ ở mọi nơi. Kích thước của sách đã được đặt, vì vậy, 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à 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ô toả sáng vào ban đêm

Những người tinh tế có thể đã nhận thấy việc sử dụng các phần tử <source> khi thảo luận về phông nền của trang trước đó. Una muốn có được lượt tương tác thể hiện cảm xúc với lựa chọn ưu tiên của người dùng trong 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 các truy vấn nội dung nghe nhìn với phần tử <picture>, nên đây là một cách hay để 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 2 phản ứng với lựa chọn ưu tiên của người dùng trong bảng phối màu. SVG được sử dụng có các vòng tròn tượng trưng cho ngọn lửa, tăng kích thước 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;
     }
   }
 }

Có phải bức chân dung này đang theo dõi bạn không?

Nếu xem trang 10, bạn có thể nhận thấy điều gì đó. Bạn đang bị theo dõi! Các mắt của chân dung sẽ theo dõi con trỏ khi bạn di chuyển xung quanh trang. Bí quyết ở đây là liên kết vị trí con trỏ với 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 rồi ánh xạ các giá trị đã cho. Ví dụ: trường hợp sử dụng này sẽ cho giá trị là 625.

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

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

Sau đó, chúng ta liên kết chúng lại với nhau và cập nhật các giá trị thuộc tính tuỳ chỉnh CSS về mắt để mắt có thể di chuyển. Một hàm được liên kết với sự kiện pointermove dựa trên window. Khi kích hoạt này, các giới hạn của mỗi mắt sẽ được dùng để tính các điểm giữa. Sau đó, vị trí con trỏ được ánh xạ tới các giá trị được đặt làm giá trị thuộc tính tùy 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 chuyển vào CSS, các kiểu có thể thực hiện những việc họ muốn với chúng. Điều tuyệt vời ở đây là sử dụng CSS clamp() để tạo hành vi khác nhau cho mỗi mắt, nhờ đó, bạn có thể làm cho mỗi mắt hoạt động khác nhau 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);
 }

Phép thuật

Khi xem trang sáu, bạn có cảm thấy lôi cuốn không? Trang này sở hữu thiết kế chú cáo kỳ diệu của chúng tô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. này sử dụng ảnh động canvas. Phần tử <canvas> nằm phía trên phần nội dung còn lại của trang và có 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;
}

Tương tự như cách chân dung theo dõi sự kiện pointermove trên window, phần tử <canvas> của chúng ta 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 biểu thị các hình dạng được sử dụng trong vệt con trỏ. Chúng có toạ độ và màu sắc ngẫu nhiên.

Hàm mapRange ở phần trước được dùng lại, vì chúng ta có thể dùng hàm này để ánh xạ delta con trỏ tớ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 nơi cần vẽ.

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 sẽ được tạo với requestAnimationFrame. Vệt con trỏ chỉ hiển thị khi trang đang được xem. Chúng tôi có một IntersectionObserver cập nhật và xác định trang nào đang được xem. Nếu một trang đang hiển thị, các đối tượng sẽ hiển thị dưới dạng vòng tròn trên canvas.

Sau đó, chúng ta lặp lại mảng blocks rồi vẽ từng phần của đường nhỏ. Mỗi khung sẽ làm 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à chia tỷ lệ. Nếu đối tượng thu nhỏ hoàn toàn, thì đố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 không xuất hiện trong chế độ xem, 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 bị xoá.

Sau đây là đường dẫn hoạt động của con trỏ!

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

Việc tạo ra một trải nghiệm thú vị để khám phá sẽ rất tốt, nhưng nếu người dùng không truy cập được thì sẽ chẳng có ích gì. Chuyên môn của Adam trong lĩnh vực này đã chứng minh vô giá trong việc chuẩn bị Chrometober cho quy trình đánh giá khả năng hỗ trợ tiếp cận trước khi phát hành.

Sau đây là một số khía cạnh đáng chú ý:

  • Đảm bảo HTML được sử dụng có ngữ nghĩa. Trong đó có các phần tử điểm mốc thích hợp như <main> cho cuốn sách; việc sử dụng phần tử <article> cho mỗi khối nội dung và các phần tử <abbr> có từ viết tắt. Với việc suy nghĩ trước khi cuốn sách được tạo ra, mọi thứ 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 điều hướng dễ dàng 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 thông báo bằng công nghệ hỗ trợ.
  • Đảm bảo rằng tất cả hình ảnh đều sử dụng thuộc tính alt thích hợp. Đối với các tệp 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 các đườ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 loại bỏ sự không rõ ràng về vị trí mà đường liên kết sẽ đưa người dùng đế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ỉ có đường liên kết "Đọc thêm".
  • Sử dụng IntersectionObserver để theo dõi trang nào đang hiển thị trước đó. Điều 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 hiển thị sẽ bị tạm dừng bất kỳ ảnh động hoặc tương tác nào. Nhưng những trang này cũng được á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 thị giác. Tiêu điểm vẫn nằm trong trang bạn đang xem và người dùng không thể chuyển sang trang khác.
  • Cuối cùng nhưng không kém phần quan trọng, chúng tôi sử dụng truy vấn đa phương tiện để tôn trọng sở thích chuyển động của người dùng.

Sau đây là ảnh chụp màn hình trong một bài đánh giá, trong đó nêu bật một số biện pháp đang được áp dụng.

được xác định là bao 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. Thông tin khác được nêu trong ảnh chụp màn hình." width="800" height="465">

Ảnh chụp màn hình sách trên Chrometober đang mở. Các hộp có đường viền màu xanh lục được cung cấp xung quanh các khía cạnh khác nhau 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ả về 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 tuyên bố rằng các trang nằm ngoài khung hiển thị. Thông tin khác được nêu trong ảnh chụp màn hình.

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

Động lực thôi thúc Chrometober không chỉ nêu bật nội dung trên web trong cộng đồng, mà còn là một cách để chúng tôi chạy thử nghiệm polyfill API cho hoạt ảnh liên kết cuộn đang trong quá trình phát triển.

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

Nhóm CSS, giao diện người dùng và Công cụ cho nhà phát triển ngồi quanh bàn trong phòng hội nghị. Una đứng bên chiếc bảng trắng giấy dán giấy. Các thành viên khác trong nhóm ngồi quanh bàn cùng đồ uống và máy tính xách tay.

Ví dụ: việc kiểm thử cuốn sách trên các thiết bị đã gây ra vấn đề kết xuất. Sách của chúng tôi sẽ không hiển thị như mong đợi trên các thiết bị iOS. Đơn vị khung nhìn định kích thước trang, nhưng khi có khía cạnh, điều này đã ả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 phát sinh một số vấn đề về polyfill API. Bramus đã nêu ra những vấn đề này trong kho lưu trữ polyfill. Sau đó, ông đã 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 đã tăng hiệu suất bằng cách thêm chức 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 bản minh hoạ đang mở trong Chrome. Công cụ cho nhà phát triển đang mở và hiển thị số liệu đo lường hiệu suất cơ sở.

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

Vậy là xong!

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

Chrometober 2022 là một bản tóm tắt.

Chúng tôi hy vọng bạn thích chương trình này! Bạn thích tính năng nào? Tweet cho tôi và cho chúng tôi biết nhé!

Jhey đang cầm một tờ hình dán có 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 trong nhóm nếu bạn gặp chúng tôi tại một sự kiện.

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