יצירת רכיב להחלפת עיצוב

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

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

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

אם ברצונך ליצור סרטון, הנה גרסת YouTube של הפוסט הזה:

סקירה כללית

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

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

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

Markup

יש להשתמש ב-<button> כדי להחליף את המצב, וכך להפיק תועלת מאירועים ומתכונות של אינטראקציה שהדפדפן מספק, כמו אירועי קליק ויכולת מיקוד.

הלחצן

ללחצן צריכה להיות סיווג לשימוש מ-CSS ומזהה לשימוש ב-JavaScript. בנוסף, מכיוון שתוכן הלחצן הוא סמל ולא טקסט, כדאי להוסיף את המאפיין title כדי לספק מידע על מטרת הלחצן. לסיום, מוסיפים [aria-label] כדי להחזיק את המצב של לחצן הסמל, כדי שקוראי מסך יוכלו לשתף את מצב העיצוב עם אנשים בעלי ליקויי ראייה.

<button 
  class="theme-toggle" 
  id="theme-toggle" 
  title="Toggles light & dark" 
  aria-label="auto"
>
  …
</button>

aria-label וגם aria-live באדיבות

כדי לציין לקוראי מסך שצריך להכריז על שינויים ב-aria-label, מוסיפים את aria-live="polite" ללחצן.

<button 
  class="theme-toggle" 
  id="theme-toggle" 
  title="Toggles light & dark" 
  aria-label="auto" 
  aria-live="polite"
>
  …
</button>

התוספת הזו של תגי העיצוב תיידע את קוראי המסך שעליהם לעשות זאת בנימוס, במקום aria-live="assertive", להודיע למשתמש מה השתנה. במקרה של הלחצן הזה, הכריזה "light" (בהיר) או "dark" (כהה) בהתאם לצבע aria-label.

סמל גרפי וקטורי שניתן להתאמה (SVG)

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

תגי העיצוב הבאים של SVG נכנסים אל <button>:

<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  …
</svg>

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

השמש

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

הגרפיקה של שדה השמש מורכבת מעיגול וקווים, שאפשר להשתמש בהם ב-SVG בצורה נוחה. השדה <circle> ממורכז על ידי הגדרת המאפיינים cx ו-cy כ-12, שהוא חצי מגודל אזור התצוגה (24), ואז נקבע רדיוס (r) של 6, שקובע את הגודל.

<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  <circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
</svg>

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

קורות השמש

סמל השמש שמוצג כשמרכז השמש מעומעם וחץ בוורוד
  שמצביע על קרני השמש.

בשלב הבא, קווי קרני השמש מתווספים מתחת לעיגול, בתוך קבוצה של רכיב קבוצתי <g>.

<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  <circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
  <g class="sun-beams" stroke="currentColor">
    <line x1="12" y1="1" x2="12" y2="3" />
    <line x1="12" y1="21" x2="12" y2="23" />
    <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
    <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
    <line x1="1" y1="12" x2="3" y2="12" />
    <line x1="21" y1="12" x2="23" y2="12" />
    <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
    <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
  </g>
</svg>

הפעם, במקום שהערך של fill יהיה currentColor, המערכת מגדירה את הקו של כל שורה. הקווים והצורות של העיגולים יוצרים שמש יפה עם קורות.

הירח

על מנת ליצור אשליה של מעבר חלק בין אור (שמש) לכהה (ירח), הירח מהווה הרחבה של סמל השמש באמצעות מסכת SVG.

<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  <circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
  <g class="sun-beams" stroke="currentColor">
    …
  </g>
  <mask class="moon" id="moon-mask">
    <rect x="0" y="0" width="100%" height="100%" fill="white" />
    <circle cx="24" cy="10" r="6" fill="black" />
  </mask>
</svg>
גרפיקה עם שלוש שכבות אנכיות שעוזרות להראות איך פועלת המיסוך. השכבה העליונה היא ריבוע לבן עם עיגול שחור. השכבה האמצעית היא סמל השמש.
השכבה התחתונה מסומנת כתוצאה מכך, שבה מופיע סמל השמש עם חיתוך במקום שבו נמצא העיגול השחור בשכבה העליונה.

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

מה קורה אם שירות ה-CSS לא נטען?

צילום מסך של לחצן רגיל בדפדפן עם סמל השמש בפנים.

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

