Xây dựng thành phần chuyển đổi giao diện

Thông tin tổng quan cơ bản về cách tạo thành phần chuyển đổi giao diện thích ứng và dễ tiếp cận.

Trong bài đăng này, tôi muốn chia sẻ suy nghĩ về cách tạo thành phần chuyển đổi giao diện sáng và tối. Dùng thử bản minh hoạ.

Kích thước nút "Bản minh hoạ" được tăng lên để dễ nhìn thấy

Nếu bạn thích xem video, hãy xem phiên bản video của bài đăng này trên YouTube:

Tổng quan

Một trang web có thể cung cấp chế độ cài đặt để kiểm soát bảng phối màu thay vì hoàn toàn dựa vào lựa chọn ưu tiên của hệ thống. Điều này có nghĩa là người dùng có thể duyệt web ở một chế độ khác với lựa chọn ưu tiên của hệ thống. Ví dụ: hệ thống của người dùng đang ở giao diện sáng, nhưng người dùng muốn trang web hiển thị ở giao diện tối.

Có một số cân nhắc về kỹ thuật web khi xây dựng tính năng này. Ví dụ: trình duyệt cần được thông báo về lựa chọn ưu tiên càng sớm càng tốt để ngăn chặn hiện tượng nhấp nháy màu trang. Trước tiên, chế độ điều khiển cần đồng bộ hoá với hệ thống, sau đó cho phép các trường hợp ngoại lệ được lưu trữ phía máy khách.

Bản đồ cho thấy bản xem trước của sự kiện tải trang JavaScript và tương tác với tài liệu để tổng thể cho thấy có 4 đường dẫn để thiết lập giao diện

Markup (note: đây là tên ứng dụng)

Bạn nên sử dụng <button> cho nút bật/tắt, vì khi đó bạn sẽ hưởng lợi từ các sự kiện và tính năng tương tác do trình duyệt cung cấp, chẳng hạn như sự kiện nhấp chuột và khả năng tập trung.

Nút

Nút cần có một lớp để sử dụng từ CSS và một mã nhận dạng để sử dụng từ JavaScript. Ngoài ra, vì nội dung của nút là biểu tượng thay vì văn bản, hãy thêm thuộc tính title (tiêu đề) để cung cấp thông tin về mục đích của nút. Cuối cùng, hãy thêm một [aria-label] để giữ trạng thái của nút biểu tượng, nhờ đó, trình đọc màn hình có thể chia sẻ trạng thái của giao diện với những người khiếm thị.

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

aria-labelaria-live lịch sự

Để cho trình đọc màn hình biết rằng bạn cần thông báo về các thay đổi đối với aria-label, hãy thêm aria-live="polite" vào nút.

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

Việc thêm mã đánh dấu này sẽ báo hiệu cho trình đọc màn hình một cách lịch sự, thay vì aria-live="assertive", cho người dùng biết nội dung đã thay đổi. Trong trường hợp của nút này, nút sẽ thông báo "sáng" hoặc "tối" tuỳ thuộc vào trạng thái của aria-label.

Biểu tượng đồ hoạ vectơ có thể mở rộng (SVG)

SVG cung cấp cách tạo các hình dạng chất lượng cao, có thể mở rộng mà không cần đánh dấu nhiều lần. Việc tương tác với nút này có thể kích hoạt các trạng thái hình ảnh mới cho các vectơ, giúp SVG trở thành một lựa chọn tuyệt vời cho biểu tượng.

Mã đánh dấu SVG sau đây nằm bên trong <button>:

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

aria-hidden đã được thêm vào phần tử SVG để trình đọc màn hình biết cách bỏ qua phần tử đó khi phần tử này được đánh dấu là trình bày. Đây là cách tuyệt vời để trang trí hình ảnh, chẳng hạn như biểu tượng bên trong nút. Ngoài thuộc tính viewBox bắt buộc trên phần tử, hãy thêm chiều cao và chiều rộng vì các lý do tương tự khiến hình ảnh phải có kích thước nội tuyến.

Mặt trời

Biểu tượng mặt trời xuất hiện cùng các tia nắng mờ dần và một mũi tên màu hồng đậm
  chỉ vào vòng tròn ở giữa.

Hình ảnh mặt trời bao gồm một vòng tròn và các đường mà SVG có hình dạng thuận tiện. <circle> được căn giữa bằng cách đặt thuộc tính cxcy thành 12, tức là một nửa kích thước khung nhìn (24), sau đó đặt bán kính (r) là 6 để đặt kích thước.

<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>

