Switch

Este padrão mostra como criar um componente de interruptor responsivo, adaptável e acessível.

Artigo completo · Vídeo no YouTube · Fonte no 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) })