Grid Workshop

Scroll horizontal de productos

Cards de producto en fila scrolleable con scroll-snap, flechas, dots de posición y navegación por teclado. Los indicadores muestran el grupo visible.

Destacados

Portada de The Shadow #1
Noir

The Shadow #1

Primer número de la serie noir. Nueva edición restaurada con arte original.

Portada de The Shadow #2
Noir

The Shadow #2

Edición de colección con portada alternativa y extras de making of.

Portada de The Shadow #3
Acción

The Shadow #3

Tapa dura con sobrecubierta impresa y guardas con arte inédito.

Portada de The Shadow #4
Suspenso

The Shadow #4

Pack con el libro y una lámina firmada numerada de edición limitada.

Portada de The Shadow #5
Noir

The Shadow #5

Número con argumento y guion de la edición original de los años 40.

Portada de The Shadow #6
Acción

The Shadow #6

Caja recopilatoria de la saga completa con índice ilustrado.

Portada de The Shadow #7
Suspenso

The Shadow #7

Edición bilingüe con ensayo crítico y cronología de la serie.

Portada de The Shadow #8
Noir

The Shadow #8

Último número de la serie, con epílogo y galería de portadas.

Cómo funciona y cómo implementarlo

Cómo funciona

Scroll con snap — igual que en streaming: .scroll__track con overflow-x:auto y scroll-snap-type:x mandatory; cada card scroll-snap-align:start.
Dots — initDots() genera un button.dot por grupo visible (items que entran en el track). Cada dot salta con track.scrollTo({ left: i*step*visible }).
Sincronía — el evento scroll actualiza el dot activo (scrollLeft / (step*visible)), tanto al usar flechas como teclado como clics.
Accesibilidad — cada dot lleva aria-label="Ir al grupo N de M" y el grupo .dots tiene role="group".

Implementación

1. Estructura — agregá un contenedor <div class="dots" data-group="tu-grupo" role="group" aria-label="Posición"></div> después del .scroll.
2. Fila — .products .scroll__row { display:grid; grid-auto-flow:column; grid-auto-columns:280px; gap:12px; }
3. JS — initScroll('products') detecta el .dots[data-group] adyacente y arma los dots automáticamente.
4. No olvidar min-width:0 — en los contenedores flex/grid que envuelven al track; si no, el scroll interno no se activa y los dots no aparecen.

Código para replicar

Snippets autocontenidos: copiá cada bloque y pegá en tu proyecto. Los dots se generan por JS según cuántos items entran en el track.

HTML
<div class="products">
  <h2>Destacados</h2>
  <div class="scroll scroll--gutter" data-group="products">
    <button class="arrow arrow--left" aria-label="Anteriores">◀</button>
    <div class="scroll__track" role="list" tabindex="0">
      <div class="scroll__row">
        <article class="product-card" role="listitem">
          <div class="product-card__image"><img src="001.jpg" alt="Portada"></div>
          <div class="product-card__body">
            <h3>The Shadow #1</h3>
            <span class="product-card__price">$12.990</span>
            <button class="product-card__btn">Agregar al carrito</button>
          </div>
        </article>
        <!-- mas items… -->
      </div>
    </div>
    <button class="arrow arrow--right" aria-label="Siguientes">▶</button>
  </div>
  <div class="dots" data-group="products" role="group" aria-label="Posición"></div>
</div>
CSS
:root{
  --surface:hsl(215,11%,3%);
  --border:hsl(214,35%,22%);
  --text:hsl(215,25%,85%);
  --accent:hsl(22,100%,62%);
}

.products{
  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:280px;
  gap:12px;
}

.product-card{
  scroll-snap-align:start;
}

.dots{
  display:flex;
  justify-content:center;
  gap:6px;
  margin-top:12px;
}

.dot{
  width:6px;
  height:6px;
  border-radius:50%;
  background:var(--border);
  border:none;
  cursor:pointer;
}

.dot.is-active{
  background:var(--accent);
  width:18px;
  border-radius:3px;
}
JS
const wrap = document.querySelector('.scroll[data-group="products"]');
const track = wrap.querySelector('.scroll__track');
const items = wrap.querySelectorAll('.scroll__row > *');
const row = wrap.querySelector('.scroll__row');
const step = items[0].getBoundingClientRect().width +
  parseFloat(getComputedStyle(row).columnGap || 12);

const visible = Math.max(1, Math.floor(track.clientWidth / step));
const steps = Math.ceil(items.length / visible);
const dotsWrap = document.querySelector('.dots[data-group="products"]');

for (let i = 0; i < steps; i++) {
  const dot = document.createElement('button');
  dot.className = 'dot' + (i === 0 ? ' is-active' : '');
  dot.setAttribute('aria-label', 'Ir al grupo ' + (i + 1) + ' de ' + steps);
  dot.addEventListener('click', () =>
    track.scrollTo({ left: i * step * visible, behavior: 'smooth' }));
  dotsWrap.appendChild(dot);
}

track.addEventListener('scroll', () => {
  const idx = Math.round(track.scrollLeft / (step * visible));
  dotsWrap.querySelectorAll('.dot').forEach((d, i) =>
    d.classList.toggle('is-active', i === idx));
});