Ngoài ra, thuộc tính mặt nạ trỏ đến mã nhận dạng của phần tử SVG mà bạn sẽ tạo tiếp theo và cuối cùng là cung cấp màu tô khớp với màu văn bản của trang bằng currentColor.

Ánh nắng

Biểu tượng mặt trời hiển thị với tâm mặt trời bị mờ và một mũi tên màu hồng đậm
  chỉ vào các tia nắng.

Tiếp theo, các đường ánh nắng được thêm ngay bên dưới vòng tròn, bên trong một nhóm phần tử <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>

Lần này, thay vì giá trị của fillcurrentColor, nét vẽ của mỗi dòng sẽ được đặt. Các đường kẻ cùng với hình tròn tạo nên một mặt trời đẹp với các tia sáng.

Mặt trăng

Để tạo hiệu ứng chuyển đổi liền mạch giữa ánh sáng (mặt trời) và bóng tối (trăng), mặt trăng là một phần mở rộng của biểu tượng mặt trời, sử dụng mặt nạ 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>
Hình ảnh có ba lớp dọc để giúp minh hoạ cách hoạt động của tính năng che. Lớp trên cùng là một hình vuông màu trắng có một vòng tròn màu đen. Lớp ở giữa là biểu tượng mặt trời.
Lớp dưới cùng được gắn nhãn là kết quả và hiển thị biểu tượng mặt trời với phần cắt ở vị trí vòng tròn màu đen của lớp trên cùng.

Mặt nạ với SVG rất mạnh mẽ, cho phép màu trắng và đen loại bỏ hoặc đưa vào các phần của một đồ hoạ khác. Biểu tượng mặt trời sẽ bị che khuất bởi hình dạng mặt trăng <circle> có mặt nạ SVG, chỉ bằng cách di chuyển hình tròn vào và ra khỏi vùng mặt nạ.

Điều gì xảy ra nếu CSS không tải?

Ảnh chụp màn hình nút trình duyệt đơn giản có biểu tượng mặt trời bên trong.

Bạn nên kiểm thử SVG như thể CSS không tải để đảm bảo kết quả không quá lớn hoặc gây ra vấn đề về bố cục. Các thuộc tính chiều cao và chiều rộng cùng dòng trên SVG cùng với việc sử dụng currentColor cung cấp các quy tắc kiểu tối thiểu để trình duyệt sử dụng nếu CSS không tải. Điều này tạo ra các kiểu phòng thủ hiệu quả chống lại sự nhiễu mạng.

Bố cục

Thành phần công tắc giao diện có ít diện tích bề mặt, vì vậy, bạn không cần dùng lưới hoặc hộp linh hoạt cho bố cục. Thay vào đó, vị trí SVG và phép biến đổi CSS được sử dụng.

Kiểu

Kiểu .theme-toggle

Phần tử <button> là vùng chứa cho các hình dạng và kiểu biểu tượng. Ngữ cảnh gốc này sẽ chứa các màu và kích thước thích ứng để truyền xuống SVG.

Nhiệm vụ đầu tiên là đặt nút ở dạng vòng tròn và xoá các kiểu nút mặc định:

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

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

Tiếp theo, hãy thêm một số kiểu tương tác. Thêm kiểu con trỏ cho người dùng chuột. Thêm touch-action: manipulation để có trải nghiệm chạm phản hồi nhanh. Xoá phần đánh dấu bán trong suốt iOS áp dụng cho các nút. Cuối cùng, hãy để trạng thái lấy nét phác thảo một số khoảng trống từ cạnh của phần tử:

.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 bên trong nút cũng cần một số kiểu. SVG phải vừa với kích thước của nút và để tạo hiệu ứng mềm mại cho hình ảnh, hãy bo tròn các đầu đường viền:

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

Định cỡ thích ứng bằng truy vấn nội dung đa phương tiện hover

Kích thước nút biểu tượng hơi nhỏ ở 2rem, điều này không gây vấn đề gì cho người dùng chuột nhưng có thể gây khó khăn cho con trỏ thô như ngón tay. Đảm bảo nút đáp ứng nhiều nguyên tắc về kích thước thao tác chạm bằng cách sử dụng truy vấn nội dung đa phương tiện khi di chuột để chỉ định tăng kích thước.

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

Kiểu mặt trời và mặt trăng SVG

Nút này chứa các khía cạnh tương tác của thành phần nút chuyển đổi giao diện, trong khi SVG bên trong sẽ chứa các khía cạnh hình ảnh và ảnh động. Đây là nơi bạn có thể làm cho biểu tượng trở nên đẹp mắt và sống động.

Giao diện sáng

