Présentation de base sur la façon de créer un composant de commutateur responsive et accessible.
Dans cet article, je vais vous expliquer comment créer des composants de commutateur. Tester la fonctionnalité
Si vous préférez les vidéos, voici une version YouTube de cet article :
Présentation
Un commutateur fonctionne de la même manière qu'une case à cocher, mais représente explicitement les états booléens "activé" et "désactivé".
Cette démo utilise <input type="checkbox" role="switch">
pour la majorité de ses fonctionnalités, ce qui présente l'avantage de ne pas nécessiter de CSS ni de JavaScript pour être pleinement fonctionnelle et accessible. Le chargement de CSS est compatible avec les langues de droite à gauche, la verticalité, l'animation et plus encore. Le chargement de JavaScript rend le bouton bascule déplaçable et tangible.
Propriétés personnalisées
Les variables suivantes représentent les différentes parties du commutateur et leurs options. En tant que classe de premier niveau, .gui-switch
contient des propriétés personnalisées utilisées dans les enfants du composant et des points d'entrée pour la personnalisation centralisée.
Suivre
La longueur (--track-size
), la marge intérieure et deux couleurs :
.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%);
}
}
Thumb
Taille, couleur d'arrière-plan et couleurs de mise en surbrillance des interactions :
.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%);
}
}
Mouvements réduits
Pour ajouter un alias clair et réduire la répétition, une requête média utilisateur de préférence de mouvement réduit peut être placée dans une propriété personnalisée avec le plug-in PostCSS basé sur cette spécification provisoire dans Media Queries 5 :
@custom-media --motionOK (prefers-reduced-motion: no-preference);
Annoter
J'ai choisi d'encapsuler mon élément <input type="checkbox" role="switch">
avec un <label>
, en regroupant leur relation pour éviter toute ambiguïté dans l'association entre la case à cocher et le libellé, tout en permettant à l'utilisateur d'interagir avec le libellé pour activer ou désactiver l'entrée.
<label for="switch" class="gui-switch">
Label text
<input type="checkbox" role="switch" id="switch">
</label>
<input type="checkbox">
est précompilé avec une API et un état. Le navigateur gère la propriété checked
et les événements d'entrée tels que oninput
et onchanged
.
Mises en page
Flexbox, grid et custom properties sont essentiels pour conserver les styles de ce composant. Elles centralisent les valeurs, donnent des noms à des calculs ou des zones autrement ambigus, et permettent une petite API de propriétés personnalisées pour faciliter la personnalisation des composants.
.gui-switch
La mise en page de premier niveau du bouton bascule est flexbox. La classe .gui-switch
contient les propriétés personnalisées privées et publiques que les enfants utilisent pour calculer leurs mises en page.
.gui-switch {
display: flex;
align-items: center;
gap: 2ch;
justify-content: space-between;
}
Étendre et modifier la mise en page Flexbox revient à modifier n'importe quelle mise en page Flexbox.
Par exemple, pour placer des libellés au-dessus ou en dessous d'un bouton bascule, ou pour modifier le flex-direction
:
<label for="light-switch" class="gui-switch" style="flex-direction: column">
Default
<input type="checkbox" role="switch" id="light-switch">
</label>
Suivre
L'entrée de la case à cocher est stylisée en tant que piste de commutateur en supprimant son appearance: checkbox
normal et en fournissant sa propre taille :
.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;
}
La piste crée également une zone de piste de grille de cellule unique pour qu'une miniature puisse être revendiquée.
Thumb
Le style appearance: none
supprime également la coche visuelle fournie par le navigateur. Ce composant utilise un pseudo-élément et la pseudo-classe :checked
sur l'entrée pour remplacer cet indicateur visuel.
Le pouce est un pseudo-élément enfant associé à input[type="checkbox"]
et s'empile au-dessus de la piste au lieu d'en dessous en revendiquant la zone de grille track
:
.gui-switch > input::before {
content: "";
grid-area: track;
inline-size: var(--thumb-size);
block-size: var(--thumb-size);
}
Styles
Les propriétés personnalisées permettent de créer un composant de commutateur polyvalent qui s'adapte aux schémas de couleurs, aux langues de droite à gauche et aux préférences de mouvement.
Styles d'interaction tactile
Sur mobile, les navigateurs ajoutent des fonctionnalités de mise en surbrillance au toucher et de sélection de texte aux libellés et aux entrées. Cela a eu un impact négatif sur le style et le feedback visuel de l'interaction dont ce bouton avait besoin. Avec quelques lignes de CSS, je peux supprimer ces effets et ajouter mon propre style cursor: pointer
:
.gui-switch {
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
Il n'est pas toujours conseillé de supprimer ces styles, car ils peuvent fournir des informations visuelles utiles sur l'interaction. Veillez à proposer des alternatives personnalisées si vous les supprimez.
Suivre
Les styles de cet élément concernent principalement sa forme et sa couleur, auxquelles il accède à partir de l'élément parent .gui-switch
via la cascade.
.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);
}
Une grande variété d'options de personnalisation pour la piste de commutateur provient de quatre propriétés personnalisées. border: none
est ajouté, car appearance: none
ne supprime pas les bordures de la case à cocher sur tous les navigateurs.
Thumb
L'élément thumb est déjà à droite track
, mais il a besoin de styles de cercle :
.gui-switch > input::before {
background: var(--thumb-color);
border-radius: 50%;
}
Interaction
Utilisez des propriétés personnalisées pour préparer les interactions qui afficheront des surbrillances au survol et des changements de position du pouce. La préférence de l'utilisateur est également vérifiée avant la transition des styles de mise en surbrillance du mouvement ou du pointeur.
.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;
}}
}
Position du pouce
Les propriétés personnalisées fournissent un mécanisme de source unique pour positionner le pouce sur la piste. Nous disposons des tailles de la piste et du pouce que nous utiliserons dans les calculs pour maintenir le pouce correctement décalé et entre les limites de la piste : 0%
et 100%
.
L'élément input
possède la variable de position --thumb-position
, et le pseudo-élément thumb l'utilise comme position translateX
:
.gui-switch > input {
--thumb-position: 0%;
}
.gui-switch > input::before {
transform: translateX(var(--thumb-position));
}
Nous sommes maintenant libres de modifier --thumb-position
à partir du CSS et des pseudo-classes fournies sur les éléments de case à cocher. Étant donné que nous avons défini transition: transform
var(--thumb-transition-duration) ease
de manière conditionnelle plus tôt sur cet élément, ces modifications peuvent être animées lorsqu'elles sont modifiées :
/* 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)
);
}
J'ai trouvé que cette orchestration découplée fonctionnait bien. L'élément thumb n'est concerné que par un seul style, une position translateX
. L'entrée peut gérer toute la complexité et tous les calculs.
Vertical
La prise en charge a été effectuée avec une classe de modificateur -vertical
qui ajoute une rotation avec des transformations CSS à l'élément input
.
Toutefois, un élément 3D en rotation ne modifie pas la hauteur globale du composant, ce qui peut fausser la mise en page en bloc. Tenez-en compte à l'aide des variables --track-size
et --track-padding
. Calculez l'espace minimal requis pour qu'un bouton vertical s'affiche correctement dans la mise en page :
.gui-switch.-vertical {
min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));
& > input {
transform: rotate(-90deg);
}
}
(RTL) de droite à gauche
Avec un ami CSS, Elad Schecter, nous avons prototypé ensemble un menu latéral coulissant à l'aide de transformations CSS qui géraient les langues de droite à gauche en inversant une seule variable. Nous avons fait cela, car il n'existe pas de transformations de propriétés logiques en CSS, et il n'y en aura peut-être jamais. Elad a eu l'excellente idée d'utiliser une valeur de propriété personnalisée pour inverser les pourcentages, afin de permettre la gestion d'un seul emplacement de notre propre logique personnalisée pour les transformations logiques. J'ai utilisé la même technique dans ce switch et je pense que cela a très bien fonctionné :
.gui-switch {
--isLTR: 1;
&:dir(rtl) {
--isLTR: -1;
}
}
Une propriété personnalisée appelée --isLTR
contient initialement la valeur 1
, ce qui signifie qu'elle est true
, car notre mise en page est de gauche à droite par défaut. Ensuite, à l'aide de la pseudo-classe CSS :dir()
, la valeur est définie sur -1
lorsque le composant se trouve dans une mise en page de droite à gauche.
Mettez --isLTR
en pratique en l'utilisant dans un calc()
à l'intérieur d'une transformation :
.gui-switch.-vertical > input {
transform: rotate(-90deg);
transform: rotate(calc(90deg * var(--isLTR) * -1));
}
La rotation du bouton bascule vertical tient désormais compte de la position du côté opposé requise par la mise en page de droite à gauche.
Les transformations translateX
sur le pseudo-élément "thumb" doivent également être mises à jour pour tenir compte de l'exigence du côté opposé :
.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)
);
}
Bien que cette approche ne permette pas de répondre à tous les besoins concernant un concept tel que les transformations CSS logiques, elle offre certains principes DRY pour de nombreux cas d'utilisation.
États
L'utilisation de input[type="checkbox"]
intégré ne serait pas complète sans la gestion des différents états dans lesquels il peut se trouver : :checked
, :disabled
, :indeterminate
et :hover
. :focus
a été intentionnellement laissé tel quel, avec un ajustement uniquement apporté à son décalage. L'anneau de sélection était parfait sur Firefox et Safari :
Coché
<label for="switch-checked" class="gui-switch">
Default
<input type="checkbox" role="switch" id="switch-checked" checked="true">
</label>
Cet état représente l'état on
. Dans cet état, l'arrière-plan de la piste d'entrée est défini sur la couleur active et la position du curseur est définie sur "la fin".
.gui-switch > input:checked {
background: var(--track-color-active);
--thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}
Désactivé
<label for="switch-disabled" class="gui-switch">
Default
<input type="checkbox" role="switch" id="switch-disabled" disabled="true">
</label>
Un bouton :disabled
n'a pas seulement une apparence différente, mais il doit également rendre l'élément immuable.L'immuabilité de l'interaction est sans frais dans le navigateur, mais les états visuels ont besoin de styles en raison de l'utilisation 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%);
}}
}
}
Cet état est délicat, car il nécessite des thèmes clairs et sombres avec des états désactivés et cochés. J'ai choisi des styles minimalistes pour ces états afin de faciliter la maintenance des combinaisons de styles.
Indéterminé
Un état souvent oublié est :indeterminate
, où une case à cocher n'est ni cochée ni décochée. C'est un état amusant, accueillant et sans prétention. Un bon rappel que les états booléens peuvent avoir des états intermédiaires sournois.
Il est difficile de définir une case à cocher sur "indéterminée". Seul JavaScript peut le faire :
<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>
Comme l'état est, pour moi, simple et accueillant, il m'a semblé approprié de placer le pouce du bouton sur la position du milieu :
.gui-switch > input:indeterminate {
--thumb-position: calc(
calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
* var(--isLTR)
);
}
Survol
Les interactions par pointeur doivent fournir une assistance visuelle pour l'UI connectée et également fournir des indications pour l'UI interactive. Ce bouton met en évidence le pouce avec un anneau semi-transparent lorsque le libellé ou l'entrée sont pointés. Cette animation au survol fournit ensuite une indication vers l'élément miniature interactif.
L'effet "Mettre en surbrillance" est réalisé avec box-shadow
. Au survol d'un champ de saisie non désactivé, augmentez la taille de --highlight-size
. Si l'utilisateur accepte le mouvement, nous effectuons la transition de box-shadow
et le voyons grandir. S'il n'accepte pas le mouvement, la mise en surbrillance apparaît instantanément :
.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
Pour moi, une interface à bascule peut sembler étrange dans sa tentative d'imiter une interface physique, en particulier ce type avec un cercle à l'intérieur d'une piste. iOS a bien fait les choses avec son commutateur, vous pouvez les faire glisser d'un côté à l'autre, et c'est très satisfaisant d'avoir cette option. Inversement, un élément d'interface utilisateur peut sembler inactif si un geste de déplacement est tenté et que rien ne se passe.
Vignettes déplaçables
Le pseudo-élément thumb reçoit sa position à partir de var(--thumb-position)
à portée .gui-switch > input
. JavaScript peut fournir une valeur de style intégré sur l'entrée pour mettre à jour dynamiquement la position du pouce, ce qui donne l'impression qu'il suit le geste du pointeur. Lorsque le pointeur est relâché, supprimez les styles intégrés et déterminez si le déplacement était plus proche de la position "désactivé" ou "activé" à l'aide de la propriété personnalisée --thumb-position
. Il s'agit de l'épine dorsale de la solution : les événements de pointeur suivent conditionnellement les positions du pointeur pour modifier les propriétés personnalisées CSS.
Étant donné que le composant était déjà 100 % fonctionnel avant l'apparition de ce script, il faut pas mal de travail pour maintenir le comportement existant, comme cliquer sur un libellé pour activer ou désactiver la saisie. Notre JavaScript ne doit pas ajouter de fonctionnalités au détriment de celles existantes.
touch-action
Le déplacement est un geste personnalisé, ce qui en fait un excellent candidat pour les avantages touch-action
. Dans le cas de ce bouton bascule, un geste horizontal doit être géré par notre script, ou un geste vertical capturé pour la variante de bouton bascule vertical. Avec touch-action
, nous pouvons indiquer au navigateur les gestes à gérer sur cet élément, afin qu'un script puisse gérer un geste sans concurrence.
Le code CSS suivant indique au navigateur que lorsqu'un geste de pointeur commence dans cette piste de commutateur, il doit gérer les gestes verticaux et ne rien faire avec les gestes horizontaux :
.gui-switch > input {
touch-action: pan-y;
}
Le résultat souhaité est un geste horizontal qui ne fait pas non plus panoramiquer ni défiler la page. Un pointeur peut faire défiler verticalement le contenu à partir de la zone de saisie et faire défiler la page, mais les pointeurs horizontaux sont gérés de manière personnalisée.
Utilitaires de style de valeur de pixel
Lors de la configuration et du déplacement, différentes valeurs numériques calculées devront être extraites des éléments. Les fonctions JavaScript suivantes renvoient des valeurs de pixels calculées pour une propriété CSS donnée. Il est utilisé dans le script de configuration comme suit : 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,
}
Notez que window.getComputedStyle()
accepte un deuxième argument, un pseudo-élément cible. Il est plutôt pratique que JavaScript puisse lire autant de valeurs à partir d'éléments, même à partir de pseudo-éléments.
dragging
Il s'agit d'un moment clé pour la logique de déplacement. Voici quelques points à noter concernant le gestionnaire d'événements de la fonction :
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`)
}
Le héros du script est state.activethumb
, le petit cercle que ce script positionne avec un pointeur. L'objet switches
est un Map()
dont les clés sont des .gui-switch
et les valeurs sont des limites et des tailles mises en cache qui permettent au script de rester efficace. La gestion de la mise en page de droite à gauche s'effectue à l'aide de la même propriété personnalisée que celle utilisée par CSS --isLTR
, qui peut l'utiliser pour inverser la logique et continuer à prendre en charge la mise en page de droite à gauche. event.offsetX
est également utile, car il contient une valeur delta permettant de positionner le pouce.
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
Cette dernière ligne de CSS définit la propriété personnalisée utilisée par l'élément thumb. Cette attribution de valeur se ferait normalement au fil du temps, mais un événement de pointeur précédent a temporairement défini --thumb-transition-duration
sur 0s
, ce qui a permis d'éviter une interaction lente.
dragEnd
Pour que l'utilisateur puisse faire glisser le bouton loin en dehors du commutateur et le relâcher, un événement de fenêtre global doit être enregistré :
window.addEventListener('pointerup', event => {
if (!state.activethumb) return
dragEnd(event)
})
Je pense qu'il est très important qu'un utilisateur puisse faire glisser un élément librement et que l'interface soit suffisamment intelligente pour en tenir compte. Il n'a pas fallu grand-chose pour le gérer avec ce commutateur, mais il a fallu l'examiner attentivement pendant le processus de développement.
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()
}
L'interaction avec l'élément est terminée. Il est temps de définir la propriété "checked" de l'entrée et de supprimer tous les événements de geste. La case à cocher est modifiée avec state.activethumb.checked = determineChecked()
.
determineChecked()
Cette fonction, appelée par dragEnd
, détermine où se trouve actuellement le pouce dans les limites de sa piste et renvoie "true" s'il se trouve à mi-chemin ou au-delà de la piste :
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
}
Autres remarques
Le geste de déplacement a entraîné une dette de code en raison de la structure HTML initiale choisie, notamment en encapsulant l'entrée dans un libellé. Le libellé, qui est un élément parent, recevra les interactions de clic après la saisie. À la fin de l'événement dragEnd
, vous avez peut-être remarqué que padRelease()
est une fonction étrange.
const padRelease = () => {
state.recentlyDragged = true
setTimeout(_ => {
state.recentlyDragged = false
}, 300)
}
Cela permet de tenir compte du fait que le libellé reçoit ce clic ultérieur, car il désélectionne ou sélectionne l'interaction effectuée par l'utilisateur.
Si je devais le refaire, j'envisagerais d'ajuster le DOM avec JavaScript lors de la mise à niveau de l'UX, afin de créer un élément qui gère lui-même les clics sur les libellés et ne se heurte pas au comportement intégré.
Je n'aime pas écrire ce genre de code JavaScript, je ne veux pas gérer la propagation conditionnelle des événements :
const preventBubbles = event => {
if (state.recentlyDragged)
event.preventDefault() && event.stopPropagation()
}
Conclusion
Ce minuscule composant de commutateur a fini par être le plus difficile de tous les défis d'interface utilisateur jusqu'à présent ! Maintenant que vous savez comment j'ai fait, comment feriez-vous ? 🙂
Diversifions nos approches et découvrons toutes les façons de créer sur le Web. Créez une démo, tweetez-moi les liens et je l'ajouterai à la section des remix de la communauté ci-dessous !
Remix de la communauté
- @KonstantinRouda avec un élément personnalisé : démo et code.
- @jhvanderschee avec un bouton : Codepen.
Ressources
Retrouvez le code source .gui-switch
sur GitHub.