Una descripción general básica de cómo compilar un componente de pestañas similar a los que se encuentran en las apps para iOS y Android.
En esta entrada, quiero compartir mis ideas sobre cómo crear un componente de pestañas para la Web que sea responsivo, admita múltiples entradas de dispositivos y funcione en todos los navegadores. Prueba la demostración.
Si prefieres un video, aquí tienes una versión de este artículo en YouTube:
Descripción general
Las pestañas son un componente común de los sistemas de diseño, pero pueden adoptar muchas formas. Primero, se crearon pestañas para computadoras de escritorio basadas en el elemento <frame>
y, ahora, tenemos componentes móviles fluidos que animan el contenido según las propiedades físicas.
Todos intentan hacer lo mismo: ahorrar espacio.
Actualmente, los elementos esenciales de la experiencia del usuario con pestañas son un área de navegación con botones que alterna la visibilidad del contenido en un marco de visualización. Muchas áreas de contenido diferentes comparten el mismo espacio, pero se presentan de forma condicional según el botón seleccionado en la navegación.

Tácticas web
En general, me resultó bastante sencillo crear este componente gracias a algunas funciones críticas de la plataforma web:
scroll-snap-points
para interacciones elegantes de deslizamiento y teclado con posiciones de detención de desplazamiento adecuadas- Vínculos directos a través de hashes de URL para la compatibilidad con el uso compartido y la fijación de desplazamiento en la página controlados por el navegador
- Compatibilidad con lectores de pantalla con lenguaje de marcado de elementos
<a>
yid="#hash"
prefers-reduced-motion
para habilitar las transiciones de fundido cruzado y el desplazamiento instantáneo en la página- La función web en borrador
@scroll-timeline
para subrayar y cambiar el color de la pestaña seleccionada de forma dinámica
El HTML
Básicamente, la UX aquí es la siguiente: hacer clic en un vínculo, hacer que la URL represente el estado de la página anidada y, luego, ver cómo se actualiza el área de contenido a medida que el navegador se desplaza al elemento coincidente.
Hay algunos miembros de contenido estructural allí: vínculos y :target
. Necesitamos una lista de vínculos, para lo que <nav>
es ideal, y una lista de elementos <article>
, para lo que <section>
es ideal. Cada hash de vínculo coincidirá con una sección, lo que permitirá que el navegador se desplace a través de la vinculación.
Por ejemplo, hacer clic en un vínculo enfoca automáticamente el artículo :target
en Chrome 89, sin necesidad de JS. Luego, el usuario puede desplazarse por el contenido del artículo con su dispositivo de entrada como siempre. Es contenido complementario, como se indica en el marcado.
Usé el siguiente lenguaje de marcado para organizar las pestañas:
<snap-tabs>
<header>
<nav>
<a></a>
<a></a>
<a></a>
<a></a>
</nav>
</header>
<section>
<article></article>
<article></article>
<article></article>
<article></article>
</section>
</snap-tabs>
Puedo establecer conexiones entre los elementos <a>
y <article>
con las propiedades href
y id
de la siguiente manera:
<snap-tabs>
<header>
<nav>
<a href="#responsive"></a>
<a href="#accessible"></a>
<a href="#overscroll"></a>
<a href="#more"></a>
</nav>
</header>
<section>
<article id="responsive"></article>
<article id="accessible"></article>
<article id="overscroll"></article>
<article id="more"></article>
</section>
</snap-tabs>
Luego, completé los artículos con cantidades mixtas de texto de relleno y los vínculos con un conjunto mixto de títulos de longitud e imágenes. Con el contenido listo, podemos comenzar con el diseño.
Diseños desplazables
Hay 3 tipos diferentes de áreas de desplazamiento en este componente:
- La navegación (rosa) se puede desplazar horizontalmente.
- El área de contenido (azul) se puede desplazar horizontalmente.
- Cada elemento del artículo (verde) se puede desplazar verticalmente.

