5 min de lecture
Un bouton doit répondre avant qu’on le relâche
Le retour à la pression, pas au clic. Pourquoi quelques millisecondes changent la sensation d’une interface, comment je le code en Vue, et ce qu’il faut prévoir pour le clavier et la confirmation.
Mélanie Da Silva, développeuse front-end
- Interaction
- Vue
- Motion
Appuyez sur un bouton d’iOS. Il réagit au moment où le doigt le touche, pas quand il le quitte. Cette différence tient en quelques dizaines de millisecondes, et pourtant c’est elle qui sépare une interface qui écoute d’une interface qui attend.
La plupart des sites réagissent trop tard. Pas parce que c’est difficile à corriger, mais parce que personne ne le remarque consciemment. On sent juste que « c’est mou », sans savoir pourquoi.
Le clic arrive trop tard
L’événement click se déclenche au relâchement. Si c’est là que le bouton change d’état, l’utilisateur a déjà appuyé, attendu et relâché sans rien voir. Sur mobile s’ajoute le délai du système, et l’écran paraît figé le temps d’un battement.
Le retour visuel doit vivre sur pointerdown, l’instant du contact. Le clic, lui, garde son rôle : déclencher l’action. Deux événements, deux responsabilités.
Essayez les deux versions ci-dessous, à la souris ou au doigt. La différence se sent plus qu’elle ne se voit.
La version la plus courte : CSS
Avant d’écrire du JavaScript, il y a :active. Le navigateur l’applique dès l’appui, à la souris comme au doigt, et aussi au clavier quand on enfonce la barre d’espace :
.button {
transition: transform 120ms ease-out;
}
.button:active {
transform: scale(0.97);
}
Pour un bouton simple, c’est tout ce qu’il faut, et c’est ce que j’utilise dans la majorité des cas. Zéro code, zéro état à gérer, et ça marche sans JavaScript.
La version Vue, quand il faut un état
Parfois l’appui déclenche autre chose qu’un style : un son, une vibration, un retour sur un élément voisin. Là, il faut l’événement :
<script setup lang="ts">
const pressed = ref(false)
</script>
<template>
<button
class="transition-transform duration-100 ease-out"
:class="pressed && 'scale-[0.97]'"
@pointerdown="pressed = true"
@pointerup="pressed = false"
@pointerleave="pressed = false"
@pointercancel="pressed = false"
>
Enregistrer
</button>
</template>
Trois détails comptent :
- L’échelle reste subtile. Entre 0,95 et 0,98. En dessous, le bouton semble s’enfoncer dans la page, et sur un petit élément le texte devient flou le temps de l’animation.
- La durée est courte. 100 à 160 ms. Plus long, et le retour arrive après la sensation, ce qui est pire que rien.
pointerleaveetpointercancelremettent l’état. Sinon le bouton reste enfoncé quand le doigt glisse en dehors, ou quand le système interrompt le geste pour un défilement.
Et au clavier ?
pointerdown ne se déclenche pas quand on valide avec Entrée ou Espace. Un bouton qui ne réagit qu’au pointeur est muet pour tout le monde au clavier, ce qui inclut beaucoup de gens qui utilisent un site toute la journée.
Si le retour passe par du JavaScript, j’écoute aussi le clavier :
<button
@keydown.space.enter="pressed = true"
@keyup.space.enter="pressed = false"
@blur="pressed = false"
>
Le blur évite un bouton coincé en position appuyée si le focus part pendant l’appui. Et le contour de focus reste visible : il ne bouge pas avec l’échelle, parce que la transformation s’applique à l’élément entier, contour compris. C’est un point de plus pour une échelle légère.
Et les animations réduites ?
Une échelle de 3 % sur 120 ms ne déclenche pas de gêne vestibulaire : ce n’est pas un mouvement dans l’espace, c’est un état. Je la garde même quand prefers-reduced-motion est actif. Ce que je retire dans ce cas, ce sont les déplacements, les rebonds et les transitions longues, pas le retour à l’appui.
La confirmation, c’est un autre moment
Le retour à la pression dit « je t’ai entendu ». La confirmation dit « c’est fait ». Ce sont deux moments, et ils méritent deux traitements : un rapide sur le bouton, un plus posé pour le message qui suit.
Pour une action qui prend du temps, le bouton passe par trois états, et chacun se voit :
<script setup lang="ts">
const status = ref<'idle' | 'saving' | 'saved'>('idle')
async function save() {
status.value = 'saving'
await api.save()
status.value = 'saved'
setTimeout(() => (status.value = 'idle'), 1500)
}
</script>
<template>
<button :disabled="status === 'saving'" :aria-busy="status === 'saving'" @click="save">
<span v-if="status === 'saving'">Enregistrement…</span>
<span v-else-if="status === 'saved'">Enregistré</span>
<span v-else>Enregistrer</span>
</button>
<p role="status" class="sr-only">{{ status === 'saved' ? 'Modifications enregistrées' : '' }}</p>
</template>
Deux choses à ne pas oublier :
- Le bouton garde sa largeur. Si « Enregistrement… » est plus long que « Enregistrer », le bouton s’élargit et tout ce qui est à côté saute. Une largeur minimale, ou un libellé de même longueur, règle le problème.
- Le message existe aussi pour un lecteur d’écran. La couleur qui passe au vert ne dit rien à qui ne la voit pas. Une zone
role="status"annonce la confirmation sans voler le focus.
Ce que ça change
Pris un par un, ces détails ne se remarquent pas. Personne ne dira « ce bouton répond 80 ms plus tôt ». Mais mis bout à bout, sur tous les boutons, tous les liens, tous les champs, ils font la différence entre un site qu’on trouve agréable sans savoir pourquoi et un site qu’on trouve lent sans savoir pourquoi.
Quand les deux moments sont là, le retour et la confirmation, personne ne les remarque. C’est le but.