ProductThumbnail

PreviousNext

A product (resort, cruise, tour…) in a list — an image slider next to its name, tags and review

'use client';

import { ProductThumbnail } from '@/ui/ProductThumbnail';

const IMAGES = [
  'https://images.unsplash.com/photo-1506905925346-21bda4d32df4',
  'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b',
  'https://images.unsplash.com/photo-1454496522488-7a8e488e8606',
];

const ITEMS = IMAGES.map((src, index) => ({
  alt: `Grand Massif Samoëns Morillon ${index + 1}`,
  sizes: '(min-width: 768px) 50vw, 100vw',
  src: `${src}?w=960&q=70`,
}));

export function ProductThumbnailDemo() {
  return (
    <div>
      <ProductThumbnail
        className="max-w-full"
        heading="Grand Massif Samoëns Morillon"
        link={{ href: '#grand-massif' }}
        review={{ providerIcon: 'GoogleMonogram', rating: '4.5/5', label: '1975 reviews' }}
        sliderFrame={{ items: ITEMS, labels: { next: 'Next image', previous: 'Previous image' } }}
        subtitle="French Alps"
        tags={[
          {
            label: 'Exclusive Collection',
            icon: 'Diamond',
            color: 'white',
            backgroundColor: 'black',
          },
          { label: 'Ski-in ski-out', icon: 'SkiInAndOut', backgroundColor: 'lightGrey' },
        ]}
      />
    </div>
  );
}

Overview

ProductThumbnail presents a product in a list of results: a square SliderFrame of images next to a text column with the product name, a subtitle, tags and a review. On mobile the slider sits above the text; from md up, it takes half of the width on the inline start side.

The heading is a link whose hit area covers the whole thumbnail, so the product can be opened by clicking anywhere on it, while the slider arrows stay usable.

Installation

npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/product-thumbnail.json

Usage

import { ProductThumbnail } from '@/ui/ProductThumbnail';
 
export function Component() {
  return (
    <ProductThumbnail
      heading="Grand Massif Samoëns Morillon"
      link={{ href: '/r/grand-massif-samoens-morillon/w' }}
      review={{ providerIcon: 'GoogleMonogram', rating: '4.5/5', label: '1975 reviews' }}
      sliderFrame={{
        items: images,
        labels: { next: 'Next image', previous: 'Previous image' },
      }}
      subtitle="French Alps"
      tags={[
        { label: 'Exclusive Collection', icon: 'Diamond', color: 'white', backgroundColor: 'black' },
        { label: 'Ski-in ski-out', icon: 'SkiInAndOut', backgroundColor: 'lightGrey' },
      ]}
    />
  );
}

With every element

Every element of the text column is optional except the heading and the subtitle:

  • eyebrowTag: a tag above the heading,
  • pill: a small tag next to the subtitle,
  • slot: any content between the heading and the tags, like a price,
  • tags and review: a row of tags followed by a review,
  • children: any content at the end of the column.

frameTag puts a tag in the top corner of the slider.

New

Tignes

French Alps — 2,100 m
Winter

From €1,890 per person

4.5/5 • 812 reviews

Childcare from 4 months old

Tignes 1
Best seller
'use client';

import { ProductThumbnail } from '@/ui/ProductThumbnail';

const IMAGES = [
  'https://images.unsplash.com/photo-1506905925346-21bda4d32df4',
  'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b',
  'https://images.unsplash.com/photo-1454496522488-7a8e488e8606',
];

const ITEMS = IMAGES.map((src, index) => ({
  alt: `Tignes ${index + 1}`,
  sizes: '(min-width: 768px) 50vw, 100vw',
  src: `${src}?w=960&q=70`,
}));