Hay 2 tipos diferentes de elementos relacionados con el desplazamiento:
- Una ventana
Una caja con dimensiones definidas que tiene el estilo de propiedadoverflow
. - Una superficie de gran tamaño
En este diseño, son los contenedores de listas: vínculos de navegación, artículos de sección y contenido de artículos.
Diseño <snap-tabs>
El diseño de nivel superior que elegí fue flex (Flexbox). Establecí la dirección en column
, por lo que el encabezado y la sección se ordenan verticalmente. Esta es nuestra primera ventana de desplazamiento, y oculta todo lo que tiene overflow: hidden. Pronto, el encabezado y la sección emplearán el desplazamiento excesivo como zonas individuales.
<snap-tabs> <header></header> <section></section> </snap-tabs>
snap-tabs { display: flex; flex-direction: column; /* establish primary containing box */ overflow: hidden; position: relative; & > section { /* be pushy about consuming all space */ block-size: 100%; } & > header { /* defend againstneeding 100% */ flex-shrink: 0; /* fixes cross browser quarks */ min-block-size: fit-content; } }
Volviendo al diagrama colorido de 3 desplazamientos:
- Ahora,
<header>
está preparado para ser el contenedor de desplazamiento (rosa). <section>
está preparado para ser el contenedor de desplazamiento (azul).
Los marcos que destaqué a continuación con VisBug nos ayudan a ver las ventanas que crearon los contenedores de desplazamiento.

Diseño de pestañas <header>
El siguiente diseño es casi el mismo: uso flex para crear un ordenamiento vertical.
<snap-tabs> <header> <nav></nav> <span class="snap-indicator"></span> </header> <section></section> </snap-tabs>
header { display: flex; flex-direction: column; }
El .snap-indicator
debe desplazarse horizontalmente con el grupo de vínculos, y este diseño de encabezado ayuda a establecer esa etapa. Aquí no hay elementos con posición absoluta.

