1 estrella en GitHub = 1 componente nuevo.

Dar una estrella
Secciones de Página carouselproductsecommerceslider

Shop Product Rail

Carrusel de productos con scroll-snap, tarjetas minimalistas numeradas, flechas y barra de progreso.

Vista Previa en Vivo

Just in

New this week

View all

Uso

Copia y pega este código directamente en tu página de Astro:

astro
---
import { ShopProductRail } from 'astro-component-kit/sections';
---

<ShopProductRail title="New this *week*" products={products} productBase="/product/" />
--- import { ShopProductRail } from 'astro-component-kit/sections'; --- <ShopProductRail title="New this *week*" products={products} productBase="/product/" />

Instalación Manual

Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: src/components/lib/ShopProductRail.astro:

astro
---
/**
 * ShopProductRail — Horizontal, scroll-snapping product carousel of ProductCardMinimal cards with
 * numbered items, prev/next buttons and a progress line.
 *
 * @param {string} eyebrow - Small label. Default 'Just in'.
 * @param {string} title - Heading. Default 'New this *week*'.
 * @param {Product[]} products - Products to show.
 * @param {string} productBase - Product URL prefix (`${productBase}${slug}/`). Default '#'.
 * @param {{label: string, href: string}} link - "View all" link (optional).
 * @param {boolean} numbered - Show "N° 01" on cards. Default true.
 * @param {string} currency - ISO currency. Default 'EUR'.
 * @param {string} locale - Number format locale. Default 'en-GB'.
 * @param {string} cartKey - Cart storage key. Default 'ack-cart'.
 * @param {string} id - Rail id (unique per page). Default 'product-rail'.
 * @param {string} size - Card width (sm|md|lg). Default 'md'.
 * @param {string} className - Extra class.
 *
 * Accessibility: the rail is a labelled region (role="region") scrollable with keyboard;
 * buttons have labels and disable at the ends; snapping is disabled with reduced motion.
 */
import ProductCardMinimal from '../ecommerce/ProductCardMinimal.astro';
import type { Product } from '../ecommerce/types';

interface Props {
  eyebrow?: string;
  title?: string;
  products?: Product[];
  productBase?: string;
  link?: { label: string; href: string };
  numbered?: boolean;
  currency?: string;
  locale?: string;
  cartKey?: string;
  id?: string;
  size?: 'sm' | 'md' | 'lg';
  className?: string;
}

const {
  eyebrow = 'Just in',
  title = 'New this *week*',
  products = [
    { slug: 'linen-overshirt', name: 'Linen Overshirt', price: 145, category: 'clothing', badge: 'New', sizes: ['S', 'M', 'L'], colors: [{ name: 'Oat', hex: '#d9cbb3' }, { name: 'Ink', hex: '#22252b' }], icon: 'shirt', hue: 30 },
    { slug: 'merino-crew', name: 'Merino Crew Knit', price: 160, category: 'clothing', sizes: ['S', 'M', 'L'], colors: [{ name: 'Sage', hex: '#8a9a7b' }], icon: 'jacket', hue: 95 },
    { slug: 'field-tote', name: 'Field Tote', price: 120, category: 'bags', colors: [{ name: 'Rust', hex: '#9c3d1c' }], icon: 'bag', hue: 18 },
    { slug: 'ash-vase', name: 'Ash Vase', price: 68, category: 'objects', icon: 'bottle', hue: 40 },
    { slug: 'dune-loafer', name: 'Dune Loafer', price: 210, category: 'shoes', sizes: ['39', '40', '41', '42'], icon: 'sneaker', hue: 28 },
  ],
  productBase = '#',
  link = { label: 'View all', href: '#' },
  numbered = true,
  currency = 'EUR',
  locale = 'en-GB',
  cartKey = 'ack-cart',
  id = 'product-rail',
  size = 'md',
  className = '',
} = Astro.props;

const parts = title.split(/\*(.+?)\*/g);
const plainTitle = title.replace(/\*/g, '');
const hrefFor = (slug: string) => (productBase === '#' ? '#' : `${productBase}${slug}/`);
---

