1 GitHub star = 1 new component.

Star the repo
E-commerce

Shop Product Rail

Scroll-snapping product carousel of minimal product cards with numbering, prev/next buttons and a progress line.

carouselproductsecommerceslider
Live Preview

Just in

New this week

View all

Usage

Copy and paste this code into your React component:

tsx
import { ShopProductRail } from 'react-component-kit-ui/sections';

<ShopProductRail title="New this *week*" products={products} productBase="/product/" />

Manual Installation

If you are not using the npm package, create a new file and paste the following code: src/components/sections/ShopProductRail.tsx + ShopProductRail.css:

ShopProductRail.tsx
'use client';

import { useEffect, useRef } from 'react';
import ProductCardMinimal from '../ecommerce/ProductCardMinimal';
import type { Product } from '../ecommerce/types';
import './ShopProductRail.css';

/** class:list โ†’ className */
function cx(...args: unknown[]): string {
  const out: string[] = [];
  const walk = (v: unknown) => {
    if (!v) return;
    if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
    else if (Array.isArray(v)) v.forEach(walk);
    else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
  };
  args.forEach(walk);
  return out.join(' ');
}

/**
 * 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.
 */
export interface ShopProductRailProps {
  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;
}

export function ShopProductRail({
  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 = '',
}: ShopProductRailProps) {
  const parts = title.split(/\*(.+?)\*/g);
  const plainTitle = title.replace(/\*/g, '');
  const hrefFor = (slug: string) => (productBase === '#' ? '#' : `${productBase}${slug}/`);

  // Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
  const mounted = useRef(false);
  useEffect(() => {
    if (mounted.current) return;
    mounted.current = true;
    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();
      });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

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

export default ShopProductRail;
ShopProductRail.css
/* ShopProductRail โ€” ported from astro-component-kit (sections/ShopProductRail.astro) */

.spr:where([data-rk-14294a]) { container-type: inline-size; --slide-w: clamp(220px, 24vw, 320px); color: var(--c-text-1, #fff); min-width: 0; }
.spr--sm:where([data-rk-14294a]) { --slide-w: clamp(180px, 18vw, 240px); }
.spr--lg:where([data-rk-14294a]) { --slide-w: clamp(260px, 30vw, 420px); }
.spr__head:where([data-rk-14294a]) { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--sp-4, 1rem); margin-bottom: var(--sp-6, 1.5rem); }
.spr__eyebrow:where([data-rk-14294a]) { 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:where([data-rk-14294a]) { 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:where([data-rk-14294a]) em:where([data-rk-14294a]) { font-style: italic; color: var(--shop-accent, var(--c-primary-light, #d4a017)); }
.spr__controls:where([data-rk-14294a]) { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); }
.spr__all:where([data-rk-14294a]) { 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:where([data-rk-14294a]) { 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):where([data-rk-14294a]) { background: var(--c-text-1, #fff); color: var(--c-bg, #050505); }
.spr__arrow:disabled:where([data-rk-14294a]) { opacity: 0.3; cursor: default; }
.spr:where([data-rk-14294a]) :is(a, button):focus-visible:where([data-rk-14294a]), .spr__track:focus-visible:where([data-rk-14294a]) { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 3px; }

.spr__track:where([data-rk-14294a]) {
  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:where([data-rk-14294a])::-webkit-scrollbar { display: none; }
.spr__slide:where([data-rk-14294a]) { scroll-snap-align: start; min-width: 0; }
.spr__progress:where([data-rk-14294a]) { 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:where([data-rk-14294a]) span:where([data-rk-14294a]) { 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:where([data-rk-14294a]) { grid-auto-columns: 72%; }
  .spr__arrow:where([data-rk-14294a]), .spr__all:where([data-rk-14294a]) { display: none; }
}
@media (prefers-reduced-motion: reduce) { .spr__track:where([data-rk-14294a]) { scroll-snap-type: none; } }