פריסה

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

סגנונות

.theme-toggle סגנונות

הרכיב <button> הוא המאגר של הצורות והסגנונות של הסמלים. ההקשר ההורה הזה יכלול צבעים וגדלים מותאמים כדי להעביר אותם ל-SVG.

המשימה הראשונה היא ליצור מעגל ולהסיר את עיצובי ברירת המחדל של הלחצן:

.theme-toggle {
  --size: 2rem;
  
  background: none;
  border: none;
  padding: 0;

  inline-size: var(--size);
  block-size: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;
}

בשלב הבא, מוסיפים סגנונות של אינטראקציות. הוספת סגנון סמן למשתמשי העכבר. אפשר להוסיף את touch-action: manipulation כדי חוויית מגע עם תגובה מהירה. הסר את ההדגשה השקופה למחצה iOS שחלה על לחצנים. לבסוף, נותנים למצב המיקוד מרווח נשימה בקצה האלמנט:

.theme-toggle {
  --size: 2rem;

  background: none;
  border: none;
  padding: 0;

  inline-size: var(--size);
  block-size: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;

  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  outline-offset: 5px;
}

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

.theme-toggle {
  --size: 2rem;

  background: none;
  border: none;
  padding: 0;

  inline-size: var(--size);
  block-size: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;

  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  outline-offset: 5px;

  & > svg {
    inline-size: 100%;
    block-size: 100%;
    stroke-linecap: round;
  }
}

שינוי גודל מותאם עם שאילתת המדיה hover

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

.theme-toggle {
  --size: 2rem;
  …
  
  @media (hover: none) {
    --size: 48px;
  }
}

סגנונות SVG של שמש וירח

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

עיצוב בהיר

ALT_TEXT_HERE

כדי לשנות את גודל האנימציות ולסובב אותן מהמרכז של צורות SVG, צריך להגדיר את השדה transform-origin: center center. הצבעים הניתנים להתאמה שהלחצן מספק משמשים כאן על ידי הצורות. הירח והשמש משתמשים בלחצן var(--icon-fill) ו-var(--icon-fill-hover) למילוי, וקרני השמש משתמשות במשתנים כדי לסמן קו.

.sun-and-moon {
  & > :is(.moon, .sun, .sun-beams) {
    transform-origin: center center;
  }

  & > :is(.moon, .sun) {
    fill: var(--icon-fill);

    @nest .theme-toggle:is(:hover, :focus-visible) > & {
      fill: var(--icon-fill-hover);
    }
  }

  & > .sun-beams {
    stroke: var(--icon-fill);
    stroke-width: 2px;

    @nest .theme-toggle:is(:hover, :focus-visible) & {
      stroke: var(--icon-fill-hover);
    }
  }
}

עיצוב כהה

ALT_TEXT_HERE

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

.sun-and-moon {
  @nest [data-theme="dark"] & {
    & > .sun {
      transform: scale(1.75);
    }

    & > .sun-beams {
      opacity: 0;
    }

    & > .moon > circle {
      transform: translateX(-7px);

      @supports (cx: 1) {
        transform: translateX(0);
        cx: 17;
      }
    }
  }
}

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

Animation

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

שיתוף שאילתות מדיה וייבוא הקלות

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

@custom-media --motionOK (prefers-reduced-motion: no-preference);

/* usage example */
@media (--motionOK) {
  .sun {
    transition: transform .5s var(--ease-elastic-3);
  }
}

כדי להוסיף התאמות ייחודיות וקלות לשימוש ב-CSS, אפשר לייבא את החלק של ההתאמות מ-Open Props:

@import "https://unpkg.com/open-props/easings.min.css";

/* usage example */
.sun {
  transition: transform .5s var(--ease-elastic-3);
}

השמש

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

סגנונות ברירת המחדל (עיצוב בהיר) מגדירים את המעברים, וסגנונות העיצוב הכהה מגדירים התאמות אישיות למעבר לבהיר:

​​.sun-and-moon {
  @media (--motionOK) {
    & > .sun {
      transition: transform .5s var(--ease-elastic-3);
    }

    & > .sun-beams {
      transition: 
        transform .5s var(--ease-elastic-4),
        opacity .5s var(--ease-3)
      ;
    }

    @nest [data-theme="dark"] & {
      & > .sun {
        transform: scale(1.75);
        transition-timing-function: var(--ease-3);
        transition-duration: .25s;
      }

      & > .sun-beams {
        transform: rotateZ(-25deg);
        transition-duration: .15s;
      }
    }
  }
}

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

