איך הספר הנגלל נוצר כדי לשתף טיפים וטריקים מהנים ומפחידים ב-Chrometober.
בהמשך ל-Designcember, רצינו ליצור השנה את Chrometober כדי להדגיש ולשתף תוכן מהאינטרנט מהקהילה ומצוות Chrome. ב-Designcember הצגנו את השימוש בשאילתות מאגר, אבל השנה אנחנו מציגים את ה-API של אנימציות מקושרות לגלילה ב-CSS.
אתם יכולים לראות את חוויית הגלילה בספר בכתובת web.dev/chrometober-2022.
סקירה כללית
מטרת הפרויקט הייתה לספק חוויה משעשעת שמדגישה את ה-API של אנימציות שמקושרות לגלילה. אבל למרות שהחוויה הייתה משעשעת, היא הייתה צריכה להיות גם רספונסיבית ונגישה. הפרויקט גם היה דרך מצוינת לבדוק את ה-polyfill של ה-API שנמצא בפיתוח פעיל, וגם לנסות שילוב של טכניקות וכלים שונים. והכול באווירה חגיגית של האלווין!
מבנה הצוות שלנו נראה כך:
- Tyler Reed: איור ועיצוב
- ג'יי טומפקינס: ראש תחום אדריכלות וקריאייטיב
- אונה קראבץ: מובילת הפרויקט
- Bramus Van Damme: תורם לאתר
- Adam Argyle: בדיקת נגישות
- אהרון פורינטון: קופירייטינג
יצירת טיוטה של חוויית Scrollytelling
הרעיונות ל-Chrometober התחילו לזרום במפגש הראשון של הצוות מחוץ למשרד במאי 2022. אוסף של שרבוטים גרם לנו לחשוב על דרכים שבהן משתמש יכול לגלול לאורך סטוריבורד כלשהו. בהשראת משחקי וידאו, שקלנו חוויית גלילה דרך סצנות כמו בתי קברות ובית רדוף רוחות.

היה מרגש לקבל את החופש היצירתי לקחת את הפרויקט הראשון שלי ב-Google לכיוון לא צפוי. זה היה אב טיפוס מוקדם של האופן שבו משתמש יכול לנווט בתוכן.
כשהמשתמש גולל הצידה, הבלוקים מסתובבים ומשנים את הגודל שלהם. אבל החלטתי לוותר על הרעיון הזה כי לא הייתי בטוח איך אפשר ליצור חוויית משתמש טובה במכשירים בכל הגדלים. במקום זאת, נטיתי לעיצוב של משהו שיצרתי בעבר. בשנת 2020, היה לי מזל שהייתה לי גישה ל-ScrollTrigger של GreenSock כדי ליצור הדגמות של גרסאות.
אחד מהדמואים שיצרתי היה ספר 3D-CSS שבו הדפים מתהפכים כשגוללים, וזה הרגיש הרבה יותר מתאים למה שרצינו ל-Chrometober. ה-API של אנימציות מקושרות לגלילה הוא תחליף מושלם לפונקציונליות הזו. הוא גם עובד טוב עם scroll-snap, כמו שאפשר לראות.
המאייר שלנו בפרויקט, טיילר ריד, היה מצוין בשינוי העיצוב כשהרעיונות שלנו השתנו. טיילר עשה עבודה נהדרת, לקח את כל הרעיונות היצירתיים שזרקו לעברו והפך אותם למציאות. היה כיף לעשות סיעור מוחות ביחד. חלק גדול מהאופן שבו רצינו שזה יעבוד היה פיצול התכונות לבלוקים מבודדים. כך יכולנו להרכיב מהם סצנות ואז לבחור מה להנפיש.