export function ProductThumbnailFullDemo() {
  return (
    <ProductThumbnail
      className="max-w-960"
      eyebrowTag={{ label: 'New', icon: 'SunView' }}
      frameTag={{ label: 'Best seller', backgroundColor: 'white' }}
      hLevel={2}
      heading="Tignes"
      link={{ href: '#tignes' }}
      pill={{ label: 'Winter', backgroundColor: 'white', border: 'black' }}
      review={{
        providerIcon: 'TripAdvisorMonogram',
        rating: '4.5/5',
        label: '812 reviews',
        ratingImage: 'https://www.tripadvisor.fr/img/cdsi/img2/ratings/traveler/s4.5-24206-5.svg',
      }}
      sliderFrame={{ items: ITEMS, labels: { next: 'Next image', previous: 'Previous image' } }}
      slot={
        <p className="text-b3">
          From <strong className="text-h5">€1,890</strong> per person
        </p>
      }
      subtitle="French Alps — <strong>2,100 m</strong>"
      tags={[{ label: 'Ski-in ski-out', icon: 'SkiInAndOut', backgroundColor: 'lightGrey' }]}
    >
      <p className="text-b4 text-darkGrey">Childcare from 4 months old</p>
    </ProductThumbnail>
  );
}
<ProductThumbnail
  eyebrowTag={{ label: 'New', icon: 'SunView' }}
  frameTag={{ label: 'Best seller', backgroundColor: 'white' }}
  hLevel={2}
  heading="Tignes"
  link={{ href: '/r/tignes/w' }}
  pill={{ label: 'Winter', backgroundColor: 'white', border: 'black' }}
  review={{ providerIcon: 'TripAdvisorMonogram', rating: '4.5/5', label: '812 reviews' }}
  sliderFrame={{ items: images, labels }}
  slot={
    <p className="text-b3">
      From <strong className="text-h5">€1,890</strong> per person
    </p>
  }
  subtitle="French Alps — <strong>2,100 m</strong>"
  tags={[{ label: 'Ski-in ski-out', icon: 'SkiInAndOut', backgroundColor: 'lightGrey' }]}
>
  <p className="text-b4 text-darkGrey">Childcare from 4 months old</p>
</ProductThumbnail>

In a list

Thumbnails are meant to be listed. Build the slider items once — outside of the render, or memoized — and load only the first image of the list eagerly.

Tignes

French Alps
Tignes 1
'use client';

import { ProductThumbnail } from '@/ui/ProductThumbnail';

const IMAGES = [
  'https://images.unsplash.com/photo-1506905925346-21bda4d32df4',
  'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b',
  'https://images.unsplash.com/photo-1454496522488-7a8e488e8606',
];

const LABELS = { next: 'Next image', previous: 'Previous image' };

// Built once, outside the component: `SliderFrame` memoizes its slides on the `items` reference.
const RESORTS = ['Grand Massif Samoëns Morillon', 'Tignes', 'Val Thorens Sensations'].map(
  (name, resortIndex) => ({
    name,
    href: `#resort-${resortIndex}`,
    items: IMAGES.map((src, index) => ({
      alt: `${name} ${index + 1}`,
      // Only the first image of the list is likely the largest contentful paint.
      lazyLoad: resortIndex > 0 || index > 0,
      sizes: '(min-width: 768px) 50vw, 100vw',
      src: `${src}?w=960&q=70`,
    })),
  }),
);

export function ProductThumbnailListDemo() {
  return (
    <div className="flex max-w-960 flex-col gap-40">
      {RESORTS.map(({ name, href, items }) => (
        <ProductThumbnail
          heading={name}
          key={name}
          link={{ href }}
          sliderFrame={{ items, labels: LABELS }}
          subtitle="French Alps"
        />
      ))}
    </div>
  );
}
const resorts = data.map((resort, resortIndex) => ({
  ...resort,
  items: resort.images.map((image, index) => ({
    ...image,
    lazyLoad: resortIndex > 0 || index > 0,
    sizes: '(min-width: 768px) 50vw, 100vw',
  })),
}));
 
export function ResortList() {
  return (
    <div className="flex flex-col gap-40">
      {resorts.map(({ id, name, href, items }) => (
        <ProductThumbnail
          heading={name}
          key={id}
          link={{ href }}
          sliderFrame={{ items, labels }}
          subtitle={name}
        />
      ))}
    </div>
  );
}

API Reference

ProductThumbnail

Extra props are spread on the <article> element.

