Cet atelier de programmation vous explique comment créer une expérience semblable aux stories Instagram sur le Web. Nous allons créer le composant au fur et à mesure, en commençant par le code HTML, puis le code CSS, puis le code JavaScript.
Consultez mon article de blog Créer un composant Stories pour en savoir plus sur les améliorations progressives apportées lors de la création de ce composant.
Configuration
- Cliquez sur Remix to Edit (Remixer pour modifier) pour rendre le projet modifiable.
- Ouvrez
app/index.html.
HTML
J'essaie toujours d'utiliser du HTML sémantique.
Comme chaque ami peut avoir un nombre illimité d'histoires, j'ai pensé qu'il était judicieux d'utiliser un élément <section> pour chaque ami et un élément <article> pour chaque histoire.
Mais commençons par le début. Tout d'abord, nous avons besoin d'un conteneur pour notre composant d'histoires.
Ajoutez un élément <div> à votre <body> :
<div class="stories">
</div>
Ajoutez des éléments <section> pour représenter des amis :
<div class="stories">
<section class="user"></section>
<section class="user"></section>
<section class="user"></section>
<section class="user"></section>
</div>
Ajoutez des éléments <article> pour représenter les stories :
<div class="stories">
<section class="user">
<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
<article class="story" style="--bg: url(https://picsum.photos/480/841);"></article>
</section>
<section class="user">
<article class="story" style="--bg: url(https://picsum.photos/481/840);"></article>
</section>
<section class="user">
<article class="story" style="--bg: url(https://picsum.photos/481/841);"></article>
</section>
<section class="user">
<article class="story" style="--bg: url(https://picsum.photos/482/840);"></article>
<article class="story" style="--bg: url(https://picsum.photos/482/843);"></article>
<article class="story" style="--bg: url(https://picsum.photos/482/844);"></article>
</section>
</div>
- Nous utilisons un service d'images (
picsum.com) pour créer des prototypes de stories. - L'attribut
stylede chaque<article>fait partie d'une technique de chargement d'espace réservé, que vous découvrirez plus en détail dans la section suivante.
CSS
Notre contenu est prêt à être stylisé. Transformons ces os en quelque chose avec lequel les gens voudront interagir. Aujourd'hui, nous allons travailler en priorité sur mobile.
.stories
Pour notre conteneur <div class="stories">, nous voulons un conteneur à défilement horizontal.
Pour ce faire, nous pouvons :
- Transformer le conteneur en grille
- Définir chaque enfant pour qu'il remplisse la piste de ligne
- Définir la largeur de chaque enfant sur la largeur de la fenêtre d'affichage d'un appareil mobile
La grille continuera de placer de nouvelles colonnes de largeur 100vw à droite de la précédente, jusqu'à ce que tous les éléments HTML de votre balisage soient placés.
Ajoutez le code CSS suivant en bas de app/css/index.css :
.stories {
display: grid;
grid: 1fr / auto-flow 100%;
gap: 1ch;
}
Maintenant que le contenu dépasse de la fenêtre d'affichage, nous devons indiquer au conteneur comment le gérer. Ajoutez les lignes de code en surbrillance à votre ensemble de règles .stories :
.stories {
display: grid;
grid: 1fr / auto-flow 100%;
gap: 1ch;
overflow-x: auto;
scroll-snap-type: x mandatory;
overscroll-behavior: contain;
touch-action: pan-x;
}
Nous voulons un défilement horizontal. Nous allons donc définir overflow-x sur auto. Lorsque l'utilisateur fait défiler l'écran, nous voulons que le composant s'arrête doucement sur l'histoire suivante. Nous allons donc utiliser scroll-snap-type: x mandatory. Pour en savoir plus sur ce CSS, consultez les sections Points d'ancrage du défilement CSS et overscroll-behavior de mon article de blog.
Le conteneur parent et les enfants doivent tous deux accepter le défilement scroll-snap. Nous allons donc nous en occuper maintenant. Ajoutez le code suivant en bas de app/css/index.css :
.user {
scroll-snap-align: start;
scroll-snap-stop: always;
}
Votre application ne fonctionne pas encore, mais la vidéo ci-dessous montre ce qui se passe lorsque scroll-snap-type est activé et désactivé. Lorsque cette option est activée, chaque défilement horizontal passe à la story suivante. Si elle est désactivée, le navigateur utilise son comportement de défilement par défaut.
Cela vous permettra de faire défiler vos amis, mais nous devons encore résoudre un problème avec les stories.
.user
Créons une mise en page dans la section .user qui organise ces éléments d'histoire enfants. Nous allons utiliser une astuce d'empilement pratique pour résoudre ce problème.
Nous créons essentiellement une grille 1x1 où la ligne et la colonne ont le même alias de grille [story], et chaque élément de la grille d'articles va essayer de revendiquer cet espace, ce qui entraîne un empilement.
Ajoutez le code en surbrillance à votre ensemble de règles .user :
.user {
scroll-snap-align: start;
scroll-snap-stop: always;
display: grid;
grid: [story] 1fr / [story] 1fr;
}
Ajoutez l'ensemble de règles suivant en bas du fichier app/css/index.css :
.story {
grid-area: story;
}
Maintenant, sans positionnement absolu, flottement ou autre directive de mise en page qui sort un élément du flux, nous sommes toujours dans le flux. De plus, il n'y a presque pas de code, regardez ! La vidéo et l'article de blog détaillent ce point.
.story
Il ne nous reste plus qu'à styliser l'élément de l'histoire lui-même.
Nous avons mentionné précédemment que l'attribut style de chaque élément <article> fait partie d'une technique de chargement d'espace réservé :
<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
Nous allons utiliser la propriété background-image de CSS, qui nous permet de spécifier plusieurs images de fond. Nous pouvons les classer de manière à ce que l'image de l'utilisateur se trouve en haut et s'affiche automatiquement une fois le chargement terminé. Pour ce faire, nous allons placer l'URL de notre image dans une propriété personnalisée (--bg) et l'utiliser dans notre CSS pour la superposer à l'espace réservé de chargement.
Commençons par mettre à jour l'ensemble de règles .story pour remplacer un dégradé par une image de fond une fois le chargement terminé. Ajoutez le code en surbrillance à votre ensemble de règles .story :
.story {
grid-area: story;
background-size: cover;
background-image:
var(--bg),
linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}
Définir background-size sur cover garantit qu'il n'y a pas d'espace vide dans la fenêtre d'affichage, car l'image la remplit. La définition de deux images de fond nous permet d'utiliser une astuce CSS appelée loading tombstone (chargement de l'espace réservé) :
- L'image de fond 1 (
var(--bg)) est l'URL que nous avons transmise de manière intégrée dans le code HTML. - Image de fond 2 (
linear-gradient(to top, lch(98 0 0), lch(90 0 0))est un dégradé à afficher pendant le chargement de l'URL)
Le CSS remplacera automatiquement le dégradé par l'image une fois le téléchargement de l'image terminé.
Nous allons maintenant ajouter du code CSS pour supprimer certains comportements, ce qui permettra au navigateur de se déplacer plus rapidement.
Ajoutez le code en surbrillance à votre ensemble de règles .story :
.story {
grid-area: story;
background-size: cover;
background-image:
var(--bg),
linear-gradient(to top, lch(98 0 0), lch(90 0 0));
user-select: none;
touch-action: manipulation;
}
user-select: noneempêche les utilisateurs de sélectionner accidentellement du textetouch-action: manipulationindique au navigateur que ces interactions doivent être traitées comme des événements tactiles, ce qui évite au navigateur d'essayer de déterminer si vous cliquez sur une URL ou non.
Enfin, ajoutons un peu de CSS pour animer la transition entre les stories. Ajoutez le code en surbrillance à votre ensemble de règles .story :
.story {
grid-area: story;
background-size: cover;
background-image:
var(--bg),
linear-gradient(to top, lch(98 0 0), lch(90 0 0));
user-select: none;
touch-action: manipulation;
transition: opacity .3s cubic-bezier(0.4, 0.0, 1, 1);
&.seen {
opacity: 0;
pointer-events: none;
}
}
La classe .seen sera ajoutée à une story qui nécessite une sortie.
J'ai obtenu la fonction d'accélération personnalisée (cubic-bezier(0.4, 0.0, 1,1)) à partir du guide Easing (Accélération) de Material Design (faites défiler la page jusqu'à la section Accelerated easing (Accélération accélérée)).
Si vous avez l'œil, vous avez probablement remarqué la déclaration pointer-events: none et vous vous demandez peut-être à quoi elle sert. Je dirais que c'est le seul inconvénient de la solution jusqu'à présent. Nous en avons besoin, car un élément .seen.story sera placé au-dessus et recevra les appuis, même s'il est invisible. En définissant pointer-events sur none, nous transformons l'histoire de verre en fenêtre et ne volons plus les interactions de l'utilisateur. Ce n'est pas un mauvais compromis, et ce n'est pas trop difficile à gérer dans notre CSS pour le moment. Nous ne jonglons pas avec z-index. Je suis toujours confiant.
JavaScript
Les interactions d'un composant Stories sont assez simples pour l'utilisateur : appuyer à droite pour avancer, appuyer à gauche pour revenir en arrière. Les choses simples pour les utilisateurs ont tendance à être difficiles pour les développeurs. Nous nous occuperons toutefois d'une grande partie de ces tâches.
Configuration
Pour commencer, calculons et stockons autant d'informations que possible.
Ajoutez le code suivant à app/js/index.js :
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
Notre première ligne de JavaScript récupère et stocke une référence à la racine de notre élément HTML principal. La ligne suivante calcule l'emplacement du milieu de notre élément, afin que nous puissions décider si un appui doit faire avancer ou reculer.
État
Nous créons ensuite un petit objet avec un état pertinent pour notre logique. Dans ce cas, nous ne nous intéressons qu'à l'histoire en cours. Dans notre balisage HTML, nous pouvons y accéder en récupérant le premier ami et sa story la plus récente. Ajoutez le code en surbrillance à votre app/js/index.js :
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
const state = {
current_story: stories.firstElementChild.lastElementChild
}
Écouteurs
Nous disposons désormais de suffisamment de logique pour commencer à écouter les événements utilisateur et les diriger.
Souris
Commençons par écouter l'événement 'click' sur notre conteneur d'articles.
Ajoutez le code en surbrillance à app/js/index.js :
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
const state = {
current_story: stories.firstElementChild.lastElementChild
}
stories.addEventListener('click', e => {
if (e.target.nodeName !== 'ARTICLE')
return
navigateStories(
e.clientX > median
? 'next'
: 'prev')
})
Si un clic se produit et qu'il ne s'agit pas d'un élément <article>, nous l'ignorons et ne faisons rien.
S'il s'agit d'un article, nous récupérons la position horizontale de la souris ou du doigt avec clientX. Nous n'avons pas encore implémenté navigateStories, mais l'argument qu'il accepte spécifie la direction à suivre. Si la position de l'utilisateur est supérieure à la médiane, nous savons que nous devons accéder à next, sinon à prev (précédent).
Clavier
Maintenant, écoutons les pressions sur le clavier. Si l'utilisateur appuie sur la flèche vers le bas, il accède à next. S'il s'agit de la flèche vers le haut, nous passons à prev.
Ajoutez le code en surbrillance à app/js/index.js :
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
const state = {
current_story: stories.firstElementChild.lastElementChild
}
stories.addEventListener('click', e => {
if (e.target.nodeName !== 'ARTICLE')
return
navigateStories(
e.clientX > median
? 'next'
: 'prev')
})
document.addEventListener('keydown', ({key}) => {
if (key !== 'ArrowDown' || key !== 'ArrowUp')
navigateStories(
key === 'ArrowDown'
? 'next'
: 'prev')
})
Navigation dans les stories
Il est temps de s'attaquer à la logique métier unique des stories et à l'expérience utilisateur qui les ont rendues célèbres. Cela peut sembler complexe et difficile, mais si vous l'examinez ligne par ligne, vous verrez que c'est assez facile à comprendre.
Nous stockons d'abord quelques sélecteurs qui nous aident à décider s'il faut faire défiler l'écran jusqu'à un ami ou afficher/masquer une story. Comme nous travaillons dans le code HTML, nous allons l'interroger pour vérifier la présence d'amis (utilisateurs) ou d'histoires (histoire).
Ces variables nous aideront à répondre à des questions telles que : "Étant donné la story x, est-ce que 'suivant' signifie passer à une autre story du même ami ou à un autre ami ?" Pour cela, j'ai utilisé la structure arborescente que nous avons créée, en accédant aux parents et à leurs enfants.
Ajoutez le code suivant en bas de app/js/index.js :
const navigateStories = direction => {
const story = state.current_story
const lastItemInUserStory = story.parentNode.firstElementChild
const firstItemInUserStory = story.parentNode.lastElementChild
const hasNextUserStory = story.parentElement.nextElementSibling
const hasPrevUserStory = story.parentElement.previousElementSibling
}
Voici notre objectif de logique métier, aussi proche que possible du langage naturel :
- Décidez de la façon de gérer l'appui.
- S'il existe une story suivante/précédente : afficher cette story
- S'il s'agit de la dernière/première story de l'ami : afficher un nouvel ami
- S'il n'y a pas de story dans cette direction : ne rien faire
- Stocker la nouvelle story actuelle dans
state
Ajoutez le code en surbrillance à votre fonction navigateStories :
const navigateStories = direction => {
const story = state.current_story
const lastItemInUserStory = story.parentNode.firstElementChild
const firstItemInUserStory = story.parentNode.lastElementChild
const hasNextUserStory = story.parentElement.nextElementSibling
const hasPrevUserStory = story.parentElement.previousElementSibling
if (direction === 'next') {
if (lastItemInUserStory === story && !hasNextUserStory)
return
else if (lastItemInUserStory === story && hasNextUserStory) {
state.current_story = story.parentElement.nextElementSibling.lastElementChild
story.parentElement.nextElementSibling.scrollIntoView({
behavior: 'smooth'
})
}
else {
story.classList.add('seen')
state.current_story = story.previousElementSibling
}
}
else if(direction === 'prev') {
if (firstItemInUserStory === story && !hasPrevUserStory)
return
else if (firstItemInUserStory === story && hasPrevUserStory) {
state.current_story = story.parentElement.previousElementSibling.firstElementChild
story.parentElement.previousElementSibling.scrollIntoView({
behavior: 'smooth'
})
}
else {
story.nextElementSibling.classList.remove('seen')
state.current_story = story.nextElementSibling
}
}
}
Essayer
- Pour prévisualiser le site, appuyez sur View App (Afficher l'application), puis sur Fullscreen (Plein écran)
.
Conclusion
Voilà qui résume mes besoins concernant le composant. N'hésitez pas à l'enrichir, à l'alimenter avec des données et, de manière générale, à vous l'approprier.