מעבר מבהיר לכהה
מעבר מכהה לבהיר

הירח

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

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

​​.sun-and-moon {
  @media (--motionOK) {
    & .moon > circle {
      transform: translateX(-7px);
      transition: transform .25s var(--ease-out-5);

      @supports (cx: 1) {
        transform: translateX(0);
        cx: 17;
        transition: cx .25s var(--ease-out-5);
      }
    }

    @nest [data-theme="dark"] & {
      & > .moon > circle {
        transition-delay: .25s;
        transition-duration: .5s;
      }
    }
  }
}
מעבר מבהיר לכהה
מעבר מכהה לבהיר

העדפה לצמצום התנועה

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

JavaScript

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

חוויית טעינת הדף

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

<script src="./theme-toggle.js"></script>

כדי לעשות זאת, נטען קודם תג <script> פשוט במסמך <head>, לפני תגי עיצוב של CSS או <body>. כשהדפדפן נתקל בסקריפט לא מסומן כזה, הוא מריץ את הקוד ומפעיל אותו לפני שאר ה-HTML. אם מצמצמים את רגע החסימה הזה, אפשר להגדיר את מאפיין ה-HTML לפני שה-CSS הראשי יצביע על הדף, וכך למנוע הבהוב או צבעים.

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

const storageKey = 'theme-preference'

const getColorPreference = () => {
  if (localStorage.getItem(storageKey))
    return localStorage.getItem(storageKey)
  else
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light'
}

פונקציה להגדרת העדפת המשתמש באחסון מקומי מנותחת בשלב הבא:

const setPreference = () => {
  localStorage.setItem(storageKey, theme.value)
  reflectPreference()
}

אחריו מופיעה פונקציה שמשנה את המסמך עם ההעדפות.

const reflectPreference = () => {
  document.firstElementChild
    .setAttribute('data-theme', theme.value)

  document
    .querySelector('#theme-toggle')
    ?.setAttribute('aria-label', theme.value)
}

דבר שחשוב לשים לב בשלב הזה הוא מצב הניתוח של מסמכי ה-HTML. הדפדפן עדיין לא יודע על הלחצן " #theme-toggle", כי התג <head> עדיין לא נותח במלואו. עם זאת, בדפדפן יש תג document.firstElementChild, שנקרא גם התג <html>. הפונקציה מנסה להגדיר את שניהם כדי לשמור על סנכרון, אבל בהפעלה הראשונה היא תוכל להגדיר רק את תג ה-HTML. הקוד querySelector לא ימצא שום דבר בהתחלה, ואופרטור השרשור האופציונלי יבטיח שלא יהיו שגיאות תחביר אם הוא לא נמצא וכשתנסו להפעיל את הפונקציה setAttribute.

בשלב הבא, הפונקציה reflectPreference() מופעלת באופן מיידי, כך שלמסמך ה-HTML יש את המאפיין data-theme שמוגדר לו:

reflectPreference()

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

window.onload = () => {
  // set on load so screen readers can get the latest value on the button
  reflectPreference()

  // now this script can find and listen for clicks on the control
  document
    .querySelector('#theme-toggle')
    .addEventListener('click', onClick)
}

חוויית החלפת המצב

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

const onClick = () => {
  theme.value = theme.value === 'light'
    ? 'dark'
    : 'light'

  setPreference()
}

סנכרון עם המערכת

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

אפשר לעשות זאת באמצעות JavaScript והאזנה לאירוע matchMedia כדי לזהות שינויים בשאילתת מדיה:

window
  .matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', ({matches:isDark}) => {
    theme.value = isDark ? 'dark' : 'light'
    setPreference()
  })
שינוי העדפת המערכת של MacOS משנה את מצב החלפת העיצוב

סיכום

עכשיו, אחרי שאת יודעת איך עשיתי את זה, איך היית רוצה ‽ 🙂

בואו נגוון את הגישות שלנו ונלמד את כל הדרכים לבנות באינטרנט. צור הדגמה (דמו), ציוץ לי קישורים ואני אוסיף אותה לקטע 'רמיקסים של הקהילה' למטה!

רמיקסים של הקהילה