דילוג לתוכן הראשי

How it works

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

אדם עייף מול מחשב נייד, משפשף את העיניים

💥 תבנית העיצוב: OV והפאזל

OV (overload) הוא הכשל הנפוץ באתרי פרימיום: אנימציות כבדות, נעילות גלילה (ScrollTrigger.pin) ושרשור של timelines.

  • עייפות: גלילה שנעצרת ותנועה עודפת מעייפות.
  • ספגטי של timelines: A נכנס, B זז, C משתנה. כל שינוי בתוכן מחייב לחשב מחדש את הזמנים.
  • ביצועים: חישובים בזמן גלילה, בעיקר בטלפון.

התשובה, שלושה חוקים:

  1. sticky ולא pin: נעילה ב-position: sticky של הדפדפן, בלי נעילה ב-JS (נעילה בלי pin).
  2. העימוד הוא המצב הסופי: ברוב המקרים ה-DOM בנוי בעיצוב הסופי של הדף, והאנימציה רק מובילה לשם. אפקט לא מחזיק עיצוב משלו במספרים של JS, ולכן הוא רספונסיב מעצמו (הפאזל, data-from, הוא דוגמה אחת).
  3. מצב קריאה: המבנה והאנימציה נפרדים, אז דגל אחד מבטל את ה-from, והאתר חוזר לסטטי, קריא ונגיש. ROADMAP
💥 OVמההחוק
0בלי אנימציה בכלל: גלילה רגילה.גם זו בחירה: ברמת המנגנון יש מקרים שפשוט אין בהם אנימציה.
1-3תוכן שקט: טיפוגרפיה, מרווחים, כניסות.הנשימה של הדף.
4-6פאזל ומורף: אלמנט אחד שעובר מ-A ל-B. תמונה במסך מלא שנוחתת בכרטיס בגריד, תמונה שמתפרקת ל-30 ריבועים ומתחברת.פעם אחת, מ-A ל-B.
7-10סצנה קולנועית: כמה שלבים. רכב נכנס, חלון נפתח, מלל נחשף.אחת לדף. במצב קריאה רק הפריים האחרון.

סובייקטיבי: זה לא משהו מתמטי שמודדים, זו גם תחושה. הציונים לפי קטגוריה במקור אחד, app/docs/gsap/_ov.ts, ומוצגים ב-GSAP ובראש כל דף קטגוריה. הם מצטברים: ה-OV של סקשן הוא כל מה שנראה יחד באותו FOLD, עם תקרה של 10.

מנוחה: אחרי אפקט ב-OV גבוה בא אזור ב-OV נמוך. ככה הקורא נח, וזה האיזון בין סקשנים, לא רק בתוך FOLD אחד. למשל: כניסה מעניינת של HERO, ואחריה פשוט גלילה עם מלל רץ.

כמות התוכן: יחס הפוך. אפקט ב-OV גבוה לא מתאים להרבה תוכן, והרבה תוכן הולך עם OV נמוך.

💥 OVכמה תוכן
1כתבה בבלוג
10FOLD שיש בו רק כותרת ענקית: "Edge Technology"

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

גם עיצוב סטטי: OV מושפע בסוף גם מעיצוב בלי תנועה. דף שדוחפים בו הרבה Outside the Box מעמיס גם בלי אנימציה. את זה לא מודדים במטריצה: זה עיצוב.

מה זז ומה נקרא: על אלמנט שזז קשה יותר לקרוא מלל. לכן אצלנו התמונה זזה והמלל עומד, למשל בזיגזג.

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

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

DSL ל-AI: שפה מצומצמת שמודל בונה בה דף מאוזן. בלי יחס קבוע: האיזון נקבע מהסקשן הנוכחי, הקודם והבא. זה חישוב מורכב יותר, אבל הוא קורה מעצם הבנייה.

Nielsen Norman Group, Scrolljacking 101: ברוב המבחנים המשתתפים התבלבלו לפחות מעט כשהאתר השתלט על הגלילה, ומי שבא למשימה סבל את זה פחות ממי שסתם גלש.

WCAG 2.1, 2.3.3 Animation from Interactions: אנימציה שמופעלת מאינטראקציה, כמו גלילה, צריכה אפשרות לכבות אותה.

