Cách tạo ra cuốn sách cuộn để chia sẻ các mẹo và thủ thuật thú vị và đáng sợ trong tháng 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 cuộn 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ái, 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 polyfill API đ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ấu trúc nhóm của chúng tôi 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.
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 đã may mắn có quyền truy cập vào 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!
Họa sĩ minh hoạ cho dự án này, Tyler Reed, đã rất linh hoạt 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.
Ý tưởng chính là khi người dùng đọc 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ả những 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.
Nói chung, bạn có thể sử dụng API này để liên kết ảnh động với thao tác 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:
- Những thành phần phản ứng với vị trí cuộn.
- 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 phần sau, 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 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-delay
và animation-end-delay
(tại thời điểm viết) là cách chúng ta 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 sẽ được liên kết liên quan đến vị trí của một phần tử trong vùng chứa cuộn. Trong ví dụ này, chúng ta sẽ bắt đầu ảnh động khi phần tử này vào (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.
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 rất 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 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 các trình kích hoạt được làm nổi bật bằng đường viền đứt khúc.
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 cách căn chỉnh cuộn-nhanh của các 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 có thêm 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 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 sẽ xoay một phần tử của trang trên trục y để xoay 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 ra cơ chế cho cuốn sách, tôi có thể tập trung vào việc làm cho các hình 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ằng 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 giá trị 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. 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ẻ tiến trình để sử dụng ở nơi khác thay vì tạo lại tiến trình. 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
. Các trang sử dụng tính năng khe 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. 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
Việc chuyển đổi mẫu quảng cáo sang sách giúp việc chia các phần trở nên dễ dàng hơn nhiều và mỗi trang của sách là một cảnh lấy từ thiết kế ban đầu.
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.
Thuộc tính 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 rằng 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 cần 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
ả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 thực hiện hai việc:
- Kiểm tra lựa chọn ưu tiên về chuyển động của người dùng.
- 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
. Thuộc tính này đượ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 lật 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
. Hàm này xác định rằng khi trang được xoay 80%, hãy bắt đầu di chuyển 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.
Trang này cũng có các phần tử được cung cấp bởi ảnh động CSS.
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á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 cuốn 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à 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 việc sử dụng các phần tử <source>
khi thảo luận về phông nền trang trước đó. 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 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 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 đại diện cho ngọn lửa, được điều chỉnh theo tỷ lệ và tạo ảnh động ở 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ẽ cho giá trị 625.
mapRange(0, 100, 250, 1000, 50) // 625
Đối với chân dung, giá trị đầu vào là điểm giữa của mỗi mắt, cộng hoặc trừ một số 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 trung tâm 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
Nếu xem trang 6, bạn có cảm thấy bị mê hoặc 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 với 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 bức ảnh chân dung nghe 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ể sử dụng hàm này để liên kết delta con trỏ với size
và rate
. 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 cho mỗi đối tượng sẽ cho phần tử <canvas>
biết vị trí 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 đượ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 mòn. Mỗi khung hình giảm kích thước và thay đổi vị trí của đối tượng theo rate
. Điều này tạo ra hiệu ứng rơi và điều chỉnh theo tỷ lệ. 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ố trang được công bố bằng công nghệ hỗ trợ. - Đả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 SVG cùng dòng, phần tửtitle
sẽ xuất hiện 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ụngaria-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ụngaria-describedBy
trên các đường liên kết "Đọc thêm" sẽ truyền tải 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. - Về chủ đề 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. Mọi ảnh động hoặc lượt tương tác trên các trang không hiển thị sẽ bị tạm dừng. Tuy nhiên, các trang này cũng áp dụng thuộc tínhinert
. Đ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 bình thường. 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 đa phương tiệ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 của quy trình xem xét, trong đó nêu bật một số biện pháp đã được áp dụng.
đượ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">
Đ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 phiên họp 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.
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 xác định kích thước trang, nhưng khi có một rãnh, nó sẽ ảnh hưởng đến cuố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.
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é!
Bạn thậm chí có thể nhận được một số hình dán từ một thành viên trong nhóm nếu gặp chúng tôi tại một sự kiện.
Ảnh chính do David Menidrey chụp trên Unsplash