Cet atelier de programmation vous explique comment créer une expérience semblable à Instagram Stories sur le Web. Nous allons créer le composant au fur et à mesure, en commençant par le code HTML, puis le CSS, puis le 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 Remixer pour modifier pour rendre le projet modifiable.
- Ouvrez
app/index.html
.
HTML
Je m'efforce toujours d'utiliser un code HTML sémantique.
Étant donné que 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.
Reprenons du début. Tout d'abord, nous avons besoin d'un conteneur pour notre composant "stories".
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 des 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 d'histoires. - L'attribut
style
de chaque<article>
fait partie d'une technique de chargement d'espace réservé, que vous découvrirez dans la section suivante.
CSS
Nos contenus sont prêts à être stylisés. Transformons ces os en quelque chose avec lequel les utilisateurs voudront interagir. Nous allons travailler en priorité sur le mobile aujourd'hui.
.stories
Pour notre conteneur <div class="stories">
, nous voulons un conteneur à défilement horizontal.
Pour ce faire, procédez comme suit:
- Faire du conteneur une 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 vue d'un appareil mobile
La grille continuera de placer de nouvelles colonnes de 100vw
de large à droite de la précédente, jusqu'à ce qu'elle ait placé tous les éléments HTML dans votre balisage.
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 la fenêtre d'affichage, il est temps d'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 souhaitons un défilement horizontal. Nous allons donc définir overflow-x
sur auto
. Lorsque l'utilisateur fait défiler la page, nous voulons que le composant repose doucement sur l'histoire suivante. Nous allons donc utiliser scroll-snap-type: x mandatory
. Pour en savoir plus sur ce CSS, consultez les sections CSS Scroll Snap Points (Points d'ancrage de défilement CSS) et overscroll-behavior (comportement de défilement excessif) de mon article de blog.
Le conteneur parent et les enfants doivent accepter le forçage du défilement. Commençons par cela. 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 s'arrête sur la story suivante. Si elle est désactivée, le navigateur utilise son comportement de défilement par défaut.
Vous allez faire défiler vos amis, mais nous avons encore un problème avec les stories à résoudre.
.user
Créons une mise en page dans la section .user
qui place ces éléments de story enfant. Pour résoudre ce problème, nous allons utiliser une astuce d'empilement pratique.
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 de l'histoire va essayer de revendiquer cet espace, ce qui entraînera une pile.
Ajoutez le code mis 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 le jeu de règles suivant en bas de app/css/index.css
:
.story {
grid-area: story;
}
Maintenant, sans positionnement absolu, flottants ni autres directives de mise en page qui retirent un élément du flux, nous sommes toujours dans le flux. De plus, il n'y a presque pas de code. Regardez ! Ce point est détaillé dans la vidéo et l'article de blog.
.story
Il ne nous reste plus qu'à styliser l'élément de récit 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
du CSS, qui nous permet de spécifier plusieurs images de fond. Nous pouvons les mettre dans un ordre afin que notre photo d'utilisateur soit en haut et s'affiche automatiquement une fois le chargement terminé. Pour ce faire, nous allons placer notre URL d'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 le jeu de règles .story
pour remplacer un dégradé par une image de fond une fois le chargement terminé. Ajoutez le code mis 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 le viewport, car notre image le remplira. Définir deux images de fond nous permet d'utiliser une astuce Web CSS appelée loading tombstone (pierre tombale de chargement) :
- L'image de fond 1 (
var(--bg)
) est l'URL que nous avons transmise en ligne 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 terminé.
Nous allons ensuite ajouter du CSS pour supprimer certains comportements, ce qui libère le navigateur et lui permet de s'exécuter plus rapidement.
Ajoutez le code mis 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: none
empêche les utilisateurs de sélectionner accidentellement du texte.touch-action: manipulation
indique 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 mis 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
est ajoutée à une histoire qui nécessite une sortie.
J'ai obtenu la fonction d'atténuation personnalisée (cubic-bezier(0.4, 0.0, 1,1)
) dans le guide Atténuation de Material Design (faites défiler la page jusqu'à la section Atténuation 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 pourquoi. Je dirais que c'est le seul inconvénient de la solution jusqu'à présent. Nous avons besoin de cela, car un élément .seen.story
se trouve en haut et reçoit des pressions, 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 aucune interaction 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 satisfait de cette décision.
JavaScript
Les interactions d'un composant Stories sont assez simples pour l'utilisateur: appuyez sur la droite pour avancer, appuyez sur la gauche pour revenir en arrière. Les choses simples pour les utilisateurs sont souvent difficiles pour les développeurs. Nous nous en occuperons en grande partie.
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 code 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 se faire en avant ou en arrière.
É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 mis 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'histoires.
Ajoutez le code mis 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 concerne pas un élément <article>
, nous abandonnons et ne faisons rien.
S'il s'agit d'un article, nous saisissons la position horizontale de la souris ou du doigt avec clientX
. Nous n'avons pas encore implémenté navigateStories
, mais l'argument qu'il prend spécifie la direction à prendre. Si cette position de l'utilisateur est supérieure à la médiane, nous savons que nous devons accéder à next
, sinon à prev
(précédent).
Clavier
Écoutons maintenant les pressions sur le clavier. Si vous appuyez sur la flèche vers le bas, vous accédez à next
. Si l'icône est la flèche vers le haut, nous allons à prev
.
Ajoutez le code mis 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 pour laquelle elles sont devenues célèbres. Cela semble lourd et compliqué, mais si vous le prenez ligne par ligne, vous constaterez qu'il est assez digeste.
Au départ, nous stockons des sélecteurs qui nous aident à décider de faire défiler un ami ou d'afficher/masquer une story. Étant donné que nous travaillons sur le code HTML, nous allons l'interroger pour savoir s'il contient des amis (utilisateurs) ou des histoires (story).
Ces variables nous aideront à répondre à des questions telles que "Étant donné l'histoire X, "suivant" signifie-t-il passer à une autre histoire de cet ami ou à celle d'un autre ami ?". J'ai fait cela en utilisant la structure arborescente que nous avons créée, en touchant les 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, le plus proche possible du langage naturel:
- Décider de la manière de gérer le contact
- Si une story suivante/précédente est disponible, l'afficher
- S'il s'agit de la première ou de la dernière story de l'ami: afficher un nouvel ami
- S'il n'y a pas d'histoire à développer dans cette direction, ne faites rien.
- Placer la nouvelle histoire actuelle dans
state
Ajoutez le code mis 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 Afficher l'application, puis sur Plein écran .
Conclusion
C'est tout pour les besoins que j'avais concernant le composant. N'hésitez pas à l'utiliser, à l'alimenter avec des données et, en général, à l'adapter à vos besoins.