A continuación, los estilos de desplazamiento. Resulta que podemos compartir los estilos de desplazamiento entre nuestras 2 áreas de desplazamiento horizontal (encabezado y sección), por lo que creé una clase de utilidad, .scroll-snap-x
.
.scroll-snap-x {
/* browser decide if x is ok to scroll and show bars on, y hidden */
overflow: auto hidden;
/* prevent scroll chaining on x scroll */
overscroll-behavior-x: contain;
/* scrolling should snap children on x */
scroll-snap-type: x mandatory;
@media (hover: none) {
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
}
Cada uno necesita un desbordamiento en el eje X, contención de desplazamiento para atrapar el desplazamiento excesivo, barras de desplazamiento ocultas para dispositivos táctiles y, por último, scroll-snap para bloquear las áreas de presentación de contenido. El orden de las pestañas del teclado es accesible y cualquier interacción guía el enfoque de forma natural. Los contenedores de ajuste de desplazamiento también obtienen una agradable interacción de estilo de carrusel desde el teclado.
Diseño del encabezado de pestañas <nav>
Los vínculos de navegación deben estar dispuestos en una línea, sin saltos de línea, centrados verticalmente y cada elemento de vínculo debe ajustarse al contenedor de ajuste de desplazamiento. ¡Trabajo rápido para el CSS de 2021!
<nav> <a></a> <a></a> <a></a> <a></a> </nav>
nav { display: flex; & a { scroll-snap-align: start; display: inline-flex; align-items: center; white-space: nowrap; } }
Cada vínculo se diseña y dimensiona por sí mismo, por lo que el diseño de navegación solo necesita especificar la dirección y el flujo. Los anchos únicos en los elementos de navegación hacen que la transición entre pestañas sea divertida, ya que el indicador ajusta su ancho al nuevo objetivo. Según la cantidad de elementos que haya aquí, el navegador renderizará una barra de desplazamiento o no.

Diseño de pestañas <section>
Esta sección es un elemento flexible y debe ser el consumidor dominante de espacio. También debe crear columnas para colocar los artículos. Una vez más, ¡felicitaciones por el trabajo realizado en el CSS 2021! El elemento block-size: 100%
estira este elemento para que ocupe el elemento principal lo más posible y, luego, para su propio diseño, crea una serie de columnas que son 100%
el ancho del elemento principal. Los porcentajes funcionan muy bien aquí porque escribimos restricciones sólidas en el elemento principal.
<section> <article></article> <article></article> <article></article> <article></article> </section>
section { block-size: 100%; display: grid; grid-auto-flow: column; grid-auto-columns: 100%; }
Es como si dijéramos "expándete verticalmente lo más posible, de forma insistente" (recuerda el encabezado que establecimos en flex-shrink: 0
: es una defensa contra este impulso de expansión), lo que establece la altura de la fila para un conjunto de columnas de altura completa. El diseño auto-flow
le indica a la cuadrícula que siempre coloque los elementos secundarios en una línea horizontal, sin ajuste, exactamente lo que queremos: que desborden la ventana principal.

A veces, me cuesta entender estos conceptos. Este elemento de sección se ajusta a una caja, pero también creó un conjunto de cajas. Espero que los elementos visuales y las explicaciones te ayuden.
Diseño de pestañas <article>
El usuario debe poder desplazarse por el contenido del artículo, y las barras de desplazamiento solo deben aparecer si hay desbordamiento. Estos elementos del artículo se encuentran en una posición ordenada. Son, al mismo tiempo, un elemento principal y un elemento secundario de desplazamiento. El navegador realmente controla algunas interacciones difíciles con el mouse, el teclado y el tacto por nosotros.
<article> <h2></h2> <p></p> <p></p> <h2></h2> <p></p> <p></p> ... </article>
article { scroll-snap-align: start; overflow-y: auto; overscroll-behavior-y: contain; }
Decidí que los artículos se ajustaran dentro de su desplazador principal. Me gusta mucho cómo los elementos de vínculo de navegación y los elementos del artículo se ajustan al inicio en línea de sus respectivos contenedores de desplazamiento. Se ve y se siente como una relación armoniosa.

El artículo es un elemento secundario de la cuadrícula y su tamaño está predeterminado para ser el área de la ventana gráfica en la que queremos proporcionar la UX de desplazamiento. Esto significa que no necesito ningún estilo de altura o ancho aquí, solo necesito definir cómo se desborda. Establecí overflow-y en auto y, luego, también capturé las interacciones de desplazamiento con la práctica propiedad overscroll-behavior.
Resumen de las 3 áreas de desplazamiento
A continuación, elegí en la configuración del sistema "Mostrar siempre las barras de desplazamiento". Creo que es doblemente importante que el diseño funcione con este parámetro de configuración activado, ya que me permite revisar el diseño y la organización del desplazamiento.

Creo que ver el canal de la barra de desplazamiento en este componente ayuda a mostrar claramente dónde están las áreas de desplazamiento, la dirección que admiten y cómo interactúan entre sí. Ten en cuenta que cada uno de estos marcos de ventanas de desplazamiento también son elementos principales de diseño flexibles o de cuadrícula.
Las Herramientas para desarrolladores pueden ayudarnos a visualizar esto:

Los diseños de desplazamiento están completos: se pueden ajustar, tienen vínculos directos y son accesibles con el teclado. Base sólida para mejoras, estilo y deleite de la UX
Función destacada
Los elementos secundarios que se ajustan al desplazamiento mantienen su posición bloqueada durante el cambio de tamaño. Esto significa que JavaScript no tendrá que mostrar nada cuando se rote el dispositivo o se cambie el tamaño del navegador. Pruébalo en el Modo de dispositivo de las Herramientas para desarrolladores de Chromium. Para ello, selecciona cualquier modo que no sea Responsivo y, luego, cambia el tamaño del marco del dispositivo. Observa que el elemento permanece a la vista y bloqueado con su contenido. Esta opción está disponible desde que Chromium actualizó su implementación para que coincida con la especificación. Aquí tienes una entrada de blog sobre el tema.
Animación
El objetivo del trabajo de animación aquí es vincular claramente las interacciones con la respuesta de la IU. Esto ayuda a guiar o asistir al usuario para que descubra todo el contenido de forma (con suerte) fluida. Agregaré movimiento con propósito y de forma condicional. Ahora los usuarios pueden especificar sus preferencias de movimiento en su sistema operativo, y me encanta responder a sus preferencias en mis interfaces.
Vincularé un subrayado de la pestaña con la posición de desplazamiento del artículo. El ajuste no solo es una alineación atractiva, sino que también ancla el inicio y el final de una animación.
Esto mantiene el <nav>
, que actúa como un minimapa, conectado al contenido.
Verificaremos la preferencia de movimiento del usuario desde CSS y JS. Hay algunos lugares excelentes para ser considerado.
Comportamiento de desplazamiento
Existe la oportunidad de mejorar el comportamiento de movimiento de :target
y element.scrollIntoView()
. De forma predeterminada, es instantánea. El navegador solo establece la posición de desplazamiento. ¿Qué sucede si queremos hacer la transición a esa posición de desplazamiento en lugar de parpadear allí?
@media (prefers-reduced-motion: no-preference) {
.scroll-snap-x {
scroll-behavior: smooth;
}
}
Como aquí presentamos movimiento, y movimiento que el usuario no controla (como el desplazamiento), solo aplicamos este estilo si el usuario no tiene preferencias en su sistema operativo en relación con la reducción del movimiento. De esta manera, solo presentamos el movimiento de desplazamiento para las personas que lo aceptan.
Indicador de pestañas
El propósito de esta animación es ayudar a asociar el indicador con el estado del contenido. Decidí usar una transición de color gradual para los estilos de border-bottom
para los usuarios que prefieren un movimiento reducido y una animación de deslizamiento vinculada al desplazamiento con transición de color para los usuarios que no tienen problemas con el movimiento.
En las Herramientas para desarrolladores de Chromium, puedo activar o desactivar la preferencia y mostrar los 2 estilos de transición diferentes. Me divertí mucho creando esto.
@media (prefers-reduced-motion: reduce) {
snap-tabs > header a {
border-block-end: var(--indicator-size) solid hsl(var(--accent) / 0%);
transition: color .7s ease, border-color .5s ease;
&:is(:target,:active,[active]) {
color: var(--text-active-color);
border-block-end-color: hsl(var(--accent));
}
}
snap-tabs .snap-indicator {
visibility: hidden;
}
}
Oculto el .snap-indicator
cuando el usuario prefiere un movimiento reducido, ya que no lo necesito más. Luego, lo reemplazo por estilos border-block-end
y un transition
. También observa en la interacción de las pestañas que el elemento de navegación activo no solo tiene un resaltado de subrayado de la marca, sino que el color del texto también es más oscuro. El elemento activo tiene un mayor contraste de color del texto y un acento de luz inferior brillante.
Con solo unas pocas líneas adicionales de CSS, una persona se sentirá comprendida (en el sentido de que respetamos cuidadosamente sus preferencias de movimiento). Me encanta.
@scroll-timeline
En la sección anterior, te mostré cómo manejo los estilos de transición cruzada de movimiento reducido. En esta sección, te mostraré cómo vinculé el indicador y un área de desplazamiento. A continuación, veremos algunas cosas experimentales divertidas. Espero que sientas tanto entusiasmo como yo.
const { matches:motionOK } = window.matchMedia(
'(prefers-reduced-motion: no-preference)'
);
Primero, verifico la preferencia de movimiento del usuario desde JavaScript. Si el resultado es false
, significa que el usuario prefiere un movimiento reducido, por lo que no ejecutaremos ninguno de los efectos de movimiento de vinculación del desplazamiento.
if (motionOK) {
// motion based animation code
}
Al momento de escribir este documento, no hay compatibilidad del navegador con @scroll-timeline
. Es una especificación de borrador con solo implementaciones experimentales. Sin embargo, tiene un polyfill que uso en esta demostración.
ScrollTimeline
Si bien CSS y JavaScript pueden crear cronogramas de desplazamiento, elegí JavaScript para poder usar mediciones de elementos en vivo en la animación.
const sectionScrollTimeline = new ScrollTimeline({
scrollSource: tabsection, // snap-tabs > section
orientation: 'inline', // scroll in the direction letters flow
fill: 'both', // bi-directional linking
});
Quiero que un elemento siga la posición de desplazamiento de otro, y, para ello, creo un ScrollTimeline
que define el controlador de la vinculación de desplazamiento, el scrollSource
.
Normalmente, una animación en la Web se ejecuta en función de un tic de tiempo global, pero con un sectionScrollTimeline
personalizado en la memoria, puedo cambiar todo eso.
tabindicator.animate({
transform: ...,
width: ...,
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
Antes de entrar en los fotogramas clave de la animación, creo que es importante señalar que el seguidor del desplazamiento, tabindicator
, se animará en función de una línea de tiempo personalizada, el desplazamiento de nuestra sección. Esto completa la vinculación, pero falta el ingrediente final: los puntos con estado entre los que se animará, también conocidos como fotogramas clave.
Fotogramas clave dinámicos
Hay una forma declarativa pura de CSS muy potente para animar con @scroll-timeline
, pero la animación que elegí hacer era demasiado dinámica. No hay forma de realizar una transición entre el ancho de auto
, y no hay forma de crear de forma dinámica una cantidad de fotogramas clave según la longitud de los elementos secundarios.
Sin embargo, JavaScript sabe cómo obtener esa información, por lo que iteraremos sobre los elementos secundarios y tomaremos los valores calculados en el tiempo de ejecución:
tabindicator.animate({
transform: [...tabnavitems].map(({offsetLeft}) =>
`translateX(${offsetLeft}px)`),
width: [...tabnavitems].map(({offsetWidth}) =>
`${offsetWidth}px`)
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
Para cada tabnavitem
, desestructura la posición offsetLeft
y devuelve una cadena que la usa como un valor translateX
. Esto crea 4 fotogramas clave de transformación para la animación. Lo mismo se hace para el ancho: se le pregunta a cada uno cuál es su ancho dinámico y, luego, se usa como valor de fotograma clave.
Este es un ejemplo de resultado basado en mis fuentes y preferencias del navegador:
Fotogramas clave de translateX:
[...tabnavitems].map(({offsetLeft}) =>
`translateX(${offsetLeft}px)`)
// results in 4 array items, which represent 4 keyframe states
// ["translateX(0px)", "translateX(121px)", "translateX(238px)", "translateX(464px)"]
Fotogramas clave de ancho:
[...tabnavitems].map(({offsetWidth}) =>
`${offsetWidth}px`)
// results in 4 array items, which represent 4 keyframe states
// ["121px", "117px", "226px", "67px"]
Para resumir la estrategia, el indicador de la pestaña ahora se animará en 4 fotogramas clave según la posición de ajuste de desplazamiento del desplazador de la sección. Los puntos de ajuste crean una delineación clara entre nuestros fotogramas clave y realmente contribuyen a la sensación sincronizada de la animación.

El usuario controla la animación con su interacción, y ve cómo el ancho y la posición del indicador cambian de una sección a otra, con un seguimiento perfecto del desplazamiento.
Quizás no lo hayas notado, pero me enorgullece mucho la transición de color a medida que se selecciona el elemento de navegación destacado.
El gris claro sin seleccionar parece aún más alejado cuando el elemento destacado tiene más contraste. Es común hacer una transición de color para el texto, como cuando se coloca el cursor sobre él y cuando se selecciona, pero es de otro nivel hacer una transición de ese color al desplazarse, sincronizada con el indicador de subrayado.
A continuación, te mostramos cómo lo hice:
tabnavitems.forEach(navitem => {
navitem.animate({
color: [...tabnavitems].map(item =>
item === navitem
? `var(--text-active-color)`
: `var(--text-color)`)
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
});
Cada vínculo de navegación por pestañas necesita esta nueva animación de color, que hace un seguimiento de la misma línea de tiempo de desplazamiento que el indicador de subrayado. Uso la misma línea de tiempo que antes: dado que su función es emitir un tick en el desplazamiento, podemos usar ese tick en cualquier tipo de animación que queramos. Al igual que antes, creo 4 fotogramas clave en el bucle y devuelvo colores.
[...tabnavitems].map(item =>
item === navitem
? `var(--text-active-color)`
: `var(--text-color)`)
// results in 4 array items, which represent 4 keyframe states
// [
"var(--text-active-color)",
"var(--text-color)",
"var(--text-color)",
"var(--text-color)",
]
El fotograma clave con el color var(--text-active-color)
destaca el vínculo, y, de lo contrario, es un color de texto estándar. El bucle anidado hace que sea relativamente sencillo, ya que el bucle externo es cada elemento de navegación y el bucle interno son los fotogramas clave personales de cada elemento de navegación. Compruebo si el elemento del bucle externo es el mismo que el del bucle interno y uso eso para saber cuándo está seleccionado.
Me divertí mucho escribiendo esto. Mucho.
Más mejoras en JavaScript
Vale la pena recordar que el núcleo de lo que te muestro aquí funciona sin JavaScript. Dicho esto, veamos cómo podemos mejorarlo cuando JS está disponible.
Vínculos directos
Los vínculos directos son más un término para dispositivos móviles, pero creo que la intención del vínculo directo se cumple aquí con las pestañas, ya que puedes compartir una URL directamente al contenido de una pestaña. El navegador navegará en la página hasta el ID que coincida en el hash de la URL. Descubrí que este controlador onload
generaba el efecto en todas las plataformas.
window.onload = () => {
if (location.hash) {
tabsection.scrollLeft = document
.querySelector(location.hash)
.offsetLeft;
}
}
Sincronización del final del desplazamiento
Nuestros usuarios no siempre hacen clic o usan un teclado. A veces, solo se desplazan libremente, como deberían poder hacerlo. Cuando el desplazador de sección deja de desplazarse, el lugar en el que se detiene debe coincidir con la barra de navegación superior.
Así es como espero el final del desplazamiento:js
tabsection.addEventListener('scroll', () => {
clearTimeout(tabsection.scrollEndTimer);
tabsection.scrollEndTimer = setTimeout(determineActiveTabSection, 100);
});
Cada vez que se desplazan las secciones, borra el tiempo de espera de la sección, si existe, y comienza uno nuevo. Cuando se deja de desplazar las secciones, no borres el tiempo de espera y activa el evento 100 ms después de que se detenga el desplazamiento. Cuando se activa, llama a la función que intenta determinar dónde se detuvo el usuario.
const determineActiveTabSection = () => {
const i = tabsection.scrollLeft / tabsection.clientWidth;
const matchingNavItem = tabnavitems[i];
matchingNavItem && setActiveTab(matchingNavItem);
};
Si se supone que el desplazamiento se ajustó, dividir la posición de desplazamiento actual por el ancho del área de desplazamiento debería dar como resultado un número entero y no un decimal. Luego, intento tomar un elemento de navegación de nuestra caché a través de este índice calculado y, si encuentro algo, envío la coincidencia para que se establezca como activa.
const setActiveTab = tabbtn => {
tabnav
.querySelector(':scope a[active]')
.removeAttribute('active');
tabbtn.setAttribute('active', '');
tabbtn.scrollIntoView();
};
Para establecer la pestaña activa, primero se borra cualquier pestaña activa y, luego, se le otorga al elemento de navegación entrante el atributo de estado activo. La llamada a scrollIntoView()
tiene una interacción divertida con CSS que vale la pena destacar.
.scroll-snap-x {
overflow: auto hidden;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
@media (prefers-reduced-motion: no-preference) {
scroll-behavior: smooth;
}
}
En el CSS de la utilidad de ajuste de desplazamiento horizontal, anidamos una consulta de medios que aplica el desplazamiento smooth
si el usuario tolera el movimiento. JavaScript puede realizar llamadas libremente para mostrar los elementos en la vista, y CSS puede administrar la UX de forma declarativa.
A veces, hacen una pareja encantadora.
Conclusión
Ahora que sabes cómo lo hice, ¿cómo lo harías tú? Esto genera una arquitectura de componentes divertida. ¿Quién creará la primera versión con ranuras en su framework favorito? 🙂
Diversifiquemos nuestros enfoques y aprendamos todas las formas de crear contenido en la Web. Crea un Glitch, envíame un tweet con tu versión y la agregaré a la sección de Remixes de la comunidad que se encuentra a continuación.
Remixes de la comunidad
- @devnook, @rob_dodson y @DasSurma con componentes web: artículo.
- @jhvanderschee con botones: Codepen.