PropTypeDefaultDescription
headingstring(required)Name of the product.
subtitlestring(required)Subtitle of the product. Rendered with Prose, so it accepts HTML.
sliderFrameProductThumbnailSliderFrame(required)Images of the slider, see below.
linkComponentPropsWithoutRef<'a'>undefinedAttributes of the link wrapping the heading. Without it, no link.
hLevel1 | 2 | 3 | 4 | 5 | 63Level of the heading tag.
eyebrowTagTagProps | nullundefinedTag displayed above the heading.
pillTagProps | nullundefinedSmall tag next to the subtitle. Its theme is forced to pill.
tagsTagProps[]undefinedTags under the heading. Their theme is forced to swap.
reviewProductThumbnailReviewundefinedReview displayed after the tags, see ContentReview.
frameTagTagProps | nullundefinedTag displayed in the top corner of the slider.
slotReactNodeundefinedContent rendered between the heading and the tags, like a price.
childrenReactNodeundefinedContent rendered at the end of the text column.
classNamestringundefinedClasses merged on the <article>, e.g. to change the gaps or the max width.

ProductThumbnailSliderFrame

The SliderFrame props, except as, describedby and format which the thumbnail sets (div, the heading id and square).

PropTypeDefaultDescription
itemsImageProps[](required)Images of the slider. With a single image, no carousel.
labels{ next: string; previous: string }(required)Accessible names of the arrow buttons.
classNamestringundefinedClasses merged on the frame, e.g. to change its width on md.

Each item accepts any <img> attribute (srcSet, sizes…), plus lazyLoad (default true) and sources for a <picture>.

ProductThumbnailReview

PropTypeDefaultDescription
providerIconIconicNames(required)Icon of the review provider.
ratingstring(required)Rating, displayed as text.
labelstringundefinedText after the rating, like a number of reviews.
ratingImagestringundefinedURL of an image illustrating the rating.

Behaviour

The heading link has an absolutely positioned ::before covering the whole <article>, so clicking the text column — tags, review, gaps — opens the product. Interactive content in slot or children (a button, another link) needs relative z-2 to sit above it.

  • With several images, the slider sits above the covering link (z-2): the arrows, which appear on hover, and the swipe stay usable. Clicking an image does not open the product.
  • With a single image, there is nothing to navigate: the slider sits under the covering link, and clicking the image opens the product.

Responsive layout

Below md, the slider takes the full width above the text column. From md up, it takes half of the width on the inline start side, so it moves to the right in right-to-left layouts. Change its width with sliderFrame.className (e.g. md:w-380).

Accessibility

  • The thumbnail is an <article>, with the heading and subtitle grouped in an <hgroup>.
  • The arrow buttons are described by the heading (aria-describedby), so a screen reader announces "Next image, Grand Massif Samoëns Morillon".
  • Give the tags a meaningful label: on mobile only their icon is displayed, the label stays available as the tag title, which is also its accessible name.
  • Choose hLevel to fit the document outline: 3 by default, under the h2 of a result section.

Performance

  • Images: slider images are lazy loaded and decoded asynchronously by default. In a list, load the first image of the first thumbnail eagerly (lazyLoad: false), as it is likely the largest contentful paint, and pass sizes ((min-width: 768px) 50vw, 100vw) with a srcSet so the browser downloads an image matching the frame width instead of the largest one.
  • Stable items: SliderFrame memoizes its slides on the items reference. Build the items once, outside of the render or with useMemo, so re-rendering a list does not rebuild every slide.
  • Server rendering: ProductThumbnail itself is not a client component; only the slider is. In React Server Components, the text column is rendered on the server and ships no JavaScript.
  • One carousel per thumbnail: each thumbnail with several images instantiates an Embla carousel. For long lists, paginate or virtualize the results rather than rendering hundreds of thumbnails at once.

Notes

  • Tag themes are forced: pill is always rendered with the pill theme and tags with the swap theme (icon only on mobile, label from sm up); the other themes are ignored.
  • slot is not the HTML attribute: the native slot attribute is not supported on the <article>.
  • Subtitle HTML: the subtitle is injected as HTML. Never pass user-generated content without sanitizing it.