- Accordion
- Advanced Toast
- AlertMessage
- ArrowButton
- Arrows
- Avatar
- Backdrop
- Basic Toast
- Breadcrumb
- Button
- CardBackground
- Card
- Carousel
- Chapters
- ChatBody
- ChatButton
- ChatHeader
- ChatInput
- ChatMessageActions
- ChatMessageEditor
- ChatMessage
- ChatTypingIndicator
- ChatWindowCsatPrompt
- ChatWindowQuote
- ChatWindow
- CheckboxSelect
- Checkbox
- Checkboxes
- Chip
- ChoiceExpander
- Clickable
- ContentReview
- Countdown
- CriteriaDates
- CriteriaParticipants
- CriteriaTransports
- Date Field
- Datepicker
- Dropdown
- ElasticHeight
- ExpandableCard
- ExpandableText
- Filter
- FloatingLabelTextField
- Form Control
- Form Label
- Frame
- HamburgerIcon
- Heading
- Image
- JourneyCard
- LayerHeader
- Layer
- Link
- Loader
- Number Field
- PageNavigation
- Pagination
- Paragraph
- Password
- Phone Field
- Popin
- Portal
- ProductThumbnail
- Promises
- Prose
- Radio Group
- RadioSelect
- Radio
- Range
- Scrollbar
- Select
- SidebarLayout
- SliderFrame
- SparkleWrapper
- Spinner
- Switch
- Table
- Tabs
- Tag
- TextField
- Toggle
- Tooltip
- ValidationMessage
'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,tagsandreview: 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.
Tignes
From €1,890 per person
Childcare from 4 months old
'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.
'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.
| 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 tagtitle, which is also its accessible name. - Choose
hLevelto fit the document outline:3by default, under theh2of 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 passsizes((min-width: 768px) 50vw, 100vw) with asrcSetso the browser downloads an image matching the frame width instead of the largest one. - Stable items:
SliderFramememoizes its slides on theitemsreference. Build the items once, outside of the render or withuseMemo, so re-rendering a list does not rebuild every slide. - Server rendering:
ProductThumbnailitself 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:
pillis always rendered with thepilltheme andtagswith theswaptheme (icon only on mobile, label fromsmup); the other themes are ignored. slotis not the HTML attribute: the nativeslotattribute is not supported on the<article>.- Subtitle HTML: the subtitle is injected as HTML. Never pass user-generated content without sanitizing it.