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

<ComponentPreview align="start" name="product-thumbnail-demo" />

## 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

Using the CLI:

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

Or manually, copy the following code into `/ui/ProductThumbnail.tsx`:

```tsx
import {
  type ComponentPropsWithoutRef,
  type FunctionComponent,
  type ReactNode,
  useId,
} from 'react';

import { ContentReview, type ContentReviewProps } from './ContentReview';
import { Prose } from './Prose';
import { Tag, type TagProps } from './Tag';
import { FrameLabelsWrapper } from './frame/FrameLabelsWrapper';
import { SliderFrame, type SliderFrameProps } from './frame/SliderFrame';
import { cn } from './helpers/cn';
import { Heading, type HeadingProps } from './heading/Heading';

export interface ProductThumbnailProps extends Omit<ComponentPropsWithoutRef<'article'>, 'slot'> {
  /**
   * Tag displayed above the heading
   */
  eyebrowTag?: TagProps | null;
  /**
   * Tag displayed in the top corner of the slider
   */
  frameTag?: TagProps | null;
  /**
   * Level of the heading tag
   */
  hLevel?: HeadingProps['level'];
  /**
   * Name of the product
   */
  heading: string;
  /**
   * Attributes of the link wrapping the heading. Its hit area covers the whole thumbnail.
   */
  link?: ComponentPropsWithoutRef<'a'>;
  /**
   * Small tag displayed next to the subtitle
   */
  pill?: TagProps | null;
  /**
   * Review displayed after the tags
   */
  review?: Pick<ContentReviewProps, 'label' | 'providerIcon' | 'rating' | 'ratingImage'>;
  /**
   * Images of the slider. `describedby`, `format` and `as` are set by the thumbnail.
   */
  sliderFrame: Omit<SliderFrameProps, 'as' | 'describedby' | 'format'>;
  /**
   * Content rendered between the heading and the tags, like a price
   */
  slot?: ReactNode;
  /**
   * Subtitle of the product. Accepts HTML.
   */
  subtitle: string;
  /**
   * Tags displayed under the heading: icon only on mobile, label from `sm` up
   */
  tags?: TagProps[];
}

export const ProductThumbnail: FunctionComponent<ProductThumbnailProps> = ({
  children,
  className,
  eyebrowTag,
  frameTag,
  heading,
  hLevel = 3,
  link,
  pill,
  review,
  sliderFrame,
  slot,
  subtitle,
  tags,
  ...attrs
}) => {
  const headingId = useId();
  const hasTags = Boolean(tags?.length);
  // A carousel sits above the link covering the thumbnail, so its arrows and swipe stay usable.
  const hasCarousel = sliderFrame.items.length > 1;

  return (
    <article
      {...attrs}
      className={cn(
        'relative isolate grid gap-x-32 gap-y-20 md:grid-cols-2 md:items-start',
        className,
      )}
      data-name="ProductThumbnail"
    >
      <div className="flex flex-col gap-8">
        {eyebrowTag && <Tag {...eyebrowTag} className={cn('self-start', eyebrowTag.className)} />}
        <hgroup>
          <Heading className="text-b2 font-semibold" id={headingId} level={hLevel}>
            {link ? (
              <a
                {...link}
                className={cn('before:absolute before:inset-0 before:z-1', link.className)}
              >
                {heading}
              </a>
            ) : (
              heading
            )}
          </Heading>
          <div className="flex items-center gap-8">
            <Prose className="text-b3" text={subtitle} />
            {pill && <Tag {...pill} theme="pill" />}
          </div>
        </hgroup>
        {slot}
        {(hasTags || review) && (
          <div className="flex items-center gap-8">
            {tags?.map((tag, index) => <Tag {...tag} key={index} theme="swap" />)}
            <div className="@container/product-thumbnail-content-review w-full">
              {review && <ContentReview {...review} className="text-b4 sm:text-b3 truncate" />}
            </div>
          </div>
        )}
        {children}
      </div>
      <SliderFrame
        {...sliderFrame}
        as="div"
        className={cn('order-first w-full', { 'z-2': hasCarousel }, sliderFrame.className)}
        describedby={headingId}
        format="square"
      >
        <FrameLabelsWrapper hasImage>
          {frameTag && (
            <Tag {...frameTag} className={cn('m-12 self-start sm:m-24', frameTag.className)} />
          )}
        </FrameLabelsWrapper>
      </SliderFrame>
    </article>
  );
};
```

## Usage

```tsx showLineNumbers
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.

```tsx
'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>
  );
}
```

```tsx showLineNumbers
<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.

```tsx
'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>
  );
}
```

```tsx showLineNumbers
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.

| Prop          | Type                                      | Default     | Description                                                                  |
| ------------- | ----------------------------------------- | ----------- | ---------------------------------------------------------------------------- |
| `heading`     | `string`                                  | (required)  | Name of the product.                                                         |
| `subtitle`    | `string`                                  | (required)  | Subtitle of the product. Rendered with `Prose`, so it accepts HTML.          |
| `sliderFrame` | `ProductThumbnailSliderFrame`             | (required)  | Images of the slider, see below.                                             |
| `link`        | `ComponentPropsWithoutRef<'a'>`           | `undefined` | Attributes of the link wrapping the heading. Without it, no link.            |
| `hLevel`      | `1 \| 2 \| 3 \| 4 \| 5 \| 6`              | `3`         | Level of the heading tag.                                                    |
| `eyebrowTag`  | `TagProps \| null`                        | `undefined` | Tag displayed above the heading.                                             |
| `pill`        | `TagProps \| null`                        | `undefined` | Small tag next to the subtitle. Its `theme` is forced to `pill`.             |
| `tags`        | `TagProps[]`                              | `undefined` | Tags under the heading. Their `theme` is forced to `swap`.                   |
| `review`      | `ProductThumbnailReview`                  | `undefined` | Review displayed after the tags, see `ContentReview`.                        |
| `frameTag`    | `TagProps \| null`                        | `undefined` | Tag displayed in the top corner of the slider.                               |
| `slot`        | `ReactNode`                               | `undefined` | Content rendered between the heading and the tags, like a price.             |
| `children`    | `ReactNode`                               | `undefined` | Content rendered at the end of the text column.                              |
| `className`   | `string`                                  | `undefined` | Classes 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`).

| Prop        | Type                                    | Default     | Description                                                    |
| ----------- | --------------------------------------- | ----------- | -------------------------------------------------------------- |
| `items`     | `ImageProps[]`                          | (required)  | Images of the slider. With a single image, no carousel.        |
| `labels`    | `{ next: string; previous: string }`    | (required)  | Accessible names of the arrow buttons.                         |
| `className` | `string`                                | `undefined` | Classes 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

| Prop           | Type          | Default     | Description                                     |
| -------------- | ------------- | ----------- | ----------------------------------------------- |
| `providerIcon` | `IconicNames` | (required)  | Icon of the review provider.                    |
| `rating`       | `string`      | (required)  | Rating, displayed as text.                      |
| `label`        | `string`      | `undefined` | Text after the rating, like a number of reviews. |
| `ratingImage`  | `string`      | `undefined` | URL of an image illustrating the rating.        |

## Behaviour

### Covering link

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.

### Slider and link stacking

- 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.
