此模式展示了如何创建响应式、自适应且可访问的开关组件。
完整文章 · YouTube 上的视频 · GitHub 上的来源
HTML
<label for="switch-1" class="gui-switch">
  Default
  <input type="checkbox" role="switch" id="switch-1">
</label>
<label for="switch-2" class="gui-switch">
  Indeterminate
  <input type="checkbox" role="switch" id="switch-2">
  <!-- TODO: Devsite - Removed inline handlers -->
  <!-- <script>document.getElementById('switch-2').indeterminate = true</script> -->
</label>
<label for="switch-3" class="gui-switch">
  Disabled
  <input type="checkbox" role="switch" id="switch-3" disabled>
</label>
<label for="switch-4" class="gui-switch">
  Disabled (checked)
  <input type="checkbox" role="switch" id="switch-4" disabled checked>
</label>
<label for="switch-vertical" class="gui-switch -vertical">
  Vertical
  <input type="checkbox" role="switch" id="switch-vertical">
</label>CSS
        .gui-switch {
  --thumb-size: 2rem;
  --thumb: hsl(0 0% 100%);
  --thumb-highlight: hsl(0 0% 0% / 25%);
  
  --track-size: calc(var(--thumb-size) * 2);
  --track-padding: 2px;
  --track-inactive: hsl(80 0% 80%);
  --track-active: hsl(80 60% 45%);
  --thumb-color: var(--thumb);
  --thumb-color-highlight: var(--thumb-highlight);
  --track-color-inactive: var(--track-inactive);
  --track-color-active: var(--track-active);
  --isLTR: 1;
  display: flex;
  align-items: center;
  gap: 2ch;
  justify-content: space-between;
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  @media (prefers-color-scheme: dark) {
    --thumb: hsl(0 0% 5%);
    --thumb-highlight: hsl(0 0% 100% / 25%);
    --track-inactive: hsl(80 0% 35%);
    --track-active: hsl(80 60% 60%);
  }
  &:dir(rtl) {
    --isLTR: -1;
  }
  &.-vertical {
    min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));
    & > input {
      transform: rotate(calc(90deg * var(--isLTR) * -1));
      touch-action: pan-x;
    }
  }
  & > input {
    --thumb-position: 0%;
    --thumb-transition-duration: .25s;
    
    padding: var(--track-padding);
    background: var(--track-color-inactive);
    inline-size: var(--track-size);
    block-size: var(--thumb-size);
    border-radius: var(--track-size);
    appearance: none;
    pointer-events: none;
    touch-action: pan-y;
    border: none;
    outline-offset: 5px;
    box-sizing: content-box;
    flex-shrink: 0;
    display: grid;
    align-items: center;
    grid: [track] 1fr / [track] 1fr;
    transition: background-color .25s ease;
    &::before {
      --highlight-size: 0;
      content: "";
      cursor: pointer;
      pointer-events: auto;
      grid-area: track;
      inline-size: var(--thumb-size);
      block-size: var(--thumb-size);
      background: var(--thumb-color);
      box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);
      border-radius: 50%;
      transform: translateX(var(--thumb-position));
      @media (--motionOK) { & {
        transition: 
          transform var(--thumb-transition-duration) ease,
          box-shadow .25s ease;
      }}
    }
    &:not(:disabled):hover::before {
      --highlight-size: .5rem;
    }
    &:checked {
      background: var(--track-color-active);
      --thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
    }
    &:indeterminate {
      --thumb-position: calc(
        calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
        * var(--isLTR)
      );
    }
    &:disabled {
      cursor: not-allowed;
      --thumb-color: transparent;
      &::before {
        cursor: not-allowed;
        box-shadow: inset 0 0 0 2px hsl(0 0% 100% / 50%);
        @media (prefers-color-scheme: dark) {
          box-shadow: inset 0 0 0 2px hsl(0 0% 0% / 50%);
        }
      }
    }
  }
}
        JS
        const elements = document.querySelectorAll('.gui-switch')
const switches = new WeakMap()
const state = {
  activethumb: null,
  recentlyDragged: false,
}
const getStyle = (element, prop) =>
  parseInt(
    window.getComputedStyle(element)
      .getPropertyValue(prop))
const getPseudoStyle = (element, prop) =>
  parseInt(
    window.getComputedStyle(element, ':before')
      .getPropertyValue(prop))
const dragInit = event => {
  if (event.target.disabled) return
  state.activethumb = event.target
  state.activethumb.addEventListener('pointermove', dragging)
  state.activethumb.style.setProperty('--thumb-transition-duration', '0s')
}
const dragging = event => {
  if (!state.activethumb) return
  let {thumbsize, bounds, padding} = switches.get(state.activethumb.parentElement)
  let directionality = getStyle(state.activethumb, '--isLTR')
  let track = (directionality === -1)
    ? (state.activethumb.clientWidth * -1) + thumbsize + padding
    : 0
  let pos = Math.round(event.offsetX - thumbsize / 2)
  if (pos < bounds.lower) pos = 0
  if (pos > bounds.upper) pos = bounds.upper
  state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
}
const dragEnd = event => {
  if (!state.activethumb) return
  state.activethumb.checked = determineChecked()
  if (state.activethumb.indeterminate)
    state.activethumb.indeterminate = false
  state.activethumb.style.removeProperty('--thumb-transition-duration')
  state.activethumb.style.removeProperty('--thumb-position')
  state.activethumb.removeEventListener('pointermove', dragging)
  state.activethumb = null
  padRelease()
}
const padRelease = () => {
  state.recentlyDragged = true
  setTimeout(_ => {
    state.recentlyDragged = false
  }, 300)
}
const preventBubbles = event => {
  if (state.recentlyDragged)
    event.preventDefault() && event.stopPropagation()
}
const labelClick = event => {
  if (
    state.recentlyDragged || 
    !event.target.classList.contains('gui-switch') || 
    event.target.querySelector('input').disabled
  ) return
  let checkbox = event.target.querySelector('input')
  checkbox.checked = !checkbox.checked
  event.preventDefault()
}
const determineChecked = () => {
  let {bounds} = switches.get(state.activethumb.parentElement)
  let curpos = 
    Math.abs(
      parseInt(
        state.activethumb.style.getPropertyValue('--thumb-position')))
  if (!curpos) {
    curpos = state.activethumb.checked
      ? bounds.lower
      : bounds.upper
  }
  return curpos >= bounds.middle
}
elements.forEach(guiswitch => {
  let checkbox = guiswitch.querySelector('input')
  let thumbsize = getPseudoStyle(checkbox, 'width')
  let padding = getStyle(checkbox, 'padding-left') + getStyle(checkbox, 'padding-right')
  checkbox.addEventListener('pointerdown', dragInit)
  checkbox.addEventListener('pointerup', dragEnd)
  checkbox.addEventListener('click', preventBubbles)
  guiswitch.addEventListener('click', labelClick)
  switches.set(guiswitch, {
    thumbsize,
    padding,
    bounds: {
      lower: 0,
      middle: (checkbox.clientWidth - padding) / 4,
      upper: checkbox.clientWidth - thumbsize - padding,
    },
  })
})
window.addEventListener('pointerup', event => {
  if (!state.activethumb) return
  dragEnd(event)
})