עצור פקח: בעיית ה-STOP של אפקטי 100VH

משהו עולה מעליו בזמן שהוא עומד: הבעיה כמעט נעלמת

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

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

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

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

הדרךמה עושיםחי ב
Rise Overהסקשן הבא עולה בזמן שהתמונה עומדת, והיא מכוסה לפני שהיא עוזבת. הפשוטה מכולן. UkPushUnderPush Under
One Groundל-pane ולסקשן הבא אותו צבע, והסקשן עולה מעל סוף המסילה. margin-top: -30vhEye Open
Staying Worldהתמונה עומדת לאורך כל התוכן, והוא נגלל מעליה. או שהיא ממשיכה הלאה כאזור פרלקס גדול. UkShapeWorldShape World
Fade to Blackהאפקט נגמר לפני סוף המסילה, התמונה דועכת, והעמידה משתחררת בלי שרואים. wipeEndBefore / After
Window in Foldהאפקט הוא חלון ליד מלל, על רקע הדף, ולא מסך מלא. כשהוא נגמר נגללת תמונה בתוך דף, והעין לא רואה שהוא עצר. uk-containerMorph
// the plainest way: one wrapper, the effect itself stays as it is
<UkPushUnder rise="6vh" scaleTo={1} opacityTo={0.6}>
  <UkPanelStack items={worlds} />
</UkPushUnder>
<section>rises over it while it still holds</section>

Window in Fold, ב-ayocin.com: בזמן האנימציה, ואחריה בגלילה רגילה.

האנימציה בחלון ליד מלל, על רקע הדףאותו חלון נגלל, ומתחתיו הסקשן הבא על אותו רקע
  • הגלילה אף פעם לא נעצרה. רק ה-pane עומד, והסקשן שעולה זז במהירות רגילה. כשהוא מכסה את המסך נשארת גלילה רגילה.
  • משהו מבצבץ לפני הסוף, וזה תוכן רגיל בכל עימוד, לא מלל באמצע.
  • רווח ענק לא נקרא כרווח כשמשהו נכנס וזז. בדף סטטי אותו מרחק היה באג. רק בזמן תנועה: ברגע שה-pane משתחרר, הרווח כן נראה (בעיית המרחק).
  • לא עוזר: להוריד את ה-sticky (הקו נעלם וגם הסיבה לאפקט), ומלל שכבר עומד מתחת לאפקט שנפתח (הקורא לא מבין מה קרה).
  • גם בלי הפס יש שני מחירים: יש בו פריימים שלקוח לא יאשר אם יעצור עליהם (לוגו קטן על מסך לבן), והוא פוגע בשאיפה שבכל רגע של גלילה משהו באתר מגיב.
  • לא בכל אפקט. תנועה בכיוון אחר מהעלייה נותנת רק חצי כוח, כמו ה-wipe הצידה של Before / After.

בעיית המרחק: הרגע שהסטיקי משתחרר

ה-pane ננעל, המצב הסופי שלו קטן (כרטיס באמצע המסך), והוא משתחרר רק 100svh אחר כך. נשאר מסך של קרקע ריקה לפני התוכן, ובמנוחה העין מודדת אותו. הלקוח יגיד "יא מלא רווח": בשבילו זה סתם רווח, וזה שהאפקט היה צריך אותו זו לא סיבה שהוא רואה.

100VH ברגע השחרור
כותרת בודדת
יא מלא רווח
בלי tight: התוכן מתחיל רק בתחתית המסך
100VH ברגע השחרור
כותרת בודדת
רווח רגיל
עם tight: התוכן מתחיל ממש מתחת לכותרתחישוב בקוד

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

הדרךמה עושים
תמונת רקע לכל הסקשןהרווח נראה קטן: העין מחשבת את המרחק מסוף התמונה, לא מהשורה האחרונה.
משהו עולה מעל, בסנכרוןהסקשן הבא עולה מעל סוף המסילה בזמן שה-pane עוד עומד, כך שהרווח לא נראה במנוחה. מתאים כשהסצנה נגמרת בקרקע שאפשר לבוא מעליה (כוכבים, עולם). כשהיא נגמרת באובייקט (כרטיס, מוצר), התוכן בא מתחתיו: UkMorph tight.
למלא את הסצנה בעיצובמספיק בסצנה הסטיקית שהגולש לא ירגיש את המרחק. למשל חתימה גדולה שחוצה את תחתית הכרטיס, וקווי קונטור חלשים שעוברים מתחת להירו ולסקשן שאחריו.

