React product image gallery
A React product image gallery: one large view and thumbnails that choose it, built as tabs so the arrow keys move between pictures.
Install
One command copies the files into your project, as code you then own. It needs a
components.json, and the shadcn registry page has the one-time setup.Terminal
npx shadcn@latest add https://www.box-kite.dev/r/product-gallery.jsonLight and dark
The same component twice, each half in a theme of its own — nothing in it is written for either. Open it full screen to see it in yours.
light
dark
Source
The files the command writes, exactly as the preview above runs them. Every style is a prop, so there is no stylesheet to copy with them.
box-kite/store/product-gallery.tsx
JSX
'use client';
import Tabs from '@box-kite/react/components/tabs';
import VisuallyHidden from '@box-kite/react/components/visuallyHidden';
import ProductArt, { type ArtView } from './product-art';
import { finishes, type Product, productBySlug } from './store-data';
const views: { value: ArtView; label: string }[] = [
{ value: 'front', label: 'Front' },
{ value: 'set', label: 'All finishes' },
];
const list = new Intl.ListFormat('en');
export interface ProductGalleryProps {
product?: Product;
/** The finish on show — the variant picker beside the gallery owns it. */
finish?: string;
}
/**
* A product's pictures: one large view and a row of thumbnails that choose it. The thumbnails are tabs
* (APG's tabbed carousel), so the arrow keys move between them and each picture is named by its tab.
*/
export default function ProductGallery({ product = productBySlug('table-lamp'), finish = product.finishes[0] }: ProductGalleryProps) {
const also = product.finishes.filter((id) => id !== finish);
const labels: Record<ArtView, string> = {
front: `${product.name} in ${finishes[finish].name}`,
set: `${product.name} in ${list.format([finish, ...also].map((id) => finishes[id].name))}`,
};
return (
<Tabs defaultValue="front" gap={3}>
<Tabs.List label={`${product.name} pictures`} order={1} bb={0} gap={3}>
{views.map((view) => (
<Tabs.Tab
key={view.value}
value={view.value}
px={1}
py={1}
bgColor="transparent"
mb={0}
width={20}
height={20}
borderRadius={3}
b={1}
borderColor="transparent"
hover={{ borderColor: 'stone-300' }}
ariaAttr={{ selected: { borderColor: 'stone-900' } }}
focusVisible={{ outlineOffset: 2 }}
theme={{
dark: { bgColor: 'transparent', hover: { borderColor: 'stone-600' }, ariaAttr: { selected: { borderColor: 'stone-100' } } },
}}
>
<ProductArt shape={product.shape} finish={finish} also={also} view={view.value} stroke={1.5} radius={2} />
<VisuallyHidden>{view.label}</VisuallyHidden>
</Tabs.Tab>
))}
</Tabs.List>
{views.map((view) => (
<Tabs.Panel key={view.value} value={view.value} aspectRatio="square" borderRadius={6} startingStyle={{ opacity: 0 }}>
<ProductArt
shape={product.shape}
finish={finish}
also={also}
view={view.value}
label={labels[view.value]}
stroke={0.5}
radius={6}
/>
</Tabs.Panel>
))}
</Tabs>
);
}
It stands on
store-design, which the command installs with it. The design the store blocks and pages share: the components restyled with Box.createComponents(), the catalogue, the drawn product art, and the header and footer. The design is on the template page.