הרעיון המרכזי היה שככל שהמשתמשים מתקדמים בספר, הם יכולים לגשת לבלוקים של תוכן. הם גם יכלו לקיים אינטראקציה עם נגיעות של גחמנות, כולל ה-Easter eggs שבנינו בתוך החוויה. לדוגמה, דיוקן בבית רדוף רוחות, שהעיניים שלו עקבו אחרי הסמן, או אנימציות עדינות שהופעלו על ידי שאילתות מדיה. הרעיונות והתכונות האלה יונפשו בזמן הגלילה. אחד הרעיונות הראשונים היה ארנב זומבי שקם וזז לאורך ציר ה-X כשהמשתמש גולל.
היכרות עם ה-API
לפני שהתחלנו לשחק עם תכונות ספציפיות ועם ביצי פסחא, היינו צריכים ספר. לכן החלטנו לנצל את ההזדמנות הזו כדי לבדוק את קבוצת התכונות של CSS scroll-linked animations API החדש. כרגע אין תמיכה ב-API של אנימציות מקושרות לגלילה באף דפדפן. עם זאת, במהלך פיתוח ה-API, המהנדסים בצוות האינטראקציות עבדו על polyfill. כך אפשר לבדוק את ה-API בזמן שהוא בפיתוח. זה אומר שאנחנו יכולים להשתמש ב-API הזה כבר היום, ופרויקטים מהנים כאלה הם בדרך כלל מקום מצוין לנסות תכונות ניסיוניות ולתת משוב. בהמשך המאמר נסביר מה למדנו ואיזה משוב הצלחנו לספק.
ברמה הכללית, אתם יכולים להשתמש ב-API הזה כדי לקשר אנימציות לגלילה. חשוב לציין שאי אפשר להפעיל אנימציה בגלילה – יכול להיות שנוסיף את האפשרות הזו בהמשך. גם אנימציות שמקושרות לגלילה מתחלקות לשתי קטגוריות עיקריות:
- אלה שמגיבים למיקום הגלילה.
- אנימציות שמגיבות למיקום של רכיב במאגר הגלילה שלו.
כדי ליצור את האפשרות השנייה, אנחנו משתמשים ב-ViewTimeline שמוחל באמצעות מאפיין animation-timeline.
הנה דוגמה לשימוש ב-ViewTimeline ב-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;
}
}
אנחנו יוצרים ViewTimeline עם view-timeline-name ומגדירים את הציר שלו. בדוגמה הזו, block מתייחס ל-block לוגי. האנימציה מקושרת לגלילה באמצעות המאפיין animation-timeline. animation-delay ו-animation-end-delay (בזמן כתיבת המאמר) הם האופן שבו אנחנו מגדירים שלבים.
השלבים האלה מגדירים את הנקודות שבהן האנימציה צריכה להיות מקושרת ביחס למיקום של רכיב במאגר הגלילה שלו. בדוגמה שלנו, אנחנו אומרים להתחיל את האנימציה כשהרכיב נכנס (enter 0%) למאגר הגלילה. והיא תסתיים כשהיא תכסה 50% (cover 50%) מהקונטיינר של הגלילה.
הנה הדגמה של התהליך:
אפשר גם לקשר אנימציה לאלמנט שזז באזור התצוגה. כדי לעשות את זה, מגדירים את animation-timeline בתור view-timeline של הרכיב. האפשרות הזו מתאימה לתרחישים כמו אנימציות של רשימות. ההתנהגות דומה לאופן שבו אפשר להוסיף אנימציה לרכיבים בכניסה באמצעות IntersectionObserver.
element-moving-in-viewport {
view-timeline-name: foo;
view-timeline-axis: block;
animation: scale both linear;
animation-delay: enter 0%;
animation-end-delay: cover 50%;
animation-timeline: foo;
}
@keyframes scale {
0% {
scale: 0;
}
}
כך,"Mover" גדל כשהוא נכנס לאזור התצוגה, ומפעיל את הסיבוב של "Spinner".
מהניסויים שערכתי גיליתי שה-API עובד טוב מאוד עם scroll-snap. גלילה עם נקודות עצירה בשילוב עם ViewTimeline תתאים מאוד להצמדה של דפדוף בספר.
יצירת אב טיפוס של המכניקה
אחרי כמה ניסיונות, הצלחתי ליצור אב טיפוס של ספר. גוללים אופקית כדי לדפדף בספר.
בהדגמה, אפשר לראות את הטריגרים השונים מודגשים בגבולות מקווקווים.
תגי העיצוב נראים בערך כך:
<body>
<div class="book-placeholder">
<ul class="book" style="--count: 7;">
<li
class="page page--cover page--cover-front"
data-scroll-target="1"
style="--index: 0;"
>
<div class="page__paper">
<div class="page__side page__side--front"></div>
<div class="page__side page__side--back"></div>
</div>
</li>
<!-- Markup for other pages here -->
</ul>
</div>
<div>
<p>intro spacer</p>
</div>
<div data-scroll-intro>
<p>scale trigger</p>
</div>
<div data-scroll-trigger="1">
<p>page trigger</p>
</div>
<!-- Markup for other triggers here -->
</body>
כשגוללים, הדפים של הספר נפתחים או נסגרים. זה תלוי ביישור של הטריגרים לגלילה עם נקודות עצירה.
html {
scroll-snap-type: x mandatory;
}
body {
grid-template-columns: repeat(var(--trigger-count), auto);
overflow-y: hidden;
overflow-x: scroll;
display: grid;
}
body > [data-scroll-trigger] {
height: 100vh;
width: clamp(10rem, 10vw, 300px);
}
body > [data-scroll-trigger] {
scroll-snap-align: end;
}
הפעם, אנחנו לא מחברים את ViewTimeline ב-CSS, אלא משתמשים ב-Web Animations API ב-JavaScript. יתרון נוסף הוא שאפשר לעבור על קבוצה של רכיבים וליצור את ה-ViewTimeline שאנחנו צריכים, במקום ליצור כל אחד מהם באופן ידני.
const triggers = document.querySelectorAll("[data-scroll-trigger]")
const commonProps = {
delay: { phase: "enter", percent: CSS.percent(0) },
endDelay: { phase: "enter", percent: CSS.percent(100) },
fill: "both"
}
const setupPage = (trigger, index) => {
const target = document.querySelector(
`[data-scroll-target="${trigger.getAttribute("data-scroll-trigger")}"]`
);
const viewTimeline = new ViewTimeline({
subject: trigger,
axis: 'inline',
});
target.animate(
[
{
transform: `translateZ(${(triggers.length - index) * 2}px)`
},
{
transform: `translateZ(${(triggers.length - index) * 2}px)`,
offset: 0.75
},
{
transform: `translateZ(${(triggers.length - index) * -1}px)`
}
],
{
timeline: viewTimeline,
…commonProps,
}
);
target.querySelector(".page__paper").animate(
[
{
transform: "rotateY(0deg)"
},
{
transform: "rotateY(-180deg)"
}
],
{
timeline: viewTimeline,
…commonProps,
}
);
};
const triggers = document.querySelectorAll('[data-scroll-trigger]')
triggers.forEach(setupPage);
לכל טריגר, אנחנו יוצרים ViewTimeline. אחר כך אנחנו מנפישים את הדף שמשויך לטריגר באמצעות ה-ViewTimeline הזה. הפעולה הזו מקשרת את האנימציה של הדף לגלילה. באנימציה שלנו, אנחנו מסובבים רכיב של הדף בציר ה-Y כדי להפוך את הדף. אנחנו גם מתרגמים את הדף עצמו בציר ה-Z כדי שהוא יתנהג כמו ספר.
סיכום של כל המידע
אחרי שהבנתי איך הספר עובד, יכולתי להתמקד בהנפשה של האיורים של טיילר.
צילום אסטרונומי
הצוות השתמש ב-Astro ב-Designcember ב-2021, ורציתי להשתמש בו שוב ב-Chrometober. היכולת של המפתחים לפרק דברים לרכיבים מתאימה מאוד לפרויקט הזה.
הספר עצמו הוא רכיב. הוא גם אוסף של רכיבי דף. לכל דף יש שני צדדים ורקעים. הצאצאים של צד הדף הם רכיבים שאפשר להוסיף, להסיר ולמקם בקלות.
יצירת אלבום
היה לי חשוב שיהיה קל לנהל את הבלוקים. רציתי גם שיהיה קל לשאר חברי הצוות לתרום.
הדפים ברמה הגבוהה מוגדרים על ידי מערך הגדרות. כל אובייקט דף במערך מגדיר את התוכן, הרקע ומטא-נתונים אחרים של דף.
const pages = [
{
front: {
marked: true,
content: PageTwo,
backdrop: spreadOne,
darkBackdrop: spreadOneDark
},
back: {
content: PageThree,
backdrop: spreadTwo,
darkBackdrop: spreadTwoDark
},
aria: `page 1`
},
/* Obfuscated page objects */
]
הם מועברים לרכיב Book.
<Book pages={pages} />
הרכיב Book הוא המקום שבו מנגנון הגלילה מופעל ודפי הספר נוצרים. נעשה שימוש באותו מנגנון מהאב טיפוס, אבל אנחנו משתפים כמה מופעים של ViewTimeline שנוצרים באופן גלובלי.
window.CHROMETOBER_TIMELINES.push(viewTimeline);
כך אנחנו יכולים לשתף את צירי הזמן כדי להשתמש בהם במקום אחר, במקום ליצור אותם מחדש. בהמשך נרחיב בנושא הזה.
הרכב הדף
כל דף הוא פריט ברשימה:
<ul class="book">
{
pages.map((page, index) => {
const FrontSlot = page.front.content
const BackSlot = page.back.content
return (
<Page
index={index}
cover={page.cover}
aria={page.aria}
backdrop={
{
front: {
light: page.front.backdrop,
dark: page.front.darkBackdrop
},
back: {
light: page.back.backdrop,
dark: page.back.darkBackdrop
}
}
}>
{page.front.content && <FrontSlot slot="front" />}
{page.back.content && <BackSlot slot="back" />}
</Page>
)
})
}
</ul>
וההגדרה שנקבעה מועברת לכל מכונה של Page. הדפים משתמשים בתכונת המשבצת של Astro כדי להוסיף תוכן לכל דף.
<li
class={className}
data-scroll-target={target}
style={`--index:${index};`}
aria-label={aria}
>
<div class="page__paper">
<div
class="page__side page__side--front"
aria-label={`Right page of ${index}`}
>
<picture>
<source
srcset={darkFront}
media="(prefers-color-scheme: dark)"
height="214"
width="150"
>
<img
src={lightFront}
class="page__background page__background--right"
alt=""
aria-hidden="true"
height="214"
width="150"
>
</picture>
<div class="page__content">
<slot name="front" />
</div>
</div>
<!-- Markup for back page -->
</div>
</li>
הקוד הזה משמש בעיקר להגדרת המבנה. השותפים יכולים לעבוד על תוכן הספר בלי לגעת בקוד הזה.
רקעים
השינוי היצירתי לכיוון של ספר הקל מאוד על חלוקת הקטעים, וכל כפולה בספר היא סצנה שנלקחה מהעיצוב המקורי.

