בניית Chrometober!

איך הספר הנגלל התעורר לחיים לצורך שיתוף טיפים וטריקים מהנים ומפחידים מה-Chrometober הזה.

בהמשך ל-designcember, רצינו ליצור בשבילך את Chrometober השנה, כדי שנוכל להבליט ולשתף תוכן מהאינטרנט מהקהילה ומצוות Chrome. ב- Designcember הציגה שימוש בשאילתות קונטיינר, אבל השנה אנחנו מציגים את ה-CSS של ה-API של האנימציות המקושרות לגלילה.

אפשר להתנסות בגלילה עם הספר בכתובת web.dev/chrometober-2022.

סקירה כללית

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

מבנה הצוות שלנו נראה כך:

לנסח חוויה של גלילה

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

מחברת מונחת על שולחן עם מגוון שרבוטים ושרבוטים שקשורים לפרויקט.

היה מרגש לקבל את החופש היצירתי לקחת את הפרויקט הראשון שלי ב-Google לכיוון בלתי צפוי. זה היה אב-טיפוס ראשוני של האופן שבו משתמש יכול לנווט בתוכן.

כשהמשתמש גולל לצדדים, הבלוקים מסתובבים ומגדילים את קנה המידה. אבל החלטתי לוותר על הרעיון הזה מתוך חשש לגבי האופן שבו נוכל להפוך את החוויה הזו למעולה למשתמשים במכשירים בכל הגדלים. במקום זאת, התייחסתי לעיצוב של משהו שיצרתי בעבר. בשנת 2020, התמזל מזלי שהייתה לי גישה ל-GreenSock's ScrollTrigger כדי לפתח הדגמות להשקה.

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

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

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

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

היכרות עם ה-API

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

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

  1. אלה שמגיבים למיקום הגלילה.
  2. אלה שמגיבים למיקום אלמנט במאגר הגלילה שלו.

כדי ליצור את האפשרות השנייה, אנחנו משתמשים ב-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 for 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, כפי שמוגדר בהגדרות. זהו רכיב אסטרונומי (PageThree.astro). הרכיבים האלה נראים כמו קובצי HTML אבל יש להם גדר קוד בחלק העליון שדומה למילה החזית. כך אנחנו יכולים לבצע פעולות כמו לייבא רכיבים אחרים. הרכיב של דף שלישי נראה כך:

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

הינשוף שלנו, מדובר בפיצ'ר אינטראקטיבי, אחד מיני רבים בפרויקט הזה. זוהי דוגמה קטנה להמחשה שממחישה איך השתמשנו בציר הזמן המשותף שיצרנו.

ברמה גבוהה, רכיב ה-ינשופ שלנו מייבא כמה 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()

בקטע הקוד הזה אנחנו מבצעים שתי פעולות:

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

בחלק השני, הינשוף יוצר אנימציה על ציר ה-Y באמצעות ממשק ה-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> כשדננו ברקעים של הדפים בשלב מוקדם יותר. Una נהנית מאינטראקציה שהגיבה להעדפה של ערכת הצבעים. לכן, תמונות הרקע תומכות גם במצב בהיר וגם במצב כהה עם וריאציות שונות. מכיוון שניתן להשתמש בשאילתות מדיה עם הרכיב <picture>, זו דרך נהדרת לספק שני סגנונות של רקע. הרכיב <source> מבצע שאילתה לקבלת העדפה של ערכת צבעים, ומציג את הרקע המתאים.

<picture>
  <source srcset={darkFront} media="(prefers-color-scheme: dark)" height="214" width="150">
  <img src={lightFront} class="page__background page__background--right" alt="" aria-hidden="true" height="214" width="150">
</picture>

אפשר לבצע שינויים נוספים על סמך ההעדפה של ערכת הצבעים הזו. הדלעות בדף השני מגיבות להעדפת לוח הצבעים של המשתמש. בפורמט SVG יש עיגולים שמייצגים להבות, שגודלן משתנה ומונפש במצב כהה.

.pumpkin__flame,
 .pumpkin__flame circle {
   transform-box: fill-box;
   transform-origin: 50% 100%;
 }

 .pumpkin__flame {
   scale: 0.8;
 }

 .pumpkin__flame circle {
   transition: scale 0.2s;
   scale: 0;
 }

@media(prefers-color-scheme: dark) {
   .pumpkin__flame {
     animation: pumpkin-flicker 3s calc(var(--index, 0) * -1s) infinite linear;
   }

   .pumpkin__flame circle {
     scale: 1;
   }

   @keyframes pumpkin-flicker {
     50% {
       scale: 1;
     }
   }
 }

האם הדיוקן הזה צופה בך?

אם עוברים לדף 10, אולי תבחינו במשהו. עוקבים אחריך! העיניים של הדיוקן יעקבו אחרי הסמן שלך כשתנוע על הדף. הטריק הוא למפות את מיקום הסמן לערך תרגום ולהעביר אותו ל-CSS.

const mapRange = (inputLower, inputUpper, outputLower, outputUpper, value) => {
   const INPUT_RANGE = inputUpper - inputLower
   const OUTPUT_RANGE = outputUpper - outputLower
   return outputLower + (((value - inputLower) / INPUT_RANGE) * OUTPUT_RANGE || 0)
 }

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

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

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

לאחר מכן צריך לקשר ביניהם ולעדכן את הערכים של מאפיין ה-CSS המותאם אישית בעיניים כדי שהעיניים יוכלו לזוז. הפונקציה מקושרת לאירוע 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> נמצא מעל שאר התוכן בדף באמצעות 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.
  • ואחרון חביב, אנו משתמשים בשאילתות מדיה כדי לכבד את העדפת המשתמש לתנועה.

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

מזוהה כחלק מהספר כולו, כדי לציין שהוא צריך לשמש כציון הדרך העיקרי שבו משתמשים של טכנולוגיה מסייעת יוכלו למצוא. מידע נוסף מתואר בצילום המסך."width="800"height="465">

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

מה למדנו

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

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

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

לדוגמה, בדיקת הספר במכשירים הציגה בעיית עיבוד. הספר שלנו לא מוצג כמו שצריך במכשירי iOS. יחידות של אזור התצוגה גודלות את הדף, אבל אם היה חריץ, הוא השפיע על הספר. הפתרון היה להשתמש ב-viewport-fit=cover באזור התצוגה של meta:

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

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

צילום מסך של הדגמה שפתוחה ב-Chrome. הכלים למפתחים פתוחים ומציגים מדידת ביצועים בסיסית.

צילום מסך של הדגמה שפתוחה ב-Chrome. הכלים למפתחים פתוחים ומציגים מדידת ביצועים משופרת.

זהו!

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

Chrometober 2022 הגיע הזמן להכריז.

אנחנו מקווים שנהניתם. מהי התכונה האהובה עליך? שלחו לי ציוץ ועדכנו אותנו.

גלי מחזיק גיליון סטיקרים עם הדמויות מ-Chrometober.

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

תמונה ראשית (Hero) מאת David Menidrey ב-UnFlood