En este codelab, aprenderás a crear una experiencia como las Historias de Instagram en la Web. Construiremos el componente a medida que avancemos, comenzando con HTML, luego CSS y, finalmente, JavaScript.
Consulta mi entrada de blog Cómo compilar un componente de historias para obtener información sobre las mejoras progresivas que se realizaron durante la compilación de este componente.
Configuración
- Haz clic en Remix para editar para que el proyecto sea editable.
- Abre
app/index.html
.
HTML
Siempre intento usar HTML semántico.
Dado que cada amigo puede tener cualquier cantidad de historias, pensé que sería conveniente usar un elemento <section>
para cada amigo y un elemento <article>
para cada historia.
Pero comencemos desde el principio. Primero, necesitamos un contenedor para nuestro
componente de historias.
Agrega un elemento <div>
a tu <body>
:
<div class="stories">
</div>
Agrega algunos elementos <section>
para representar a los amigos:
<div class="stories">
<section class="user"></section>
<section class="user"></section>
<section class="user"></section>
<section class="user"></section>
</div>
Agrega algunos elementos <article>
para representar historias:
<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>
- Usamos un servicio de imágenes (
picsum.com
) para ayudar a crear prototipos de historias. - El atributo
style
en cada<article>
forma parte de una técnica de carga de marcador de posición, sobre la que obtendrás más información en la siguiente sección.
CSS
Nuestro contenido está listo para aplicarle diseño. Convirtamos esos esqueletos en algo con lo que las personas querrán interactuar. Hoy trabajaremos en la versión para dispositivos móviles.
.stories
Para nuestro contenedor <div class="stories">
, queremos un contenedor de desplazamiento horizontal.
Para ello, puedes hacer lo siguiente:
- Cómo hacer que el contenedor sea una cuadrícula
- Cómo configurar cada elemento secundario para que ocupe el segmento de fila
- Hacer que el ancho de cada elemento secundario sea el ancho de la ventana de visualización de un dispositivo móvil
La cuadrícula seguirá colocando nuevas columnas de 100vw
de ancho a la derecha de la anterior hasta que coloque todos los elementos HTML en tu marcado.
Agrega el siguiente CSS a la parte inferior de app/css/index.css
:
.stories {
display: grid;
grid: 1fr / auto-flow 100%;
gap: 1ch;
}
Ahora que tenemos contenido que se extiende más allá de la ventana de visualización, es hora de indicarle al contenedor cómo manejarlo. Agrega las líneas de código destacadas a tu conjunto de reglas .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;
}
Queremos que el desplazamiento sea horizontal, por lo que configuraremos overflow-x
como auto
. Cuando el usuario se desplaza, queremos que el componente se apoye suavemente en la siguiente historia, por lo que usaremos scroll-snap-type: x mandatory
. Obtén más información sobre este CSS en las secciones Puntos de ajuste de desplazamiento del CSS y overscroll-behavior de mi entrada de blog.
Tanto el contenedor superior como los secundarios deben aceptar el ajuste del desplazamiento, así que vamos a controlar eso ahora. Agrega el siguiente código al final de app/css/index.css
:
.user {
scroll-snap-align: start;
scroll-snap-stop: always;
}
Tu app aún no funciona, pero en el siguiente video se muestra lo que sucede cuando se habilita y se inhabilita scroll-snap-type
. Cuando está habilitada, cada desplazamiento horizontal se ajusta a la siguiente historia. Cuando está inhabilitada, el navegador usa su
comportamiento de desplazamiento predeterminado.
De esta manera, podrás desplazarte por tus amigos, pero aún tenemos un problema con las historias que debemos resolver.
.user
Creemos un diseño en la sección .user
que ordene esos elementos secundarios de la historia. Para resolver este problema, usaremos un truco de apilamiento útil.
En esencia, estamos creando una cuadrícula de 1 × 1 en la que la fila y la columna tienen el mismo alias de cuadrícula de [story]
, y cada elemento de la cuadrícula de la historia intentará reclamar ese espacio, lo que dará como resultado una pila.
Agrega el código destacado a tu conjunto de reglas .user
:
.user {
scroll-snap-align: start;
scroll-snap-stop: always;
display: grid;
grid: [story] 1fr / [story] 1fr;
}
Agrega el siguiente conjunto de reglas a la parte inferior de app/css/index.css
:
.story {
grid-area: story;
}
Ahora, sin posicionamiento absoluto, números de punto flotante ni otras directivas de diseño que sacan un elemento del flujo, seguimos en el flujo. Además, es como si no hubiera código, ¡mira! Esto se explica con más detalle en el video y en la entrada de blog.
.story
Ahora solo tenemos que aplicar diseño al elemento de la historia.
Anteriormente, mencionamos que el atributo style
en cada elemento <article>
forma parte de una técnica de carga de marcador de posición:
<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
Usaremos la propiedad background-image
de CSS, que nos permite especificar más de una imagen de fondo. Podemos ordenarlas para que la foto del usuario esté en la parte superior y aparezca automáticamente cuando termine de cargarse. Para habilitar esto, colocaremos la URL de nuestra imagen en una propiedad personalizada (--bg
) y la usaremos en nuestro CSS para superponerla con el marcador de posición de carga.
Primero, actualicemos el conjunto de reglas .story
para reemplazar un gradiente por una imagen de fondo una vez que se haya terminado de cargar. Agrega el código destacado a tu conjunto de reglas .story
:
.story {
grid-area: story;
background-size: cover;
background-image:
var(--bg),
linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}
Establecer background-size
en cover
garantiza que no haya espacio vacío en el viewport porque nuestra imagen lo ocupará. Definir 2 imágenes de fondo nos permite usar un truco web de CSS llamado lápida de carga:
- La imagen de fondo 1 (
var(--bg)
) es la URL que pasamos intercalada en el código HTML. - Imagen de fondo 2 (
linear-gradient(to top, lch(98 0 0), lch(90 0 0))
es un gradiente que se muestra mientras se carga la URL)
El CSS reemplazará automáticamente el gradiente por la imagen una vez que se termine de descargar.
A continuación, agregaremos un poco de CSS para quitar algunos comportamientos, lo que liberará al navegador para que se mueva más rápido.
Agrega el código destacado a tu conjunto de reglas .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
evita que los usuarios seleccionen texto por accidente.touch-action: manipulation
le indica al navegador que estas interacciones deben tratarse como eventos táctiles, lo que libera al navegador de intentar decidir si haces clic en una URL o no.
Por último, agreguemos un poco de CSS para animar la transición entre las historias. Agrega el código destacado a tu conjunto de reglas .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 clase .seen
se agregará a una historia que necesite una salida.
Obtuve la función de suavización personalizada (cubic-bezier(0.4, 0.0, 1,1)
) de la guía de suavización de Material Design (desplázate hasta la sección Suavización acelerada).
Si tienes un buen ojo, es probable que hayas notado la declaración pointer-events: none
y te estés rascando la cabeza en este momento. Diría que esta es la única desventaja de la solución hasta ahora. Necesitamos esto porque un elemento .seen.story
estará en la parte superior y recibirá toques, aunque sea invisible. Si configuramos pointer-events
en none
, convertimos la historia de vidrio en una ventana y no robamos más interacciones del usuario. No es una mala compensación, ni muy difícil de administrar en nuestro CSS en este momento. No estamos haciendo malabares con z-index
. Aún me siento bien con esto.
JavaScript
Las interacciones de un componente de Historias son bastante simples para el usuario: presiona la esquina superior derecha para avanzar y la esquina superior izquierda para retroceder. Las tareas simples para los usuarios suelen ser un trabajo difícil para los desarrolladores. Sin embargo, nos encargaremos de muchos de ellos.
Configuración
Para comenzar, calculemos y almacenemos la mayor cantidad de información posible.
Agrega el siguiente código a app/js/index.js
:
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
Nuestra primera línea de JavaScript toma y almacena una referencia a la raíz de nuestro elemento HTML principal. La siguiente línea calcula dónde está el medio de nuestro elemento, de modo que podamos decidir si un toque debe avanzar o retroceder.
Estado
A continuación, creamos un objeto pequeño con un estado relevante para nuestra lógica. En este caso, solo nos interesa la historia actual. En nuestro marcado HTML, podemos acceder a él si tomamos el primer amigo y su historia más reciente. Agrega el código destacado a tu app/js/index.js
:
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
const state = {
current_story: stories.firstElementChild.lastElementChild
}
Objetos de escucha
Ahora tenemos suficiente lógica para comenzar a escuchar los eventos del usuario y dirigirlos.
Ratón
Comencemos por escuchar el evento 'click'
en nuestro contenedor de historias.
Agrega el código destacado a 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 se produce un clic y no está en un elemento <article>
, salimos y no hacemos nada.
Si es un artículo, tomamos la posición horizontal del mouse o del dedo con clientX
. Aún no implementamos navigateStories
, pero el argumento que toma especifica en qué dirección debemos ir. Si esa posición del usuario es mayor que la mediana, sabemos que debemos navegar a next
, de lo contrario, a prev
(anterior).
Teclado
Ahora, escuchemos las pulsaciones del teclado. Si se presiona la flecha hacia abajo, navegamos a next
. Si es la flecha hacia arriba, vamos a prev
.
Agrega el código destacado a 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')
})
Navegación de Historias
Es hora de abordar la lógica empresarial única de las historias y la UX por la que se hicieron famosos. Parece complicado, pero creo que, si lo tomas línea por línea, te darás cuenta de que es bastante fácil de entender.
Al principio, ocultamos algunos selectores que nos ayudan a decidir si desplazarnos a un amigo o mostrar o ocultar una historia. Como estamos trabajando en el código HTML, lo consultaremos para detectar la presencia de amigos (usuarios) o historias (historia).
Estas variables nos ayudarán a responder preguntas como "Dada la historia X, ¿"Siguiente" significa pasar a otra historia de este mismo amigo o a un amigo diferente?". Para ello, usé la estructura de árbol que construimos y me comuniqué con los padres y sus hijos.
Agrega el siguiente código al final 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
}
Este es nuestro objetivo de lógica empresarial, lo más cercano posible al lenguaje natural:
- Decide cómo controlar el toque
- Si hay una historia anterior o siguiente, muéstrala
- Si es la primera o la última historia del amigo, muestra un amigo nuevo.
- Si no hay una historia que seguir en esa dirección, no hagas nada.
- Guarda la nueva historia actual en
state
Agrega el código destacado a tu función 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
}
}
}
Probar
- Para obtener una vista previa del sitio, presiona Ver app. Luego, presiona Pantalla completa .
Conclusión
Eso es todo sobre las necesidades que tenía con el componente. No dudes en basarte en él, impulsarlo con datos y, en general, personalizarlo.