ALT_TEXT_HERE

Để ảnh động thu phóng và xoay xảy ra từ tâm của các hình dạng SVG, hãy đặt transform-origin: center center của chúng. Các hình dạng sử dụng màu thích ứng do nút cung cấp tại đây. Mặt trăng và mặt trời sử dụng nút được cung cấp var(--icon-fill)var(--icon-fill-hover) để tô màu nền, trong khi tia nắng sử dụng các biến cho nét vẽ.

.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);
    }
  }
}

Giao diện tối

ALT_TEXT_HERE

Kiểu mặt trăng cần xoá các tia nắng, tăng kích thước vòng tròn mặt trời và di chuyển mặt nạ vòng tròn.

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

Lưu ý rằng giao diện tối không thay đổi hay chuyển đổi màu. Thành phần nút mẹ sở hữu các màu, trong đó các màu này đã thích ứng trong ngữ cảnh sáng và tối. Thông tin chuyển đổi phải nằm sau truy vấn nội dung đa phương tiện ưu tiên về chuyển động của người dùng.

Hoạt ảnh

Nút này phải hoạt động và có trạng thái nhưng không có hiệu ứng chuyển đổi tại thời điểm này. Các phần sau đây đều hướng đến việc xác định cáchnội dung chuyển đổi.

Chia sẻ truy vấn nội dung nghe nhìn và nhập easing

Để dễ dàng đặt hiệu ứng chuyển đổi và ảnh động theo lựa chọn ưu tiên về chuyển động của hệ điều hành của người dùng, trình bổ trợ PostCSS Nội dung đa phương tiện tuỳ chỉnh cho phép sử dụng cú pháp thông số kỹ thuật CSS đã soạn cho các biến truy vấn nội dung đa phương tiện:

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

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

Để có các hiệu ứng CSS độc đáo và dễ sử dụng, hãy nhập phần easings (hiệu ứng làm dịu) của Open Props:

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

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

Mặt trời

Hiệu ứng chuyển đổi mặt trời sẽ vui nhộn hơn so với mặt trăng, đạt được hiệu ứng này bằng cách sử dụng hiệu ứng giảm độ nảy. Chùm mặt trời sẽ nảy một chút khi xoay và tâm của mặt trời sẽ nảy một chút khi nó chia tỷ lệ.

Kiểu mặc định (giao diện sáng) xác định hiệu ứng chuyển đổi, còn kiểu giao diện tối xác định các cách tuỳ chỉnh khi chuyển sang giao diện sáng:

​​.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;
      }
    }
  }
}

Trong bảng điều khiển Animation (Hiệu ứng động) trong Chrome DevTools, bạn có thể tìm thấy tiến trình chuyển đổi hiệu ứng động. Bạn có thể kiểm tra thời lượng của tổng số ảnh động, các phần tử và thời gian giảm tốc.

Hiệu ứng chuyển đổi từ sáng sang tối
Hiệu ứng chuyển đổi từ tối sang sáng

Mặt trăng

Vị trí ánh sáng và bóng tối của mặt trăng đã được thiết lập, hãy thêm các kiểu chuyển đổi bên trong truy vấn nội dung nghe nhìn --motionOK để tạo hiệu ứng sống động trong khi vẫn tuân thủ các lựa chọn ưu tiên về chuyển động của người dùng.

Thời gian với độ trễ và thời lượng là yếu tố quan trọng để chuyển đổi này diễn ra suôn sẻ. Ví dụ: nếu mặt trời bị che khuất quá sớm, quá trình chuyển đổi sẽ không có cảm giác được sắp xếp hoặc vui nhộn mà sẽ có cảm giác hỗn loạn.

​​.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;
      }
    }
  }
}
Hiệu ứng chuyển đổi từ sáng sang tối
Hiệu ứng chuyển đổi từ tối sang sáng

Ưu tiên giảm chuyển động

Trong hầu hết các Thử thách về giao diện người dùng đồ hoạ, tôi cố gắng giữ lại một số ảnh động, chẳng hạn như hiệu ứng chuyển đổi độ mờ, cho những người dùng muốn giảm chuyển động. Tuy nhiên, thành phần này hoạt động tốt hơn khi trạng thái thay đổi tức thì.

JavaScript

JavaScript có rất nhiều việc cần làm trong thành phần này, từ việc quản lý thông tin ARIA cho trình đọc màn hình đến việc lấy và đặt giá trị từ bộ nhớ cục bộ.

Trải nghiệm tải trang

