#1
The Shadow
Grid Workshop
Streaming scroll
Estantería horizontal al estilo streaming con snap, navegación por flechas y teclado, y contador de posición visible. Las flechas se deshabilitan en los extremos.
The Shadow
#1
The Shadow
#2
The Shadow
#3
The Shadow
#4
The Shadow
#5
The Shadow
#6
The Shadow
#7
The Shadow
#8
The Shadow
Cómo funciona y cómo implementarlo
Cómo funciona
Scroll horizontal con snap —
.scroll__track usa overflow-x:auto y scroll-snap-type:x mandatory; cada item tiene scroll-snap-align:start, así las portadas encastran al inicio de la vista.
Flechas —
initScroll() calcula el paso (ancho de item + column-gap) y hace track.scrollBy({ left: ±step*3 }). El evento scroll deshabilita cada flecha al llegar al extremo.
Contador —
el
aria-live muestra el rango visible, derivado de scrollLeft / step y de cuántos items entran en el track.
Clave:
min-width: 0 —
el contenedor y cada envoltorio flex/grid necesitan min-width:0; si no, el track se estira al ancho del contenido y el scroll interno se rompe (las flechas desaparecen).
Implementación
1. Estructura —
.scroll > .scroll__track > .scroll__row > items. Flechas .arrow--left/.arrow--right absolutas dentro de .scroll.
2. Fila —
.scroll__row { display:grid; grid-auto-flow:column; grid-auto-columns:160px; gap:12px; }
3. Estética de flechas —
para dejar hueco a las flechas,
.scroll--gutter .scroll__track { margin: 0 44px; }.
4. JS —
llamá
initScroll('tu-grupo') con data-group="tu-grupo" en el .scroll y en el .shelf__count.
Código para replicar
Snippets autocontenidos: copiá cada bloque y pegá en tu proyecto. La clave para que el track scrollee es min-width:0 en los contenedores flex/grid que lo envuelven.
HTML
<div class="shelf">
<div class="shelf__header">
<h2>The Shadow</h2>
<span class="shelf__count" data-group="comics" aria-live="polite"></span>
</div>
<div class="scroll scroll--gutter" data-group="comics">
<button class="arrow arrow--left" aria-label="Anteriores">◀</button>
<div class="scroll__track" role="list" tabindex="0">
<div class="scroll__row">
<article class="comic" role="listitem">
<div class="comic__cover">
<img src="001.jpg" alt="">
<span class="comic__title">The Shadow</span>
</div>
</article>
<!-- mas items… -->
</div>
</div>
<button class="arrow arrow--right" aria-label="Siguientes">▶</button>
</div>
</div>
CSS
:root{
--surface:hsl(215,11%,3%);
--border:hsl(214,35%,22%);
--text:hsl(215,25%,85%);
--accent:hsl(22,100%,62%);
}
.shelf{
display:flex;
flex-direction:column;
gap:0.75rem;
min-width:0;
}
.scroll{
position:relative;
}
.scroll__track{
overflow-x:auto;
scroll-snap-type:x mandatory;
padding:4px 0 8px;
}
.scroll__row{
display:grid;
grid-auto-flow:column;
grid-auto-columns:160px;
gap:12px;
}
.comic{
scroll-snap-align:start;
}
.arrow{
position:absolute;
top:50%;
transform:translateY(-50%);
z-index:10;
}
.arrow--left{ left:0; }
.arrow--right{ right:0; }
.scroll--gutter .scroll__track{
margin:0 44px;
}
JS
const wrap = document.querySelector('.scroll[data-group="comics"]');
const track = wrap.querySelector('.scroll__track');
const prev = wrap.querySelector('.arrow--left');
const next = wrap.querySelector('.arrow--right');
const count = document.querySelector('.shelf__count[data-group="comics"]');
const items = wrap.querySelectorAll('.scroll__row > *');
const row = wrap.querySelector('.scroll__row');
const step = items[0].getBoundingClientRect().width +
parseFloat(getComputedStyle(row).columnGap || 12);
function update() {
const max = track.scrollWidth - track.clientWidth;
prev.disabled = track.scrollLeft <= 1;
next.disabled = track.scrollLeft >= max - 1;
const visible = Math.max(1, Math.floor(track.clientWidth / step));
const first = Math.min(items.length, Math.round(track.scrollLeft / step) + 1);
const last = Math.min(items.length, first + visible - 1);
count.textContent = first + '–' + last + ' de ' + items.length;
}
prev.addEventListener('click', () => track.scrollBy({ left: -step * 3, behavior: 'smooth' }));
next.addEventListener('click', () => track.scrollBy({ left: step * 3, behavior: 'smooth' }));
track.addEventListener('scroll', update, { passive: true });
update();