<section class:list={['spr', `spr--${size}`, className]} data-rail>
  <header class="spr__head">
    <div>
      {eyebrow && <p class="spr__eyebrow">{eyebrow}</p>}
      <h2 class="spr__title">{parts.map((p, i) => (i % 2 ? <em>{p}</em> : p))}</h2>
    </div>
    <div class="spr__controls">
      {link && <a class="spr__all" href={link.href}>{link.label}</a>}
      <button type="button" class="spr__arrow" aria-label="Previous products" aria-controls={id} data-prev>←</button>
      <button type="button" class="spr__arrow" aria-label="Next products" aria-controls={id} data-next>→</button>
    </div>
  </header>
  <ul class="spr__track" id={id} role="region" aria-label={plainTitle} tabindex="0" data-track>
    {products.map((p, i) => (
      <li class="spr__slide">
        <ProductCardMinimal product={p} href={hrefFor(p.slug)} index={numbered ? i + 1 : undefined} currency={currency} locale={locale} cartKey={cartKey} />
      </li>
    ))}
  </ul>
  <div class="spr__progress" aria-hidden="true"><span data-progress></span></div>
</section>

<script>
  document.querySelectorAll<HTMLElement>('[data-rail]:not([data-bound])').forEach((rail) => {
    rail.dataset.bound = '';
    const track = rail.querySelector<HTMLElement>('[data-track]')!;
    const prev = rail.querySelector<HTMLButtonElement>('[data-prev]')!;
    const next = rail.querySelector<HTMLButtonElement>('[data-next]')!;
    const bar = rail.querySelector<HTMLElement>('[data-progress]')!;
    const step = () => (track.querySelector<HTMLElement>('.spr__slide')?.offsetWidth ?? 300) + 16;
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    const update = () => {
      const max = track.scrollWidth - track.clientWidth;
      prev.disabled = track.scrollLeft <= 2;
      next.disabled = track.scrollLeft >= max - 2;
      const visible = track.clientWidth / track.scrollWidth;
      bar.style.width = `${Math.max(10, visible * 100)}%`;
      bar.style.transform = `translateX(${max > 0 ? (track.scrollLeft / max) * ((1 / Math.max(visible, 0.1)) - 1) * 100 : 0}%)`;
    };
    prev.addEventListener('click', () => track.scrollBy({ left: -step(), behavior: reduced ? 'auto' : 'smooth' }));
    next.addEventListener('click', () => track.scrollBy({ left: step(), behavior: reduced ? 'auto' : 'smooth' }));
    track.addEventListener('scroll', update, { passive: true });
    window.addEventListener('resize', update);
    update();
  });
</script>