Điều quan trọng là không được có hiện tượng nhấp nháy màu sắc khi tải trang. Nếu người dùng có bảng phối màu tối cho biết họ ưu tiên màu sáng với thành phần này, sau đó tải lại trang, thì ban đầu trang sẽ có màu tối rồi chuyển sang màu sáng. Để ngăn chặn điều này, bạn cần tiến hành một số ít chặn JavaScript với mục tiêu đặt thuộc tính HTML data-theme càng sớm càng tốt.

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

Để làm được điều này, trước tiên, một thẻ <script> thuần tuý trong tài liệu <head> sẽ được tải trước mọi mã đánh dấu CSS hoặc <body>. Khi gặp một tập lệnh chưa được đánh dấu như thế này, trình duyệt sẽ chạy mã và thực thi mã trước phần còn lại của HTML. Bằng cách sử dụng khoảnh khắc chặn này một cách tiết kiệm, bạn có thể đặt thuộc tính HTML trước khi CSS chính vẽ trang, nhờ đó ngăn chặn hiện tượng nhấp nháy hoặc màu sắc.

Trước tiên, JavaScript sẽ kiểm tra lựa chọn ưu tiên của người dùng trong bộ nhớ cục bộ và dự phòng để kiểm tra lựa chọn ưu tiên của hệ thống nếu không tìm thấy lựa chọn nào trong bộ nhớ:

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'
}

Tiếp theo, một hàm để đặt lựa chọn ưu tiên của người dùng trong bộ nhớ cục bộ sẽ được phân tích cú pháp:

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

Tiếp theo là một hàm để sửa đổi tài liệu bằng các tuỳ chọn.

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

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

Một điều quan trọng cần lưu ý tại thời điểm này là trạng thái phân tích cú pháp tài liệu HTML. Trình duyệt chưa biết về nút "#theme-toggle" vì thẻ <head> chưa được phân tích cú pháp đầy đủ. Tuy nhiên, trình duyệt có document.firstElementChild, còn gọi là thẻ <html>. Hàm này cố gắng đặt cả hai để đồng bộ hoá, nhưng trong lần chạy đầu tiên, bạn chỉ có thể đặt thẻ HTML. Ban đầu, querySelector sẽ không tìm thấy bất kỳ giá trị nào và toán tử tạo chuỗi không bắt buộc đảm bảo không có lỗi cú pháp khi không tìm thấy và hàm setAttribute được cố gắng gọi.

Tiếp theo, hàm reflectPreference() đó được gọi ngay lập tức để tài liệu HTML có bộ thuộc tính data-theme:

reflectPreference()

Nút này vẫn cần thuộc tính này, vì vậy, hãy đợi sự kiện tải trang để có thể truy vấn, thêm trình nghe và đặt thuộc tính trên:

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)
}

Trải nghiệm bật/tắt

Khi người dùng nhấp vào nút này, giao diện cần được hoán đổi trong bộ nhớ JavaScript và trong tài liệu. Bạn cần kiểm tra giá trị giao diện hiện tại và đưa ra quyết định về trạng thái mới của giao diện đó. Sau khi đặt trạng thái mới, hãy lưu và cập nhật tài liệu:

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

  setPreference()
}

Đồng bộ hoá với hệ thống

Điểm đặc biệt của nút chuyển đổi giao diện này là đồng bộ hoá với tuỳ chọn hệ thống khi tuỳ chọn đó thay đổi. Nếu người dùng thay đổi lựa chọn ưu tiên về hệ thống trong khi một trang và thành phần này đang hiển thị, thì nút chuyển giao diện sẽ thay đổi để khớp với lựa chọn ưu tiên mới của người dùng, như thể người dùng đã tương tác với nút chuyển giao diện cùng lúc với nút chuyển hệ thống.

Hãy thực hiện điều này bằng JavaScript và sự kiện matchMedia theo dõi các thay đổi đối với truy vấn nội dung nghe nhìn:

window
  .matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', ({matches:isDark}) => {
    theme.value = isDark ? 'dark' : 'light'
    setPreference()
  })
Việc thay đổi lựa chọn ưu tiên về hệ thống MacOS sẽ thay đổi trạng thái của nút chuyển đổi giao diện

Kết luận

Giờ thì bạn đã biết cách tôi làm, còn bạn thì sao‽ 🙂

Hãy đa dạng hoá các phương pháp và tìm hiểu tất cả các cách xây dựng trên web. Hãy tạo bản minh hoạ, gửi đường liên kết cho tôi trên Twitter và tôi sẽ thêm bản minh hoạ đó vào phần phối lại của cộng đồng ở bên dưới!

Bản phối lại của cộng đồng