חישוב בקוד: JS מודד את המרחק

המנגנון, tight: UkMorph מודד איפה נגמר התוכן של העימוד האחרון ולוקח בחזרה בדיוק את הקרקע הריקה שמתחתיו. הסקשן הבא מתחיל ממש מתחת לכרטיס, במרחק ה-padding שלו. בלי מספר קבוע: כרטיס גדול יותר, או כותרת מתחתיו, והמדידה זזה איתם.

// the next section rises over the ground and stops at the foot of the card
<UkMorph src={portrait} swap={note} tight>…</UkMorph>
<section className="my-next">…</section>   /* a ground of its own, its top fading in */

.my-next { background: linear-gradient(to bottom, transparent, var(--ink) 32vh); }
  • מה מחושב ומה לא. tight הוא חישוב בקוד: JS מודד את התוכן של העימוד האחרון, ושוב בכל refresh של ScrollTrigger (שינוי גודל מסך). One Ground עם margin-top שלילי הוא CSS נקי בלי מדידה, ולכן הוא תלוי במספר שמישהו כתב.
  • לא מספר קבוע. margin-top של -40vh על הסקשן הבא עבד רק לתמונה הזו בגודל הזה. מחר התמונה משתנה, או שיש מלל מתחתיה, וזה נשבר.
  • בלי קו ישר. הסקשן שעולה חותך את מה שעומד בקצה ישר, וזו עצירה. הרקע שלו נכנס בהדרגה משקוף, וגם כל מה שבתוכו (זוהר, טקסטורה) נכנס איתו בהדרגה, אחרת קצה הקופסה חותך אותו לקו.
  • השכבות לפי סדר ה-DOM. מה שבא אחרי עולה מעל, כי UkMorph סגור בתוך עצמו (isolation). בלי זה ה-z-index של התמונה בתוכו יצא החוצה והתמונה ישבה מעל הסקשן שעולה.

Mister Potato

mister_potato
מר תפוח אדמה

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

החלקמהירותאפשרויות
כניסת הקלףמהירUkPanelStack · UkPushUnder · uk-uncover · Cover Hero
תנועת הרקעאיטיParallaxImage · data-movement · data-scale
כניסת המללבאמצעtrigger_box · split · split_chars · split="blur" · split="fill"

החלק שעוד לא מתחלף עד הסוף הוא המלל: כניסה לפי in view לא מתאימה לתוכן שיושב ב-pane דביק. שם המלל צריך לרוץ על הטיימליין של הסצנה.

נעילה בלי pin

נראה פשוט כמו הגלילה הנייטיב של הדפדפן: אפקט יותר יציב

ה-CSS מחזיק את המסך, ו-GSAP רק מזיז דברים. מסילה גבוהה נותנת מרחק גלילה, pane דביק בתוכה נשאר על המסך, ו-ScrollTrigger ממיר את מעבר המסילה ל-0 עד 1. בלי pin: true. לעין זו אותה תוצאה.

/* 1. the track: 300vh, so the screen stands for 200vh of scroll */
.scene { height: 300vh; }

/* 2. the pane: the browser sticks it, no script. Under a bar that stays: --uk-sticky-top */
.scene__pane {
  position: sticky;
  top: var(--uk-sticky-top, 0px);
  height: calc(100vh - var(--uk-sticky-top, 0px));
  overflow: clip;
}

<section className="scene">
  <div className="scene__pane">
    <div className="scene__box">…</div>
  </div>
</section>

// 3. GSAP only counts: the track crossing the screen is the time. No pin
const track = document.querySelector('.scene')
const pane = track.querySelector('.scene__pane')

