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

The Shadow

W. Gibson · A. Reyes

Noir
#2 The Shadow

The Shadow

W. Gibson · A. Reyes

Noir
#3 The Shadow

The Shadow

W. Gibson · A. Reyes

Acción
#4 The Shadow

The Shadow

W. Gibson · A. Reyes

Suspenso
#5 The Shadow

The Shadow

W. Gibson · A. Reyes

Noir
#6 The Shadow

The Shadow

W. Gibson · A. Reyes

Acción
#7 The Shadow

The Shadow

W. Gibson · A. Reyes

Suspenso
#8 The Shadow

The Shadow

W. Gibson · A. Reyes

Noir

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();