E-commerceecommerceshopgalleryimagesproduct-page
Product Gallery
Visor de imágenes de producto con escenario grande y miniaturas navegables con teclado; genera vistas ilustradas si no hay fotos.
Vista Previa en Vivo
View 1 of 4
View 2 of 4
View 3 of 4
View 4 of 4
Uso
Copia y pega este código directamente en tu componente React:
tsx
import { ProductGallery } from 'react-component-kit-ui/ecommerce';
<ProductGallery images={['/img/p1.webp', '/img/p2.webp']} alt="Aurora headphones" />Instalación Manual
Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: src/components/ProductGallery.tsx + ProductGallery.css:
ProductGallery.tsx
'use client';
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import ProductMedia from './ProductMedia';
import './ProductGallery.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(' ');
}
/** "a: b; --c: d" → style object */
function st(css: string | undefined): CSSProperties {
const o: Record<string, string> = {};
for (const decl of String(css ?? '').split(';')) {
const i = decl.indexOf(':');
if (i < 0) continue;
const k = decl.slice(0, i).trim();
if (k) o[k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = decl.slice(i + 1).trim();
}
return o as CSSProperties;
}
/**
* ProductGallery — Product image viewer with a large stage and thumbnail rail.
* Works with real photos (`images`) or generates several illustrated "angles".
*
* @param {string[]} images - Photo URLs. When empty, illustrated views are generated.
* @param {string} alt - Alt text base for photos. Default 'Product photo'.
* @param {string} icon - Illustration for generated views. Default 'headphones'.
* @param {number} hue - Backdrop hue for generated views. Default 38.
* @param {number} views - Number of generated views (2–5). Default 4.
* @param {string} layout - Thumbnail position (bottom|side). Default 'side'.
* @param {string} size - Stage ratio preset (sm|md|lg). Default 'md'.
* @param {string} className - Extra class.
*
* Accessibility: thumbnails are buttons with aria-pressed and labels; arrow keys move
* between thumbnails; the stage announces the current view number.
*/
export interface ProductGalleryProps {
images?: string[];
alt?: string;
icon?: string;
hue?: number;
views?: number;
layout?: 'bottom' | 'side';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function ProductGallery({
images = [],
alt = 'Product photo',
icon = 'headphones',
hue = 38,
views = 4,
layout = 'side',
size = 'md',
className = '',
}: ProductGalleryProps) {
const generated = Array.from({ length: Math.min(5, Math.max(2, views)) }, (_, i) => ({
hue: (hue + i * 18) % 360,
variant: (i % 2 === 0 ? 'glow' : 'soft') as 'glow' | 'soft',
turn: [0, -14, 12, -4, 20][i] ?? 0,
zoom: [1, 1.18, 0.92, 1.3, 1.05][i] ?? 1,
}));
const slides = images.length
? images.map((src, i) => ({ src, label: `${alt} ${i + 1}` }))
: generated.map((g, i) => ({ ...g, label: `View ${i + 1}` }));
const ratio = size === 'sm' ? '1 / 1' : size === 'lg' ? '4 / 5' : '5 / 5.4';
// 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-gallery]:not([data-bound])').forEach((g) => {
g.dataset.bound = '';
const slides = Array.from(g.querySelectorAll<HTMLElement>('[data-slide]'));
const thumbs = Array.from(g.querySelectorAll<HTMLButtonElement>('[data-thumb]'));
const show = (i: number, focus = false) => {
slides.forEach((s, n) => { s.hidden = n !== i; s.classList.toggle('is-active', n === i); });
thumbs.forEach((t, n) => t.setAttribute('aria-pressed', String(n === i)));
if (focus) thumbs[i]?.focus();
};
thumbs.forEach((t, i) => {
t.addEventListener('click', () => show(i));
t.addEventListener('keydown', (e) => {
const keys: Record<string, number> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
if (!(e.key in keys)) return;
e.preventDefault();
show((i + keys[e.key] + thumbs.length) % thumbs.length, true);
});
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div className="p-gallery-cq" data-rk-60f127="">
<div className={cx(['p-gallery', `p-gallery--${layout}`, `p-gallery--${size}`, className])} data-gallery data-rk-60f127="">
<div className="p-gallery__stage" aria-live="polite" data-rk-60f127="">
{slides.map((s: any, i) => (
<div key={i} className={cx(['p-gallery__slide', { 'is-active': i === 0 }])} data-slide hidden={i !== 0} style={st(s.turn !== undefined ? `--turn: ${s.turn}deg; --zoom: ${s.zoom}` : '')} data-rk-60f127="">
{s.src
? <ProductMedia image={s.src} alt={s.label} ratio={ratio} />
: <ProductMedia icon={icon} hue={s.hue} variant={s.variant} ratio={ratio} size="lg" />}
<span className="sr-only" data-rk-60f127="">{s.label} of {slides.length}</span>
</div>
))}
</div>
<div className="p-gallery__thumbs" role="group" aria-label="Product views" data-rk-60f127="">
{slides.map((s: any, i) => (
<button key={i} className={cx(['p-gallery__thumb'])} type="button" aria-pressed={i === 0 ? 'true' : 'false'} aria-label={`Show ${s.label}`} data-thumb={i} style={st(s.turn !== undefined ? `--turn: ${s.turn}deg; --zoom: ${s.zoom}` : '')} data-rk-60f127="">
{s.src
? <ProductMedia image={s.src} alt="" ratio="1 / 1" />
: <ProductMedia icon={icon} hue={s.hue} variant={s.variant} ratio="1 / 1" size="sm" />}
</button>
))}
</div>
</div>
</div>
</>
);
}
export default ProductGallery;ProductGallery.css
/* ProductGallery — ported from astro-component-kit (ecommerce/ProductGallery.astro) */
.p-gallery-cq:where([data-rk-60f127]) { container-type: inline-size; }
.p-gallery:where([data-rk-60f127]) { display: grid; gap: var(--sp-3, 0.75rem); }
.p-gallery--side:where([data-rk-60f127]) { grid-template-columns: 84px minmax(0, 1fr); }
.p-gallery--side:where([data-rk-60f127]) .p-gallery__stage:where([data-rk-60f127]) { order: 2; }
.p-gallery--side:where([data-rk-60f127]) .p-gallery__thumbs:where([data-rk-60f127]) { order: 1; flex-direction: column; }
.p-gallery__stage:where([data-rk-60f127]) {
position: relative;
border-radius: var(--r-xl, 28px);
border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05));
overflow: hidden;
}
.p-gallery__slide[hidden]:where([data-rk-60f127]) { display: none; }
.p-gallery__slide.is-active:where([data-rk-60f127]) { animation: product-gallery-pg-in 0.35s ease-out; }
.p-gallery__slide:where([data-rk-60f127]) .p-media__art,
.p-gallery__thumb:where([data-rk-60f127]) .p-media__art { transform: rotate(var(--turn, 0deg)) scale(var(--zoom, 1)); }
.p-gallery__thumbs:where([data-rk-60f127]) { display: flex; gap: var(--sp-2, 0.5rem); }
.p-gallery__thumb:where([data-rk-60f127]) {
flex: 0 0 84px;
width: 84px;
padding: 0;
border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05));
border-radius: var(--r-md, 12px);
overflow: hidden;
background: none;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s ease, border-color 0.2s ease;
}
.p-gallery__thumb:hover:where([data-rk-60f127]) { opacity: 1; }
.p-gallery__thumb[aria-pressed='true']:where([data-rk-60f127]) { opacity: 1; border-color: var(--c-primary-light, #d4a017); }
.p-gallery__thumb:focus-visible:where([data-rk-60f127]) { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 2px; }
@keyframes product-gallery-pg-in { from { opacity: 0.4; transform: scale(0.985); } }
.sr-only:where([data-rk-60f127]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@container (max-width: 700px) {
.p-gallery--side:where([data-rk-60f127]) { grid-template-columns: 1fr; }
.p-gallery--side:where([data-rk-60f127]) .p-gallery__stage:where([data-rk-60f127]) { order: 1; }
.p-gallery--side:where([data-rk-60f127]) .p-gallery__thumbs:where([data-rk-60f127]) { order: 2; flex-direction: row; }
.p-gallery__thumb:where([data-rk-60f127]) { flex-basis: 64px; width: 64px; }
}
@media (prefers-reduced-motion: reduce) { .p-gallery__slide.is-active:where([data-rk-60f127]) { animation: none; } }Información Rápida
- Categoría
- E-commerce
- Nombre de archivo
ProductGallery.tsx- Dependencias
- Ninguna — React + CSS puro
- Etiquetas
- ecommerceshopgalleryimagesproduct-page