gsap.timeline({
  scrollTrigger: {
    trigger: track,
    start: () => `top ${parseFloat(getComputedStyle(pane).top) || 0}px`,  // when the pane locks
    end: 'bottom bottom',                                                  // when the track lets go
    scrub: true,
    invalidateOnRefresh: true,
  },
  defaults: { ease: 'none' },
})
  .from('.scene__box', { y: 200, duration: 0.5 }, 0)     // from 0 to 0.5 of the track
  .to('.scene__box', { rotate: 180, duration: 0.3 }, 0.6) // from 0.6 to 0.9
  .to({}, { duration: 0.1 }, 0.9)                         // pads it to 1: a timeline of 0.9 stretches over the whole track
pin: truesticky
מי מחזיק את המסךJS: הופך ל-fixed ומחשב מיקוםהדפדפן
מה קורה ל-DOMעוטף ב-pin-spacer ומוסיף גובהכלום, המבנה כתוב ב-HTML
מרחק הגלילהב-JS: end: '+=2000'גובה המסילה ב-CSS
ברגע הנעילהעלולה להיות קפיצהאין קפיצה
המלכודתpin-spacer מול React במעבר דףoverflow: hidden או position על pane או על הורה מבטלים בשקט

Puzzle on Scroll

ציר זמן של GSAP, כתוב על האלמנטים. מסילה שעוצרת, ובתוכה כל אלמנט אומר מאיפה הוא בא, מתי ולכמה זמן, מ-0 עד 1 מהמסילה. גללו: הקוביות נכנסות לפי הסדר רק כי data-at עולה, והפס בכל קובייה מראה את החלק שלה במסילה.

מה הייחודי פה

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

מדרגהמתייתרוןחיסרון
1. אטריביוט עם טוקןאפקט אחד שחוזר על הרבה אלמנטיםנקי מאוד למשתמש: ערך אחד, כמו scale-from="0.3". אצלנו data-scale, trigger_boxלשרשר איתו אנימציות על ציר הזמן לא ישים
2. TL באטריביוטיםהגישה פהסצנה שבה כל אלמנט זז פעם אחתאיזון בין גמישות לחזרתיות: הכול על האלמנט, בלי JS לכל סצנה. העימוד הוא המצב הסופיfrom אחד לאלמנט, ובלי שרשור יחסי. כתיבה מעצבנת לאדם, עובד כש-AI כותב
3. TL קלאסיאלמנט שזז יותר מפעם אחת, שרשור ארוך. נדיר מאוד, בבקשה מפורשת ואפיוןחופש מוחלט. מתאים גם לאנימציה של 30 סצנותידני לחלוטין. לא עובד בגישת אטריביוטים

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

1data-puzzle-piecedata-from="y:120%"data-at="0"data-for="0.25"0 + 0.25 = 0.25
2data-puzzle-piecedata-from="x:-120%"data-at="0.2"data-for="0.25"0.2 + 0.25 = 0.45
3data-puzzle-piecedata-from="scale:0 rotate:-180"data-at="0.4"data-for="0.25"0.4 + 0.25 = 0.65
4data-puzzle-piecedata-from="y:-120% opacity:0"data-at="0.6"data-for="0.3"0.6 + 0.3 = 0.9
אטריביוטמה הוא מייצגערכים
מבנההמנוע שגורם לאפקט להיראות כמו pin: true
data-puzzle-trackהמסילה: מרחק הגלילה של הסצנה. 0 הוא הרגע שה-pane ננעל, 1 הוא הרגע שהמסילה משחררת אותואורך: --uk-puzzle-distance (ברירת מחדל 150svh)
data-puzzle-paneקבוע בכל סצנה: נדבק למסך. לא משפיע על האנימציהבלי ערך. בלי position על האלמנט
data-puzzle-mobileעל המסילה: רץ גם מתחת ל-768px. בלעדיו, בטלפון החלקים פשוט עומדים במקוםבלי ערך
גישת TLידני: כל חלק הוא שורה בטיימליין אחד, עם ערכים משלו. המנגנון from / to
data-puzzle-pieceהלופ עובר על האטריביוט הזה: בלעדיו אלמנט לא נכנס ללופ, ו-data-from עליו לא עושה כלום. תנועה אחת לחלק, יותר מזה TL קלאסיבלי ערך
data-fromמאיפה החלק מגיע. הוא תמיד נגמר במקום שה-CSS נותן לוx, y: בפיקסלים או ב-% מהגודל שלו. scale, rotate (מעלות), opacity, וכל תכונה אחרת של GSAP. כמה ערכים עם רווח, ולכן בלי רווח בתוך ערך
data-toפאזל הפוך: החלק מתחיל במקום שה-CSS נותן לו, והגלילה מפרקת אותו. על חלק אחד או זה או data-fromאותם ערכים כמו data-from
data-atמתחיל ב-X אחוז מהטיימליין כולו0 עד 0.99. למשל 0.6: מתחיל אחרי 60% מהגלילה. ברירת מחדל 0
data-forנמשך X אחוז מהגלילה של הטיימליין כולו. לא שניות0 עד 1. למשל 0.3: נמשך 30% מהגלילה. ברירת מחדל: עד הסוף

