Секции страниц carouselproductsecommerceslider
Shop Product Rail
Карусель товаров со scroll-snap, нумерованными минималистичными карточками, стрелками и прогрессом.
Живое превью
Just in
New this week
- New
- N° 02Quick add
- N° 03
- N° 04
Ash Vase
€68.00
- N° 05Quick add
Dune Loafer
€210.00
Использование
Скопируйте и вставьте этот код на вашу страницу 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/" /> Ручная установка
Если вы не используете пакет npm, создайте файл и вставьте следующий код: 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>
Краткая информация
- Категория
- Секции страниц
- Имя файла
ShopProductRail.astro- Зависимости
- Нет — чистый Astro + CSS
- Теги
- carouselproductsecommerceslider