<style>
  .spr { container-type: inline-size; --slide-w: clamp(220px, 24vw, 320px); color: var(--c-text-1, #fff); min-width: 0; }
  .spr--sm { --slide-w: clamp(180px, 18vw, 240px); }
  .spr--lg { --slide-w: clamp(260px, 30vw, 420px); }
  .spr__head { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--sp-4, 1rem); margin-bottom: var(--sp-6, 1.5rem); }
  .spr__eyebrow { margin: 0 0 var(--sp-2, 0.5rem); font-size: 0.72rem; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: var(--c-text-2, #a3a3a3); }
  .spr__title { margin: 0; font-family: var(--font-display, Georgia, 'Times New Roman', serif); font-size: clamp(2rem, 4.5vw, 3.4rem); font-weight: 400; letter-spacing: -0.035em; line-height: 1; }
  .spr__title em { font-style: italic; color: var(--shop-accent, var(--c-primary-light, #d4a017)); }
  .spr__controls { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); }
  .spr__all { margin-right: var(--sp-3, 0.75rem); min-height: 44px; display: inline-flex; align-items: center; font-size: 0.8rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: inherit; text-decoration: underline; text-underline-offset: 5px; }
  .spr__arrow { width: 48px; height: 48px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.15)); border-radius: 50%; background: transparent; color: inherit; font-size: 1.1rem; cursor: pointer; transition: background 0.2s ease, color 0.2s ease, opacity 0.2s ease; }
  .spr__arrow:hover:not(:disabled) { background: var(--c-text-1, #fff); color: var(--c-bg, #050505); }
  .spr__arrow:disabled { opacity: 0.3; cursor: default; }
  .spr :is(a, button):focus-visible, .spr__track:focus-visible { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 3px; }

  .spr__track {
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: var(--slide-w);
    gap: 16px;
    margin: 0;
    padding: 0 0 var(--sp-2, 0.5rem);
    list-style: none;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    scrollbar-width: none;
  }
  .spr__track::-webkit-scrollbar { display: none; }
  .spr__slide { scroll-snap-align: start; min-width: 0; }
  .spr__progress { position: relative; height: 1px; margin-top: var(--sp-5, 1.25rem); background: var(--c-border, rgba(255, 255, 255, 0.15)); overflow: hidden; }
  .spr__progress span { position: absolute; inset: 0 auto 0 0; height: 100%; background: var(--c-text-1, #fff); transition: transform 0.15s linear; }

  @container (max-width: 640px) {
    .spr__track { grid-auto-columns: 72%; }
    .spr__arrow, .spr__all { display: none; }
  }
  @media (prefers-reduced-motion: reduce) { .spr__track { scroll-snap-type: none; } }
</style>
--- /** * ShopProductRail — Horizontal, scroll-snapping product carousel of ProductCardMinimal cards with * numbered items, prev/next buttons and a progress line. * * @param {string} eyebrow - Small label. Default 'Just in'. * @param {string} title - Heading. Default 'New this *week*'. * @param {Product[]} products - Products to show. * @param {string} productBase - Product URL prefix (`${productBase}${slug}/`). Default '#'. * @param {{label: string, href: string}} link - "View all" link (optional). * @param {boolean} numbered - Show "N° 01" on cards. Default true. * @param {string} currency - ISO currency. Default 'EUR'. * @param {string} locale - Number format locale. Default 'en-GB'. * @param {string} cartKey - Cart storage key. Default 'ack-cart'. * @param {string} id - Rail id (unique per page). Default 'product-rail'. * @param {string} size - Card width (sm|md|lg). Default 'md'. * @param {string} className - Extra class. * * Accessibility: the rail is a labelled region (role="region") scrollable with keyboard; * buttons have labels and disable at the ends; snapping is disabled with reduced motion. */ import ProductCardMinimal from '../ecommerce/ProductCardMinimal.astro'; import type { Product } from '../ecommerce/types'; interface Props { eyebrow?: string; title?: string; products?: Product[]; productBase?: string; link?: { label: string; href: string }; numbered?: boolean; currency?: string; locale?: string; cartKey?: string; id?: string; size?: 'sm' | 'md' | 'lg'; className?: string; } const { eyebrow = 'Just in', title = 'New this *week*', products = [ { slug: 'linen-overshirt', name: 'Linen Overshirt', price: 145, category: 'clothing', badge: 'New', sizes: ['S', 'M', 'L'], colors: [{ name: 'Oat', hex: '#d9cbb3' }, { name: 'Ink', hex: '#22252b' }], icon: 'shirt', hue: 30 }, { slug: 'merino-crew', name: 'Merino Crew Knit', price: 160, category: 'clothing', sizes: ['S', 'M', 'L'], colors: [{ name: 'Sage', hex: '#8a9a7b' }], icon: 'jacket', hue: 95 }, { slug: 'field-tote', name: 'Field Tote', price: 120, category: 'bags', colors: [{ name: 'Rust', hex: '#9c3d1c' }], icon: 'bag', hue: 18 }, { slug: 'ash-vase', name: 'Ash Vase', price: 68, category: 'objects', icon: 'bottle', hue: 40 }, { slug: 'dune-loafer', name: 'Dune Loafer', price: 210, category: 'shoes', sizes: ['39', '40', '41', '42'], icon: 'sneaker', hue: 28 }, ], productBase = '#', link = { label: 'View all', href: '#' }, numbered = true, currency = 'EUR', locale = 'en-GB', cartKey = 'ack-cart', id = 'product-rail', size = 'md', className = '', } = Astro.props; const parts = title.split(/\*(.+?)\*/g); const plainTitle = title.replace(/\*/g, ''); const hrefFor = (slug: string) => (productBase === '#' ? '#' : `${productBase}${slug}/`); --- <section class:list={['spr', `spr--${size}`, className]} data-rail> <header class="spr__head"> <div> {eyebrow && <p class="spr__eyebrow">{eyebrow}</p>} <h2 class="spr__title">{parts.map((p, i) => (i % 2 ? <em>{p}</em> : p))}</h2> </div> <div class="spr__controls"> {link && <a class="spr__all" href={link.href}>{link.label}</a>} <button type="button" class="spr__arrow" aria-label="Previous products" aria-controls={id} data-prev>←</button> <button type="button" class="spr__arrow" aria-label="Next products" aria-controls={id} data-next>→</button> </div> </header> <ul class="spr__track" id={id} role="region" aria-label={plainTitle} tabindex="0" data-track> {products.map((p, i) => ( <li class="spr__slide"> <ProductCardMinimal product={p} href={hrefFor(p.slug)} index={numbered ? i + 1 : undefined} currency={currency} locale={locale} cartKey={cartKey} /> </li> ))} </ul> <div class="spr__progress" aria-hidden="true"><span data-progress></span></div> </section> <script> document.querySelectorAll<HTMLElement>('[data-rail]:not([data-bound])').forEach((rail) => { rail.dataset.bound = ''; const track = rail.querySelector<HTMLElement>('[data-track]')!; const prev = rail.querySelector<HTMLButtonElement>('[data-prev]')!; const next = rail.querySelector<HTMLButtonElement>('[data-next]')!; const bar = rail.querySelector<HTMLElement>('[data-progress]')!; const step = () => (track.querySelector<HTMLElement>('.spr__slide')?.offsetWidth ?? 300) + 16; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const update = () => { const max = track.scrollWidth - track.clientWidth; prev.disabled = track.scrollLeft <= 2; next.disabled = track.scrollLeft >= max - 2; const visible = track.clientWidth / track.scrollWidth; bar.style.width = `${Math.max(10, visible * 100)}%`; bar.style.transform = `translateX(${max > 0 ? (track.scrollLeft / max) * ((1 / Math.max(visible, 0.1)) - 1) * 100 : 0}%)`; }; prev.addEventListener('click', () => track.scrollBy({ left: -step(), behavior: reduced ? 'auto' : 'smooth' })); next.addEventListener('click', () => track.scrollBy({ left: step(), behavior: reduced ? 'auto' : 'smooth' })); track.addEventListener('scroll', update, { passive: true }); window.addEventListener('resize', update); update(); }); </script> <style> .spr { container-type: inline-size; --slide-w: clamp(220px, 24vw, 320px); color: var(--c-text-1, #fff); min-width: 0; } .spr--sm { --slide-w: clamp(180px, 18vw, 240px); } .spr--lg { --slide-w: clamp(260px, 30vw, 420px); } .spr__head { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--sp-4, 1rem); margin-bottom: var(--sp-6, 1.5rem); } .spr__eyebrow { margin: 0 0 var(--sp-2, 0.5rem); font-size: 0.72rem; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: var(--c-text-2, #a3a3a3); } .spr__title { margin: 0; font-family: var(--font-display, Georgia, 'Times New Roman', serif); font-size: clamp(2rem, 4.5vw, 3.4rem); font-weight: 400; letter-spacing: -0.035em; line-height: 1; } .spr__title em { font-style: italic; color: var(--shop-accent, var(--c-primary-light, #d4a017)); } .spr__controls { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); } .spr__all { margin-right: var(--sp-3, 0.75rem); min-height: 44px; display: inline-flex; align-items: center; font-size: 0.8rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: inherit; text-decoration: underline; text-underline-offset: 5px; } .spr__arrow { width: 48px; height: 48px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.15)); border-radius: 50%; background: transparent; color: inherit; font-size: 1.1rem; cursor: pointer; transition: background 0.2s ease, color 0.2s ease, opacity 0.2s ease; } .spr__arrow:hover:not(:disabled) { background: var(--c-text-1, #fff); color: var(--c-bg, #050505); } .spr__arrow:disabled { opacity: 0.3; cursor: default; } .spr :is(a, button):focus-visible, .spr__track:focus-visible { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 3px; } .spr__track { display: grid; grid-auto-flow: column; grid-auto-columns: var(--slide-w); gap: 16px; margin: 0; padding: 0 0 var(--sp-2, 0.5rem); list-style: none; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; } .spr__track::-webkit-scrollbar { display: none; } .spr__slide { scroll-snap-align: start; min-width: 0; } .spr__progress { position: relative; height: 1px; margin-top: var(--sp-5, 1.25rem); background: var(--c-border, rgba(255, 255, 255, 0.15)); overflow: hidden; } .spr__progress span { position: absolute; inset: 0 auto 0 0; height: 100%; background: var(--c-text-1, #fff); transition: transform 0.15s linear; } @container (max-width: 640px) { .spr__track { grid-auto-columns: 72%; } .spr__arrow, .spr__all { display: none; } } @media (prefers-reduced-motion: reduce) { .spr__track { scroll-snap-type: none; } } </style>

Información Rápida

Categoría
Secciones de Página
Nombre de archivo
ShopProductRail.astro
Dependencias
Ninguna — Astro nativo + CSS puro
Etiquetas
carouselproductsecommerceslider