סקירה בסיסית של אופן יצירת רכיב להחלפת ערכות נושא שמותאם לנגישות.
בפוסט הזה אני רוצה לשתף את המחשבות שלי על דרך לבנות רכיב להחלפה בין עיצוב כהה לעיצוב בהיר. רוצים לנסות את ההדגמה?
אם אתם מעדיפים לצפות בסרטון, הנה גרסת YouTube של הפוסט הזה:
סקירה כללית
יכול להיות שאתר יספק הגדרות לשליטה בערכת הצבעים במקום להסתמך באופן מלא על העדפת המערכת. כלומר, יכול להיות שהמשתמשים יגלשו במצב שונה מההעדפות של המערכת שלהם. לדוגמה, המערכת של משתמש מוגדרת עם עיצוב בהיר, אבל המשתמש מעדיף שהאתר יוצג עם עיצוב כהה.
יש כמה שיקולים בהנדסת אינטרנט שצריך לקחת בחשבון כשמפתחים את התכונה הזו. לדוגמה, הדפדפן צריך לדעת על ההעדפה בהקדם האפשרי כדי למנוע הבהובים של צבעי הדף, והכלי צריך קודם להסתנכרן עם המערכת ואז לאפשר חריגים שמאוחסנים בצד הלקוח.
  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". במקרה של הלחצן הזה, הוא יכריז על 'בהיר'
או על 'כהה' בהתאם למה שהפך להיות aria-label.
הסמל הגרפי של הווקטור הניתן לשינוי גודל (SVG)
SVG מאפשר ליצור צורות באיכות גבוהה שאפשר לשנות את הגודל שלהן, עם מינימום תגי עיצוב. אינטראקציה עם הלחצן יכולה להפעיל מצבים חזותיים חדשים עבור הווקטורים, ולכן SVG הוא פורמט מצוין לסמלים.
התג <button> מכיל את קוד ה-SVG הבא:
<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.
קרני השמש
![]()
אחר כך, מוסיפים את קווי קרני השמש ממש מתחת לעיגול, בתוך רכיב group <g>group.
<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, הערך של stroke בכל שורה מוגדר. הקווים והצורות העגולות יוצרים שמש יפה עם קרניים.
הירח
כדי ליצור אשליה של מעבר חלק בין אור (שמש) לחושך (ירח), הירח הוא תוספת לסמל השמש, באמצעות מסכת 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 לא נטען, כדי לוודא שהתוצאה לא גדולה מדי או גורמת לבעיות בפריסה. המאפיינים inline height ו-width ב-SVG, בנוסף לשימוש ב-currentColor, מספקים כללי סגנון מינימליים לדפדפן לשימוש אם ה-CSS לא נטען. כך אפשר ליצור סגנונות הגנה טובים מפני תנודות ברשת.
פריסה
לרכיב של החלפת העיצוב יש שטח פנים קטן, כך שלא צריך להשתמש בפריסה של grid או 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 בתוכו מכיל את ההיבטים החזותיים והאנימטיביים. כאן אפשר להפוך את הסמל ליפה ולתת לו חיים.
עיצוב בהיר
כדי שאנימציות של שינוי גודל וסיבוב יתבצעו ממרכז הצורות ב-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);
    }
  }
}
עיצוב כהה
בסגנונות של הירח צריך להסיר את קרני השמש, להגדיל את עיגול השמש ולהזיז את מסכת העיגול.
.sun-and-moon {
  @nest [data-theme="dark"] & {
    & > .sun {
      transform: scale(1.75);
    }
    & > .sun-beams {
      opacity: 0;
    }
    & > .moon > circle {
      transform: translateX(-7px);
      @supports (cx: 1px) {
        transform: translateX(0);
        cx: 17px;
      }
    }
  }
}
שימו לב שאין שינויים או מעברים בצבעים בעיצוב הכהה. רכיב לחצן ההורה הוא הבעלים של הצבעים, שכבר מותאמים בהקשר של רקע כהה ובהיר. המידע על המעבר צריך להיות מאחורי שאילתת מדיה של העדפת התנועה של המשתמש.
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 easing, צריך לייבא את החלק easings של 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;
      }
    }
  }
}
בחלונית Animation בכלי הפיתוח ל-Chrome, אפשר למצוא ציר זמן למעברים בין אנימציות. אפשר לבדוק את משך האנימציה הכולל, את האלמנטים ואת תזמון ההאצה.
  
  הירח
המיקומים הבהירים והכהים של הירח כבר מוגדרים. מוסיפים סגנונות מעבר בתוך שאילתת המדיה --motionOK כדי להפיח בו חיים, תוך התחשבות בהעדפות התנועה של המשתמש. 
התזמון של העיכוב ומשך הזמן חשובים מאוד כדי שהמעבר הזה יתבצע בצורה חלקה. אם ליקוי החמה מתרחש מוקדם מדי, למשל, המעבר לא נראה מתוכנן או משעשע, אלא כאוטי.
.sun-and-moon {
  @media (--motionOK) {
    & .moon > circle {
      transform: translateX(-7px);
      transition: transform .25s var(--ease-out-5);
      @supports (cx: 1px) {
        transform: translateX(0);
        cx: 17px;
        transition: cx .25s var(--ease-out-5);
      }
    }
    @nest [data-theme="dark"] & {
      & > .moon > circle {
        transition-delay: .25s;
        transition-duration: .5s;
      }
    }
  }
}
  
  העדפה לצמצום תנועה
ברוב האתגרים של ממשק המשתמש הגרפי אני משתדל להוסיף אנימציה כלשהי, כמו מעברים של שקיפות, למשתמשים שמעדיפים תנועה מופחתת. עם זאת, היה נראה שהרכיב הזה עובד טוב יותר עם שינויים מיידיים במצב.
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() מופעלת באופן מיידי, כך שהמאפיין data-theme של מסמך ה-HTML מוגדר:
reflectPreference()
עדיין צריך להוסיף את המאפיין ללחצן, לכן צריך להמתין לאירוע טעינת הדף, ואז אפשר לבצע שאילתה, להוסיף listeners ולהגדיר מאפיינים ב:
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()
  })
סיכום
עכשיו כשאתה יודע איך עשיתי את זה, איך היית עושה את זה‽ 🙂
כדאי לגוון את הגישות שלנו וללמוד את כל הדרכים לבנות אתרים. אפשר ליצור סרטון הדגמה, לצייץ לי קישורים, ואוסיף אותו לקטע של רמיקסים מהקהילה שבהמשך!