כבר החלטנו על יחס גובה-רוחב לספר, אז הרקע של כל דף יכול להכיל אלמנט של תמונה. כדי לעשות את זה, מגדירים את רוחב הרכיב ל-200% ומשתמשים ב-object-position בהתאם לצד של הדף.
.page__background {
height: 100%;
width: 200%;
object-fit: cover;
object-position: 0 0;
position: absolute;
top: 0;
left: 0;
}
.page__background--right {
object-position: 100% 0;
}
תוכן הדף
בואו נראה איך יוצרים את אחד מהדפים. בעמוד השלישי יש ינשוף שקופץ מתוך עץ.
הוא מאוכלס ברכיב PageThree, כפי שמוגדר בהגדרות האישיות. זהו רכיב Astro (PageThree.astro). הרכיבים האלה נראים כמו קובצי HTML, אבל יש להם גדר קוד בחלק העליון, בדומה ל-frontmatter. כך אנחנו יכולים לבצע פעולות כמו ייבוא של רכיבים אחרים. הרכיב של דף שלוש נראה כך:
---
import TreeOwl from '../TreeOwl/TreeOwl.astro'
import { contentBlocks } from '../../assets/content-blocks.json'
import ContentBlock from '../ContentBlock/ContentBlock.astro'
---
<TreeOwl/>
<ContentBlock {...contentBlocks[3]} id="four" />
<style is:global>
.content-block--four {
left: 30%;
bottom: 10%;
}
</style>
שוב, דפים הם אטומיים במהותם. הם מורכבים מאוסף של תכונות. בדף השלישי יש בלוק תוכן וינשוף אינטראקטיבי, אז יש רכיב לכל אחד מהם.
בלוקים של תוכן הם הקישורים לתוכן שמופיע בספר. גם הם מופעלים על ידי אובייקט הגדרה.
{
"contentBlocks": [
{
"id": "one",
"title": "New in Chrome",
"blurb": "Lift your spirits with a round up of all the tools and features in Chrome.",
"link": "https://www.youtube.com/watch?v=qwdN1fJA_d8&list=PLNYkxOF6rcIDfz8XEA3loxY32tYh7CI3m"
},
…otherBlocks
]
}
ההגדרה הזו מיובאת למקומות שבהם נדרשים בלוקים של תוכן. לאחר מכן, הגדרת הבלוק הרלוונטית מועברת לרכיב ContentBlock.
<ContentBlock {...contentBlocks[3]} id="four" />
יש כאן גם דוגמה לאופן שבו אנחנו משתמשים ברכיב של הדף כמקום להצבת התוכן. כאן מוצב קטע תוכן.
<style is:global>
.content-block--four {
left: 30%;
bottom: 10%;
}
</style>
אבל הסגנונות הכלליים של בלוק תוכן נמצאים באותו מיקום כמו קוד הרכיב.
.content-block {
background: hsl(0deg 0% 0% / 70%);
color: var(--gray-0);
border-radius: min(3vh, var(--size-4));
padding: clamp(0.75rem, 2vw, 1.25rem);
display: grid;
gap: var(--size-2);
position: absolute;
cursor: pointer;
width: 50%;
}
לגבי הינשוף שלנו, זו תכונה אינטראקטיבית – אחת מני רבות בפרויקט הזה. זו דוגמה קטנה ונחמדה שמראה איך השתמשנו ב-ViewTimeline המשותף שיצרנו.
ברמה גבוהה, רכיב הינשוף שלנו מייבא קובץ SVG ומוסיף אותו באמצעות Fragment של Astro.
---
import { default as Owl } from '../Features/Owl.svg?raw'
---
<Fragment set:html={Owl} />
והסגנונות למיקום הינשוף שלנו נמצאים באותו מקום כמו קוד הרכיב.
.owl {
width: 34%;
left: 10%;
bottom: 34%;
}
יש עוד סגנון אחד שמגדיר את ההתנהגות של transform עבור הינשוף.
.owl__owl {
transform-origin: 50% 100%;
transform-box: fill-box;
}
השימוש ב-transform-box משפיע על transform-origin. הוא הופך אותו ליחסי לתיבה התוחמת של האובייקט בתוך ה-SVG. הינשוף גדל מהמרכז התחתון, ולכן משתמשים ב-transform-origin: 50% 100%.
החלק הכיפי הוא כשמקשרים את הינשוף לאחד מ-ViewTimeline שנוצרו:
const setUpOwl = () => {
const owl = document.querySelector('.owl__owl');
owl.animate([
{
translate: '0% 110%',
},
{
translate: '0% 10%',
},
], {
timeline: CHROMETOBER_TIMELINES[1],
delay: { phase: "enter", percent: CSS.percent(80) },
endDelay: { phase: "enter", percent: CSS.percent(90) },
fill: 'both'
});
}
if (window.matchMedia('(prefers-reduced-motion: no-preference)').matches)
setUpOwl()
בבלוק הקוד הזה אנחנו עושים שני דברים:
- בודקים את העדפות התנועה של המשתמש.
- אם אין להם העדפה, מקשרים אנימציה של הינשוף לגלילה.
בחלק השני, הינשוף מונפש בציר ה-Y באמצעות Web Animations API. נעשה שימוש במאפיין הטרנספורמציה הנפרד translate, והוא מקושר למאפיין ViewTimeline אחד. הוא מקושר אל CHROMETOBER_TIMELINES[1] דרך המאפיין timeline. זהו ViewTimeline שנוצר עבור דפדוף בדפים. כך האנימציה של הינשוף מקושרת להפיכת הדף באמצעות השלב enter. היא מגדירה שכאשר הדף מועבר ב-80%, הינשוף מתחיל לזוז. ב-90%, הינשוף אמור לסיים את התרגום.
תכונות של ספרים
עכשיו אתם מכירים את הגישה ליצירת דף ואת אופן הפעולה של ארכיטקטורת הפרויקט. אתם יכולים לראות איך הוא מאפשר לתורמים להצטרף ולעבוד על דף או תכונה לבחירתם. האנימציות של תכונות שונות בספר מקושרות לדפדוף בספר. לדוגמה, העטלף שעף פנימה והחוצה כשמדפדפים.
יש בו גם אלמנטים שמופעלים על ידי אנימציות CSS.
אחרי שהוספתי את בלוקי התוכן לספר, היה לי זמן להיות יצירתית עם תכונות אחרות. זה נתן לנו הזדמנות ליצור אינטראקציות שונות ולנסות דרכים שונות ליישם דברים.
איך לשמור על מהירות התגובה
יחידות רספונסיביות של אזור התצוגה קובעות את הגודל של הספר ושל התכונות שלו. עם זאת, היה מאתגר לשמור על הגופנים רספונסיביים. יחידות של שאילתות קונטיינר מתאימות כאן. אבל עדיין אין תמיכה בהם בכל מקום. הגודל של הספר מוגדר, אז אנחנו לא צריכים שאילתת מאגר. אפשר ליצור יחידת שאילתה של מאגר מוטבע באמצעות CSS calc() ולהשתמש בה כדי לקבוע את גודל הגופן.
.book-placeholder {
--size: clamp(12rem, 72vw, 80vmin);
--aspect-ratio: 360 / 504;
--cqi: calc(0.01 * (var(--size) * (var(--aspect-ratio))));
}
.content-block h2 {
color: var(--gray-0);
font-size: clamp(0.6rem, var(--cqi) * 4, 1.5rem);
}
.content-block :is(p, a) {
font-size: clamp(0.6rem, var(--cqi) * 3, 1.5rem);
}
דלועים זוהרים בלילה
אם אתם חדי עין, אולי שמתם לב לשימוש ברכיבי <source> כשדיברנו על הרקעים של הדפים קודם. אונה רצתה ליצור אינטראקציה שמגיבה להעדפה של ערכת הצבעים. כתוצאה מכך, הרקעים תומכים גם במצב בהיר וגם במצב כהה עם וריאציות שונות. אפשר להשתמש בשאילתות מדיה עם הרכיב <picture>, ולכן זו דרך מצוינת לספק שני סגנונות של רקע. הרכיב <source> שולח שאילתה לגבי העדפת ערכת הצבעים ומציג את הרקע המתאים.
<picture>
<source srcset={darkFront} media="(prefers-color-scheme: dark)" height="214" width="150">
<img src={lightFront} class="page__background page__background--right" alt="" aria-hidden="true" height="214" width="150">
</picture>
אתם יכולים להוסיף שינויים אחרים על סמך ההעדפה של ערכת הצבעים. הדלעות בדף השני מגיבות להעדפה של המשתמש לגבי ערכת הצבעים. קובץ ה-SVG שבו נעשה שימוש מכיל עיגולים שמייצגים להבות, שמשתנים בגודל ומונפשים במצב כהה.
.pumpkin__flame,
.pumpkin__flame circle {
transform-box: fill-box;
transform-origin: 50% 100%;
}
.pumpkin__flame {
scale: 0.8;
}
.pumpkin__flame circle {
transition: scale 0.2s;
scale: 0;
}
@media(prefers-color-scheme: dark) {
.pumpkin__flame {
animation: pumpkin-flicker 3s calc(var(--index, 0) * -1s) infinite linear;
}
.pumpkin__flame circle {
scale: 1;
}
@keyframes pumpkin-flicker {
50% {
scale: 1;
}
}
}
האם הדיוקן הזה מסתכל עליכם?
אם תסתכלו בעמוד 10, אולי תשימו לב למשהו. מישהו צופה בך! העיניים של הדיוקן יעקבו אחרי הסמן כשתזיזו אותו בדף. הטריק כאן הוא למפות את מיקום הסמן לערך של translate, ולהעביר אותו ל-CSS.
const mapRange = (inputLower, inputUpper, outputLower, outputUpper, value) => {
const INPUT_RANGE = inputUpper - inputLower
const OUTPUT_RANGE = outputUpper - outputLower
return outputLower + (((value - inputLower) / INPUT_RANGE) * OUTPUT_RANGE || 0)
}
הקוד הזה מקבל טווחי קלט ופלט, וממפה את הערכים הנתונים. לדוגמה, השימוש הזה ייתן את הערך 625.
mapRange(0, 100, 250, 1000, 50) // 625
בדיוקן, ערך הקלט הוא הנקודה המרכזית של כל עין, פלוס או מינוס מרחק מסוים בפיקסלים. טווח הפלט הוא המרחק שהעיניים יכולות לתרגם בפיקסלים. ואז המיקום של הסמן בציר ה-X או ה-Y מועבר כערך. כדי לקבל את נקודת המרכז של העיניים בזמן שמזיזים אותן, העיניים משוכפלות. התמונות המקוריות לא זזות, הן שקופות ומשמשות כרפרנס.
אחר כך צריך לקשר את הכל ולעדכן את הערכים של המאפיינים המותאמים אישית של ה-CSS בעיניים כדי שהעיניים יוכלו לזוז. פונקציה קשורה לאירוע pointermove ביחס ל-window. כשהאירוע הזה מופעל, הגבולות של כל עין משמשים לחישוב נקודות המרכז. לאחר מכן, מיקום הסמן ממופה לערכים שמוגדרים כערכי מאפיינים מותאמים אישית של העיניים.
const RANGE = 15
const LIMIT = 80
const interact = ({ x, y }) => {
// map a range against the eyes and pass in via custom properties
const LEFT_EYE_BOUNDS = LEFT_EYE.getBoundingClientRect()
const RIGHT_EYE_BOUNDS = RIGHT_EYE.getBoundingClientRect()
const CENTERS = {
lx: LEFT_EYE_BOUNDS.left + LEFT_EYE_BOUNDS.width * 0.5,
rx: RIGHT_EYE_BOUNDS.left + RIGHT_EYE_BOUNDS.width * 0.5,
ly: LEFT_EYE_BOUNDS.top + LEFT_EYE_BOUNDS.height * 0.5,
ry: RIGHT_EYE_BOUNDS.top + RIGHT_EYE_BOUNDS.height * 0.5,
}
Object.entries(CENTERS)
.forEach(([key, value]) => {
const result = mapRange(value - LIMIT, value + LIMIT, -RANGE, RANGE)(key.indexOf('x') !== -1 ? x : y)
EYES.style.setProperty(`--${key}`, result)
})
}
אחרי שהערכים מועברים ל-CSS, הסגנונות יכולים לעשות איתם מה שהם רוצים. החלק הכי טוב הוא שאפשר להשתמש ב-CSS clamp() כדי לשנות את ההתנהגות של כל עין, כך שכל עין תתנהג אחרת בלי לגעת שוב ב-JavaScript.
.portrait__eye--mover {
transition: translate 0.2s;
}
.portrait__eye--mover.portrait__eye--left {
translate:
clamp(-10px, var(--lx, 0) * 1px, 4px)
clamp(-4px, var(--ly, 0) * 0.5px, 10px);
}
.portrait__eye--mover.portrait__eye--right {
translate:
clamp(-4px, var(--rx, 0) * 1px, 10px)
clamp(-4px, var(--ry, 0) * 0.5px, 10px);
}
הטלת כישופים
אם תסתכלו בעמוד שש, האם תרגישו מרותקים? הדף הזה מעוצב בהשראת השועל הקסום והמדהים שלנו. אם תזיזו את הסמן, יכול להיות שתראו אפקט של שובל מותאם אישית. האנימציה הזו משתמשת ב-Canvas. רכיב <canvas> נמצא מעל שאר התוכן בדף עם pointer-events: none. כלומר, המשתמשים עדיין יכולים ללחוץ על בלוקי התוכן שמתחת.
.wand-canvas {
height: 100%;
width: 200%;
pointer-events: none;
right: 0;
position: fixed;
}
בדומה לאופן שבו הדיוקן שלנו מאזין לאירוע pointermove ב-window, כך גם רכיב <canvas> שלנו. אבל בכל פעם שהאירוע מופעל, אנחנו יוצרים אובייקט כדי להוסיף אנימציה לרכיב <canvas>. האובייקטים האלה מייצגים צורות שמשמשות בשובל הסמן. יש להם קואורדינטות וגוון אקראי.
אנחנו משתמשים שוב בפונקציה mapRange ממקודם, כי אנחנו יכולים להשתמש בה כדי למפות את הדלתא של הסמן ל-size ול-rate. האובייקטים מאוחסנים במערך שעובר בלולאה כשהאובייקטים משורטטים לרכיב <canvas>. המאפיינים של כל אובייקט מציינים לרכיב <canvas> איפה צריך לצייר את הדברים.
const blocks = []
const createBlock = ({ x, y, movementX, movementY }) => {
const LOWER_SIZE = CANVAS.height * 0.05
const UPPER_SIZE = CANVAS.height * 0.25
const size = mapRange(0, 100, LOWER_SIZE, UPPER_SIZE, Math.max(Math.abs(movementX), Math.abs(movementY)))
const rate = mapRange(LOWER_SIZE, UPPER_SIZE, 1, 5, size)
const { left, top, width, height } = CANVAS.getBoundingClientRect()
const block = {
hue: Math.random() * 359,
x: x - left,
y: y - top,
size,
rate,
}
blocks.push(block)
}
window.addEventListener('pointermove', createBlock)
כדי לצייר על הקנבס, נוצרת לולאה עם requestAnimationFrame. האפקט של שובל הסמן צריך להופיע רק כשהדף מוצג. יש לנו IntersectionObserver שמעדכן וקובע אילו דפים מוצגים. אם הדף גלוי, האובייקטים מוצגים כעיגולים באזור העריכה.
לאחר מכן, אנחנו עוברים בלולאה על המערך blocks ומציירים כל חלק של השובל. בכל פריים, הגודל של האובייקט קטן והמיקום שלו משתנה ב-rate. ככה נוצר האפקט של הנפילה והשינוי בגודל. אם האובייקט מתכווץ לחלוטין, הוא מוסר מהמערך blocks.
let wandFrame
const drawBlocks = () => {
ctx.clearRect(0, 0, CANVAS.width, CANVAS.height)
if (PAGE_SIX.className.indexOf('in-view') === -1 && wandFrame) {
blocks.length = 0
cancelAnimationFrame(wandFrame)
document.body.removeEventListener('pointermove', createBlock)
document.removeEventListener('resize', init)
}
for (let b = 0; b < blocks.length; b++) {
const block = blocks[b]
ctx.strokeStyle = ctx.fillStyle = `hsla(${block.hue}, 80%, 80%, 0.5)`
ctx.beginPath()
ctx.arc(block.x, block.y, block.size * 0.5, 0, 2 * Math.PI)
ctx.stroke()
ctx.fill()
block.size -= block.rate
block.y += block.rate
if (block.size <= 0) {
blocks.splice(b, 1)
}
}
wandFrame = requestAnimationFrame(drawBlocks)
}
אם הדף יוצא מהתצוגה, רכיבי ה-event listener מוסרים והלולאה של מסגרת האנימציה מבוטלת. גם המערך blocks נמחק.
ככה נראה שובל הסמן בפעולה.
בדיקת נגישות
זה נחמד ליצור חוויה מהנה, אבל היא לא שווה הרבה אם היא לא נגישה למשתמשים. המומחיות של אדם בתחום הזה הייתה חיונית להכנת Chrometober לבדיקת נגישות לפני ההשקה.
הנה כמה מהנושאים העיקריים שכלולים:
- לוודא שה-HTML שבו השתמשנו היה סמנטי. זה כולל דברים כמו רכיבי ציון דרך מתאימים, כמו
<main>לספר, וגם שימוש ברכיב<article>לכל בלוק תוכן, ורכיבי<abbr>שבהם מוצגים ראשי תיבות. החשיבה מראש בזמן יצירת הספר הפכה את הדברים לנגישים יותר. השימוש בכותרות ובקישורים מקל על המשתמשים לנווט. השימוש ברשימה עבור הדפים גם אומר שמספר הדפים מוכרז על ידי טכנולוגיה מסייעת. - לוודא שבכל התמונות נעשה שימוש במאפייני
altמתאימים. בקובצי SVG מוטמעים, הרכיבtitleמופיע במקומות שבהם הוא נדרש. - שימוש במאפייני
ariaבמקומות שבהם הם משפרים את החוויה. השימוש בaria-labelלדפים ולצדדים שלהם מבהיר למשתמש באיזה דף הוא נמצא. השימוש ב-aria-describedByבקישורים "מידע נוסף" מעביר את הטקסט של בלוק התוכן. כך המשתמשים יבינו בדיוק לאן הקישור יוביל אותם. - בנושא של בלוקי תוכן, אפשר ללחוץ על כל הכרטיס ולא רק על הקישור "מידע נוסף".
- הזכרנו קודם את השימוש ב-
IntersectionObserverכדי לעקוב אחרי הדפים שמוצגים. יש לכך יתרונות רבים שלא קשורים רק לביצועים. כל אנימציה או אינטראקציה בדפים שלא מוצגים תושהה. אבל בדפים האלה מוגדר גם המאפייןinert. זה אומר שמשתמשים שנעזרים בקורא מסך יכולים לעיין באותו תוכן כמו משתמשים רואים. המיקוד נשאר בדף שמוצג והמשתמשים לא יכולים לעבור לדף אחר באמצעות מקש Tab. - ולסיום, אנחנו משתמשים בשאילתות מדיה כדי לכבד את העדפת המשתמש לגבי תנועה.
הנה צילום מסך מהבדיקה שמדגיש חלק מהאמצעים שקיימים.
מזוהה כרכיב שמקיף את כל הספר, מה שמציין שהוא צריך להיות המאפיין העיקרי של ARIA שמשתמשים בטכנולוגיה מסייעת צריכים למצוא. More is outlined in the screenshot." width="800" height="465">