המשוואה: data-at + data-for היא הנקודה במסילה שבה החלק נוחת. קובייה 4: 0.6 + 0.3 = 0.9, כלומר מתחילה ב-60% ונוחתת ב-90%. סכום מעל 1 נחתך בסוף המסילה: data-for="200" הוא פשוט "עד הסוף".

<section data-puzzle-track="" className="[--uk-puzzle-distance:200svh]">
  <div data-puzzle-pane="">
    <img data-puzzle-piece="" data-from="y:85%" data-at="0.1" data-for="0.46" />
  </div>
</section>

// puzzle_on_scroll.js: each piece is one line of a timeline tied to the scroll
tl.from(piece, { yPercent: 85, duration: 0.46 }, 0.1)
  • שכבות: חלקים שנבנים למוצר יושבים absolute; inset: 0 על אותו קנבס. ההדרגה היא data-at שונה, עם חפיפה.
  • בלי position על ה-pane, גם לא relative: זה מבטל את ה-sticky בשקט.

המסילה וה-pane

אותה נעילה כמו למעלה, במונחים של הפאזל ובשלושה רגעים של המסילה:

data-puzzle-track
data-puzzle-pane = המסך
123
0: ה-pane ננעל
data-puzzle-track
data-puzzle-pane = המסך
123
0.5: באמצע המסילה
data-puzzle-track
data-puzzle-pane = המסך
123
1: המסילה משחררת

הירידה של המסך במסילה קיימת רק בציור: היוזר תמיד רואה את ה-pane באותו מקום על המסך, והמסילה עוברת מאחוריו.

הלופ

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

// puzzle_on_scroll.js, shortened
document.querySelectorAll('[data-puzzle-track]').forEach((track) => {
  const tl = gsap.timeline({ scrollTrigger: { trigger: track, scrub: true } })

  // the loop: data-puzzle-piece says who is in it, the other attributes are only read
  track.querySelectorAll('[data-puzzle-piece]').forEach((piece) => {
    const at = parseFloat(piece.dataset.at) || 0
    const dur = Math.min(parseFloat(piece.dataset.for) || 1, 1 - at)
    tl.from(piece, { ...parseFrom(piece.dataset.from), duration: dur }, at)
  })

  if (tl.duration() < 1) tl.to({}, { duration: 1 - tl.duration() })   // the timeline is always 1
})

פאזל הפוך: data-to

אותם ערכים, בכיוון השני. ב-DOM החלקים כבר במקום הסופי, והגלילה מפרקת אותם. בלי JS ובטלפון בלי data-puzzle-mobile הם נשארים מורכבים.

1data-puzzle-piecedata-to="y:120%"data-at="0"data-for="0.25"0 + 0.25 = 0.25
2data-puzzle-piecedata-to="x:-120%"data-at="0.2"data-for="0.25"0.2 + 0.25 = 0.45
3data-puzzle-piecedata-to="scale:0 rotate:-180"data-at="0.4"data-for="0.25"0.4 + 0.25 = 0.65
4data-puzzle-piecedata-to="y:-120% opacity:0"data-at="0.6"data-for="0.3"0.6 + 0.3 = 0.9

איפה זה בשימוש (נכון ל-1.11)