Descripción general básica de cómo compilar un componente de interruptor responsivo y accesible.
En esta publicación, quiero compartir ideas sobre cómo crear componentes de interruptor. Prueba la demostración.
Si prefieres un video, aquí tienes una versión de este artículo en YouTube:
Descripción general
Un interruptor funciona de manera similar a una casilla de verificación, pero representa de forma explícita los estados booleanos de encendido y apagado.
Esta demostración usa <input type="checkbox" role="switch">
para la mayor parte de su funcionalidad, lo que tiene la ventaja de no necesitar CSS ni JavaScript para ser completamente funcional y accesible. La carga de CSS brinda compatibilidad con idiomas de derecha a izquierda, verticalidad, animación y mucho más. La carga de JavaScript hace que el interruptor sea arrastrable y tangible.
Propiedades personalizadas
Las siguientes variables representan las distintas partes del interruptor y sus opciones. Como clase de nivel superior, .gui-switch
contiene propiedades personalizadas que se usan en todos los componentes secundarios y puntos de entrada para la personalización centralizada.
Pista
La longitud (--track-size
), el padding y dos colores:
.gui-switch {
--track-size: calc(var(--thumb-size) * 2);
--track-padding: 2px;
--track-inactive: hsl(80 0% 80%);
--track-active: hsl(80 60% 45%);
--track-color-inactive: var(--track-inactive);
--track-color-active: var(--track-active);
@media (prefers-color-scheme: dark) {
--track-inactive: hsl(80 0% 35%);
--track-active: hsl(80 60% 60%);
}
}
Miniatura
El tamaño, el color de fondo y los colores de resaltado de interacción:
.gui-switch {
--thumb-size: 2rem;
--thumb: hsl(0 0% 100%);
--thumb-highlight: hsl(0 0% 0% / 25%);
--thumb-color: var(--thumb);
--thumb-color-highlight: var(--thumb-highlight);
@media (prefers-color-scheme: dark) {
--thumb: hsl(0 0% 5%);
--thumb-highlight: hsl(0 0% 100% / 25%);
}
}
Movimiento reducido
Para agregar un alias claro y reducir la repetición, se puede colocar una consulta de medios del usuario de preferencia de movimiento reducido en una propiedad personalizada con el complemento de PostCSS según este borrador de especificación en Media Queries 5:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
Marca
Decidí envolver mi elemento <input type="checkbox" role="switch">
con un <label>
, agrupando su relación para evitar la ambigüedad en la asociación de la casilla de verificación y la etiqueta, y, al mismo tiempo, permitir que el usuario interactúe con la etiqueta para activar o desactivar la entrada.
<label for="switch" class="gui-switch">
Label text
<input type="checkbox" role="switch" id="switch">
</label>
<input type="checkbox">
viene precompilado con una API y un estado. El navegador administra la propiedad checked
y los eventos de entrada, como oninput
y onchanged
.
Diseños
Flexbox, grid y las propiedades personalizadas son fundamentales para mantener los estilos de este componente. Centralizan los valores, dan nombres a cálculos o áreas que, de otro modo, serían ambiguos y habilitan una pequeña API de propiedades personalizadas para facilitar las personalizaciones de los componentes.
.gui-switch
El diseño de nivel superior del interruptor es flexbox. La clase .gui-switch
contiene las propiedades personalizadas privadas y públicas que usan los elementos secundarios para calcular sus diseños.
.gui-switch {
display: flex;
align-items: center;
gap: 2ch;
justify-content: space-between;
}
Extender y modificar el diseño de flexbox es como cambiar cualquier diseño de flexbox.
Por ejemplo, para colocar etiquetas arriba o debajo de un interruptor, o para cambiar el flex-direction
, haz lo siguiente:
<label for="light-switch" class="gui-switch" style="flex-direction: column">
Default
<input type="checkbox" role="switch" id="light-switch">
</label>
Pista
La entrada de casilla de verificación se diseña como un riel de interruptor quitando su appearance: checkbox
normal y proporcionando su propio tamaño:
.gui-switch > input {
appearance: none;
inline-size: var(--track-size);
block-size: var(--thumb-size);
padding: var(--track-padding);
flex-shrink: 0;
display: grid;
align-items: center;
grid: [track] 1fr / [track] 1fr;
}
El segmento también crea un área de segmento de cuadrícula de una sola celda para que un pulgar reclame.
Miniatura
El estilo appearance: none
también quita la marca de verificación visual que proporciona el navegador. Este componente usa un seudoelemento y la seudoclase :checked
en la entrada para reemplazar este indicador visual.
El pulgar es un seudoelemento secundario adjunto al input[type="checkbox"]
y se apila sobre el riel en lugar de debajo de él reclamando el área de cuadrícula track
:
.gui-switch > input::before {
content: "";
grid-area: track;
inline-size: var(--thumb-size);
block-size: var(--thumb-size);
}
Estilos
Las propiedades personalizadas permiten un componente de interruptor versátil que se adapta a los esquemas de color, los idiomas de derecha a izquierda y las preferencias de movimiento.
Estilos de interacción táctil
En dispositivos móviles, los navegadores agregan funciones de selección de texto y de resaltado al presionar etiquetas y entradas. Estos elementos afectaron negativamente el estilo y los comentarios visuales de interacción que necesitaba este interruptor. Con unas pocas líneas de CSS, puedo quitar esos efectos y agregar mi propio estilo cursor: pointer
:
.gui-switch {
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
No siempre es recomendable quitar esos estilos, ya que pueden ser valiosos para la interacción visual. Asegúrate de proporcionar alternativas personalizadas si los quitas.
Pista
Los estilos de este elemento se relacionan principalmente con su forma y color, a los que accede desde el elemento principal .gui-switch
a través de la cascada.
.gui-switch > input {
appearance: none;
border: none;
outline-offset: 5px;
box-sizing: content-box;
padding: var(--track-padding);
background: var(--track-color-inactive);
inline-size: var(--track-size);
block-size: var(--thumb-size);
border-radius: var(--track-size);
}
Una gran variedad de opciones de personalización para el segmento de interruptor provienen de cuatro propiedades personalizadas. Se agregó border: none
, ya que appearance: none
no quita los bordes de la casilla de verificación en todos los navegadores.
Miniatura
El elemento de pulgar ya está en el track
derecho, pero necesita estilos de círculo:
.gui-switch > input::before {
background: var(--thumb-color);
border-radius: 50%;
}
Interacción
Usa propiedades personalizadas para preparar las interacciones que mostrarán destacados al pasar el cursor y cambios en la posición del pulgar. También se verifica la preferencia del usuario antes de realizar la transición de los estilos de movimiento o de resaltado al pasar el cursor.
.gui-switch > input::before {
box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);
@media (--motionOK) { & {
transition:
transform var(--thumb-transition-duration) ease,
box-shadow .25s ease;
}}
}
Posición del pulgar
Las propiedades personalizadas proporcionan un mecanismo de fuente única para posicionar el pulgar en el segmento. Tenemos a nuestra disposición los tamaños del riel y del pulgar, que usaremos en los cálculos para mantener el pulgar correctamente desplazado y dentro del riel: 0%
y 100%
.
El elemento input
posee la variable de posición --thumb-position
, y el seudoelemento de pulgar la usa como una posición translateX
:
.gui-switch > input {
--thumb-position: 0%;
}
.gui-switch > input::before {
transform: translateX(var(--thumb-position));
}
Ahora podemos cambiar --thumb-position
de CSS y las pseudoclases proporcionadas en los elementos de casilla de verificación. Dado que establecimos transition: transform
var(--thumb-transition-duration) ease
de forma condicional antes en este elemento, estos cambios pueden animarse cuando se modifiquen:
/* positioned at the end of the track: track length - 100% (thumb width) */
.gui-switch > input:checked {
--thumb-position: calc(var(--track-size) - 100%);
}
/* positioned in the center of the track: half the track - half the thumb */
.gui-switch > input:indeterminate {
--thumb-position: calc(
(var(--track-size) / 2) - (var(--thumb-size) / 2)
);
}
Creo que esta organización desacoplada funcionó bien. El elemento de pulgar solo se relaciona con un estilo, una posición translateX
. La entrada puede administrar toda la complejidad y los cálculos.
Vertical
La compatibilidad se realizó con una clase de modificador -vertical
que agrega una rotación con transformaciones CSS al elemento input
.
Sin embargo, un elemento rotado en 3D no cambia la altura general del componente, lo que puede desviar el diseño de bloques. Ten en cuenta esto con las variables --track-size
y --track-padding
. Calcula la cantidad mínima de espacio que necesita un botón vertical para fluir en el diseño según lo esperado:
.gui-switch.-vertical {
min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));
& > input {
transform: rotate(-90deg);
}
}
De derecha a izquierda (RTL)
Un amigo experto en CSS, Elad Schecter, y yo creamos juntos un prototipo de menú lateral desplegable con transformaciones de CSS que controlaba los idiomas de derecha a izquierda con solo invertir una variable. Hicimos esto porque no hay transformaciones de propiedades lógicas en CSS, y es posible que nunca las haya. A Elad se le ocurrió la excelente idea de usar un valor de propiedad personalizado para invertir los porcentajes y permitir la administración de una sola ubicación de nuestra propia lógica personalizada para las transformaciones lógicas. Usé la misma técnica en este cambio y creo que funcionó muy bien:
.gui-switch {
--isLTR: 1;
&:dir(rtl) {
--isLTR: -1;
}
}
Una propiedad personalizada llamada --isLTR
inicialmente tiene un valor de 1
, lo que significa que es true
, ya que nuestro diseño es de izquierda a derecha de forma predeterminada. Luego, con la seudoclase CSS :dir()
, el valor se establece en -1
cuando el componente se encuentra dentro de un diseño de derecha a izquierda.
Pon --isLTR
en acción usándolo dentro de un calc()
dentro de una transformación:
.gui-switch.-vertical > input {
transform: rotate(-90deg);
transform: rotate(calc(90deg * var(--isLTR) * -1));
}
Ahora, la rotación del interruptor vertical tiene en cuenta la posición del lado opuesto que requiere el diseño de derecha a izquierda.
Las transformaciones translateX
en el seudoelemento de miniatura también deben actualizarse para tener en cuenta el requisito del lado opuesto:
.gui-switch > input:checked {
--thumb-position: calc(var(--track-size) - 100%);
--thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}
.gui-switch > input:indeterminate {
--thumb-position: calc(
(var(--track-size) / 2) - (var(--thumb-size) / 2)
);
--thumb-position: calc(
((var(--track-size) / 2) - (var(--thumb-size) / 2))
* var(--isLTR)
);
}
Si bien este enfoque no funcionará para satisfacer todas las necesidades relacionadas con un concepto como las transformaciones CSS lógicas, sí ofrece algunos principios DRY para muchos casos de uso.
Estados
El uso del input[type="checkbox"]
integrado no estaría completo sin controlar los diversos estados en los que puede estar: :checked
, :disabled
, :indeterminate
y :hover
. :focus
se dejó intencionalmente sin modificar, y solo se ajustó su desplazamiento; el anillo de enfoque se veía muy bien en Firefox y Safari:
Marcado
<label for="switch-checked" class="gui-switch">
Default
<input type="checkbox" role="switch" id="switch-checked" checked="true">
</label>
Este estado representa el estado on
. En este estado, el fondo de entrada "pista" se establece en el color activo y la posición del pulgar se establece en "el final".
.gui-switch > input:checked {
background: var(--track-color-active);
--thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}
Inhabilitado
<label for="switch-disabled" class="gui-switch">
Default
<input type="checkbox" role="switch" id="switch-disabled" disabled="true">
</label>
Un botón :disabled
no solo se ve diferente, sino que también debe hacer que el elemento sea inmutable.La inmutabilidad de la interacción es gratuita en el navegador, pero los estados visuales necesitan estilos debido al uso de appearance: none
.
.gui-switch > input: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%);
}}
}
}
Este estado es complejo, ya que necesita temas claros y oscuros con estados inhabilitados y marcados. Elegí de forma estilística estilos mínimos para estos estados para aliviar la carga de mantenimiento de las combinaciones de estilos.
Indeterminado
Un estado que a menudo se olvida es :indeterminate
, en el que una casilla de verificación no está marcada ni desmarcada. Este es un estado divertido, acogedor y sencillo. Un buen recordatorio de que los estados booleanos pueden tener estados intermedios engañosos.
Es difícil establecer una casilla de verificación como indeterminada, solo JavaScript puede hacerlo:
<label for="switch-indeterminate" class="gui-switch">
Indeterminate
<input type="checkbox" role="switch" id="switch-indeterminate">
<script>document.getElementById('switch-indeterminate').indeterminate = true</script>
</label>
Dado que el estado, para mí, es modesto y atractivo, me pareció apropiado colocar la posición del selector en el medio:
.gui-switch > input:indeterminate {
--thumb-position: calc(
calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
* var(--isLTR)
);
}
Colocar el cursor sobre un elemento
Las interacciones de desplazamiento deben proporcionar asistencia visual para la IU conectada y también brindar orientación hacia la IU interactiva. Este interruptor destaca el pulgar con un anillo semitransparente cuando se coloca el cursor sobre la etiqueta o la entrada. Luego, esta animación de desplazamiento proporciona una dirección hacia el elemento interactivo de miniatura.
El efecto de "destacado" se realiza con box-shadow
. Cuando se coloca el cursor sobre una entrada no inhabilitada, aumenta el tamaño de --highlight-size
. Si el usuario acepta el movimiento, hacemos la transición del box-shadow
y vemos cómo crece. Si no lo acepta, el elemento destacado aparece de inmediato:
.gui-switch > input::before {
box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);
@media (--motionOK) { & {
transition:
transform var(--thumb-transition-duration) ease,
box-shadow .25s ease;
}}
}
.gui-switch > input:not(:disabled):hover::before {
--highlight-size: .5rem;
}
JavaScript
Para mí, una interfaz de interruptor puede parecer extraña en su intento de emular una interfaz física, especialmente este tipo con un círculo dentro de una pista. iOS lo hizo bien con su interruptor, puedes arrastrarlos de un lado a otro, y es muy satisfactorio tener la opción. Por el contrario, un elemento de la IU puede parecer inactivo si se intenta un gesto de arrastrar y no sucede nada.
Deslizadores arrastrables
El seudoelemento de pulgar recibe su posición del .gui-switch > input
con alcance var(--thumb-position)
. JavaScript puede proporcionar un valor de estilo intercalado en la entrada para actualizar de forma dinámica la posición del pulgar y hacer que parezca que sigue el gesto del puntero. Cuando se suelta el puntero, se quitan los estilos intercalados y se determina si el arrastre estuvo más cerca de la posición de apagado o encendido con la propiedad personalizada --thumb-position
. Esta es la columna vertebral de la solución: eventos de puntero que realizan un seguimiento condicional de las posiciones del puntero para modificar las propiedades personalizadas de CSS.
Dado que el componente ya era 100% funcional antes de que apareciera este script, mantener el comportamiento existente, como hacer clic en una etiqueta para activar o desactivar la entrada, requiere bastante trabajo. Nuestro JavaScript no debe agregar funciones a expensas de las existentes.
touch-action
Arrastrar es un gesto, uno personalizado, lo que lo convierte en un excelente candidato para los beneficios de touch-action
. En el caso de este interruptor, nuestro script debe controlar un gesto horizontal, o bien se debe capturar un gesto vertical para la variante de interruptor vertical. Con touch-action
, podemos indicarle al navegador qué gestos controlar en este elemento, de modo que una secuencia de comandos pueda controlar un gesto sin competencia.
El siguiente código CSS indica al navegador que, cuando un gesto de puntero comience dentro de este riel de interruptor, controle los gestos verticales y no haga nada con los horizontales:
.gui-switch > input {
touch-action: pan-y;
}
El resultado deseado es un gesto horizontal que no desplace ni deslice la página. Un puntero puede iniciar el desplazamiento vertical desde el interior de la entrada y desplazar la página, pero los horizontales se controlan de forma personalizada.
Utilidades de diseño de valores de píxeles
Durante la configuración y el arrastre, se deberán obtener varios valores numéricos calculados de los elementos. Las siguientes funciones de JavaScript devuelven valores de píxeles calculados para una propiedad de CSS determinada. Se usa en la secuencia de comandos de configuración de la siguiente manera: getStyle(checkbox, 'padding-left')
.
const getStyle = (element, prop) => {
return parseInt(window.getComputedStyle(element).getPropertyValue(prop));
}
const getPseudoStyle = (element, prop) => {
return parseInt(window.getComputedStyle(element, ':before').getPropertyValue(prop));
}
export {
getStyle,
getPseudoStyle,
}
Observa cómo window.getComputedStyle()
acepta un segundo argumento, un seudoelemento de destino. Es muy útil que JavaScript pueda leer tantos valores de los elementos, incluso de los seudoelementos.
dragging
Este es un momento clave para la lógica de arrastre, y hay algunas cosas que vale la pena destacar del controlador de eventos de la función:
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`)
}
El héroe de la secuencia de comandos es state.activethumb
, el pequeño círculo que esta secuencia de comandos posiciona junto con un puntero. El objeto switches
es un Map()
en el que las claves son .gui-switch
y los valores son límites y tamaños almacenados en caché que mantienen la eficiencia de la secuencia de comandos. La dirección de escritura de derecha a izquierda se controla con la misma propiedad personalizada que CSS --isLTR
, y puede usarla para invertir la lógica y seguir admitiendo la dirección de escritura de derecha a izquierda. El event.offsetX
también es valioso, ya que contiene un valor delta útil para posicionar el pulgar.
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
Esta última línea de CSS establece la propiedad personalizada que usa el elemento de control deslizante. De lo contrario, esta asignación de valores cambiaría con el tiempo, pero un evento de puntero anterior estableció temporalmente --thumb-transition-duration
en 0s
, lo que quitó lo que habría sido una interacción lenta.
dragEnd
Para que se le permita al usuario arrastrar el interruptor lejos de su posición y soltarlo, se debe registrar un evento de ventana global:
window.addEventListener('pointerup', event => {
if (!state.activethumb) return
dragEnd(event)
})
Creo que es muy importante que el usuario tenga la libertad de arrastrar de forma imprecisa y que la interfaz sea lo suficientemente inteligente como para tenerlo en cuenta. No fue difícil manejarlo con este interruptor, pero sí requirió una consideración cuidadosa durante el proceso de desarrollo.
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()
}
La interacción con el elemento finalizó. Es hora de establecer la propiedad checked de entrada y quitar todos los eventos de gestos. La casilla de verificación se cambia con state.activethumb.checked = determineChecked()
.
determineChecked()
Esta función, a la que llama dragEnd
, determina dónde se encuentra actualmente el pulgar dentro de los límites de su pista y devuelve verdadero si está a la mitad de la pista o más allá:
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
}
Pensamientos adicionales
El gesto de arrastrar generó una pequeña deuda de código debido a la estructura HTML inicial elegida, en particular, el ajuste de la entrada en una etiqueta. La etiqueta, al ser un elemento principal, recibiría interacciones de clics después de la entrada. Al final del evento dragEnd
, es posible que hayas notado que padRelease()
suena como una función extraña.
const padRelease = () => {
state.recentlyDragged = true
setTimeout(_ => {
state.recentlyDragged = false
}, 300)
}
Esto se debe a que la etiqueta recibe este clic posterior, ya que anularía o marcaría la interacción que realizó el usuario.
Si tuviera que hacerlo de nuevo, podría considerar ajustar el DOM con JavaScript durante la actualización de la UX para crear un elemento que controle los clics en las etiquetas por sí mismo y no entre en conflicto con el comportamiento integrado.
Este tipo de JavaScript es el que menos me gusta escribir, no quiero administrar la propagación condicional de eventos:
const preventBubbles = event => {
if (state.recentlyDragged)
event.preventDefault() && event.stopPropagation()
}
Conclusión
Este pequeño componente de interruptor terminó siendo el más difícil de todos los desafíos de GUI hasta el momento. Ahora que sabes cómo lo hice, ¿cómo lo harías tú? 🙂
Diversifiquemos nuestros enfoques y aprendamos todas las formas de crear contenido en la Web. Crea una demostración, envíame por Twitter los vínculos y la agregaré a la sección de remixes de la comunidad que se encuentra a continuación.
Remixes de la comunidad
- @KonstantinRouda con un elemento personalizado: demo y code.
- @jhvanderschee con un botón: Codepen.
Recursos
Encuentra el .gui-switch
código fuente en GitHub.