מה למדנו
המטרה של Chrometober הייתה לא רק להדגיש תוכן מהאינטרנט מהקהילה, אלא גם לאפשר לנו לבדוק את ה-polyfill של ה-API של אנימציות מקושרות לגלילה שנמצא בפיתוח.
במהלך כנס הצוות שלנו בניו יורק, הקדשנו פגישה לבדיקת הפרויקט ולטיפול בבעיות שעלו. התרומה של הצוות הייתה משמעותית מאוד. זו גם הייתה הזדמנות מצוינת לפרט את כל הדברים שהיינו צריכים לטפל בהם לפני שהאפליקציה תהיה זמינה לכולם.

לדוגמה, בדיקת הספר במכשירים שונים העלתה בעיית עיבוד. הספר שלנו לא יעובד כמו שצריך במכשירי iOS. יחידות של אזור התצוגה קובעות את גודל הדף, אבל כשיש מגרעת, היא משפיעה על הספר. הפתרון היה להשתמש ב-viewport-fit=cover באזור התצוגה meta:
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
במהלך הסשן הזה עלו גם כמה בעיות עם ה-polyfill של ה-API. Bramus העלה את הבעיות האלה במאגר ה-polyfill. לאחר מכן הוא מצא פתרונות לבעיות האלה ושילב אותם ב-polyfill. לדוגמה, בקשת המשיכה הזו שיפרה את הביצועים על ידי הוספת מטמון לחלק מה-polyfill.


זהו!
זה היה פרויקט ממש כיף לעבוד עליו, והתוצאה היא חוויית גלילה קלילה שמדגישה תוכן מדהים מהקהילה. בנוסף, זה היה נהדר לבדיקת ה-polyfill, וגם למתן משוב לצוות ההנדסה כדי לעזור לשפר את ה-polyfill.
Chrometober 2022 הסתיים.
אנחנו מקווים שנהניתם. מה התכונה האהובה עליך? אפשר לשלוח לי ציוץ ולספר לי.

אם תפגשו אותנו באירוע, אולי אפילו תוכלו לקבל כמה סטיקרים מאחד מחברי הצוות.