Recently viewed products
Products a visitor looked at before, always with pictures. A carousel, a slim strip, a timeline with price drops or pick up again.
Back to what people liked
Most shoppers compare before they buy and lose track along the way. Recently viewed brings back what they looked at, always with pictures, so the way back to a product is one click.
Carousel is a snapping row of picture cards with arrows, arrow keys and soft edges where more follows, each card can leave the history and the whole history can be cleared, both with undo. Slim strip puts round pictures into one bar at the foot of a product page, hover and focus open name and price and a red dot marks a lower price. By day groups visits into today, yesterday and earlier with filters for price drops and last pieces. Pick up again shows the last product large with the options chosen back then and a button that adds exactly those.
Place the strip at the foot of product pages and the carousel on the home page for returning visitors. Point out price drops, they are the best reason to come back. Let people clear the history, it builds trust.
Variants
Details
What Recently viewed brings along, read from the registry and the source.
- Category
- Products
- Area
- E-Commerce
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/recently-viewed.tsx
- Brings along
- utilskitbuttoniconproduct-imagemotion
- npm packages
- None
- Languages
- English and German, 27 texts of its own
- Studio
- Adjust in code
- In collections
- Online shop
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The RecentlyViewedProps interface from components/ui/recently-viewed.tsx, with the comments from the code.
variantoptionalRecentlyViewedVariantcarouselstriptimelinecontinue
eyebrowoptionalstringtitleoptionalstringitemsoptionalViewedItem[]todayoptionalstring"Today" for the groups, ISO date. Default the real today.
historyHrefoptionalstringstrip: link to the full history
onRemoveoptional(id: string) => voidonClearoptional() => voidonAddoptional(item: ViewedItem) => voidcurrencyoptionalstring
Built-in texts
Recently viewed writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 27 texts
| Key | English | German |
|---|---|---|
| title | English Recently viewed | German Zuletzt angesehen |
| clear | English Clear history | German Verlauf leeren |
| cleared | English History cleared. | German Verlauf geleert. |
| undo | English Undo | German Rückgängig |
| remove | English Remove {name} from history | German {name} aus dem Verlauf entfernen |
| removed | English {name} removed. | German {name} entfernt. |
| previous | English Previous products | German Vorherige Produkte |
| next | English Next products | German Nächste Produkte |
| empty | English Products you look at show up here. | German Produkte, die du ansiehst, erscheinen hier. |
| today | English Today | German Heute |
| yesterday | English Yesterday | German Gestern |
| earlier | English Earlier | German Früher |
| all | English All | German Alle |
| drops | English Price dropped | German Preis gesenkt |
| low | English Few left | German Fast weg |
| dropBy | English {amount} less than when you looked | German {amount} günstiger als bei deinem Besuch |
| lowStock | English Only a few left | German Nur noch wenige da |
| continueTitle | English Pick up where you left off | German Mach weiter, wo du warst |
| youPicked | English You picked | German Deine Auswahl |
| back | English Back to the product | German Zurück zum Produkt |
| add | English Add to cart | German In den Warenkorb |
| added | English In the cart | German Im Warenkorb |
| viewedAt | English Viewed {time} | German Angesehen {time} |
| more | English Also viewed | German Auch angesehen |
| filter | English Filter | German Filter |
| none | English Nothing matches this filter. | German Zu diesem Filter passt nichts. |
| viewAll | English See all {n} | German Alle {n} ansehen |
Accessibility
Read from the source of the component.
- Recently viewed handles the keys Arrow left, Arrow right and Space itself.
- Sets aria-describedby and aria-label, so screen readers announce purpose and state.
- Uses the roles status and tooltip.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 525 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, formatTime, round, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Stagger } from '@/components/ui/motion';
import { FlyUp, ProductMedia, ShopMotion, Swap, formatPrice } from '@/components/ui/product-image';
/*
* What the visitor looked at before, always with pictures.
*
* carousel a row of cards with arrows, each removable, the whole history clearable with undo
* strip a slim bar of round pictures, a card with name and price opens on hover and focus
* timeline grouped by day with the time, filters for price drops and low stock
* continue the last product large with the options picked back then, three more beside it
*/
export type RecentlyViewedVariant = 'carousel' | 'strip' | 'timeline' | 'continue';
export interface ViewedItem {
id: string;
name: string;
href?: string;
image: React.ReactNode;
price: number;
/** The price when it was viewed, a lower price now shows as a drop */
priceThen?: number;
subtitle?: string;
/** "YYYY-MM-DD" and "HH:MM" of the visit */
date?: string;
time?: string;
/** Options picked back then, e.g. "Stone · Size 42" */
picked?: string;
/** Few pieces left */
lowStock?: boolean;
}
export interface RecentlyViewedProps {
variant?: RecentlyViewedVariant;
eyebrow?: string;
title?: string;
items?: ViewedItem[];
/** "Today" for the groups, ISO date. Default the real today. */
today?: string;
/** strip: link to the full history */
historyHref?: string;
onRemove?: (id: string) => void;
onClear?: () => void;
onAdd?: (item: ViewedItem) => void;
currency?: string;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
title: 'Recently viewed',
clear: 'Clear history',
cleared: 'History cleared.',
undo: 'Undo',
remove: 'Remove {name} from history',
removed: '{name} removed.',
previous: 'Previous products',
next: 'Next products',
empty: 'Products you look at show up here.',
today: 'Today',
yesterday: 'Yesterday',
earlier: 'Earlier',
all: 'All',
drops: 'Price dropped',
low: 'Few left',
dropBy: '{amount} less than when you looked',
lowStock: 'Only a few left',
continueTitle: 'Pick up where you left off',
youPicked: 'You picked',
back: 'Back to the product',
add: 'Add to cart',
added: 'In the cart',
viewedAt: 'Viewed {time}',
more: 'Also viewed',
filter: 'Filter',
none: 'Nothing matches this filter.',
viewAll: 'See all {n}',
},
de: {
title: 'Zuletzt angesehen',
clear: 'Verlauf leeren',
cleared: 'Verlauf geleert.',
undo: 'Rückgängig',
remove: '{name} aus dem Verlauf entfernen',
removed: '{name} entfernt.',
previous: 'Vorherige Produkte',
next: 'Nächste Produkte',
empty: 'Produkte, die du ansiehst, erscheinen hier.',
today: 'Heute',
yesterday: 'Gestern',
earlier: 'Früher',
all: 'Alle',
drops: 'Preis gesenkt',
low: 'Fast weg',
dropBy: '{amount} günstiger als bei deinem Besuch',
lowStock: 'Nur noch wenige da',
continueTitle: 'Mach weiter, wo du warst',
youPicked: 'Deine Auswahl',
back: 'Zurück zum Produkt',
add: 'In den Warenkorb',
added: 'Im Warenkorb',
viewedAt: 'Angesehen {time}',
more: 'Auch angesehen',
filter: 'Filter',
none: 'Zu diesem Filter passt nichts.',
viewAll: 'Alle {n} ansehen',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, vars: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
const currencyOf = (locale: Locale, currency?: string) => currency ?? (locale === 'de' ? 'EUR' : 'USD');
const dropOf = (i: ViewedItem) => (i.priceThen && i.priceThen > i.price ? round(i.priceThen - i.price) : 0);
/** The list with remove, clear and one undo for the last step */
function useHistory(initial: ViewedItem[], props: RecentlyViewedProps) {
const [items, setItems] = useState(initial);
const [undo, setUndo] = useState<{ items: ViewedItem[]; text: string } | null>(null);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const keep = (text: string) => {
setUndo({ items, text });
clearTimeout(timer.current);
timer.current = setTimeout(() => setUndo(null), 6000);
};
return {
items,
undo,
remove: (i: ViewedItem, t: Texts) => {
keep(fill(t.removed, { name: i.name }));
setItems((xs) => xs.filter((x) => x.id !== i.id));
props.onRemove?.(i.id);
},
clear: (t: Texts) => {
keep(t.cleared);
setItems([]);
props.onClear?.();
},
restore: () => {
if (undo) setItems(undo.items);
setUndo(null);
},
};
}
function UndoBar({ h, t }: { h: ReturnType<typeof useHistory>; t: Texts }) {
return (
<div role="status" className="min-h-0">
{h.undo && (
<div className="mb-4 flex animate-[kitshop-in_0.35s_var(--ease-kit)] items-center justify-between gap-3 rounded-lg bg-surface-2 px-4 py-2 text-[13px] text-muted-foreground">
<span className="min-w-0 truncate">{h.undo.text}</span>
<button type="button" onClick={h.restore} className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-md px-2 font-medium text-foreground hover:bg-accent max-sm:h-11">
<Icon name="undo" size={14} />
{t.undo}
</button>
</div>
)}
</div>
);
}
function PriceLine({ i, currency, size = 'sm' }: { i: ViewedItem; currency: string; size?: 'sm' | 'md' }) {
const locale = useLocale();
const drop = dropOf(i);
return (
<p className={cn('flex flex-wrap items-baseline gap-x-1.5 tabular-nums', size === 'md' ? 'text-[16px]' : 'text-[13px]')}>
<span className={cn('font-medium', drop ? 'text-[color-mix(in_oklch,var(--destructive)_85%,var(--foreground))]' : 'text-foreground')}>{formatPrice(locale, i.price, currency)}</span>
{drop > 0 && <s className="text-[12px] text-muted-foreground">{formatPrice(locale, i.priceThen!, currency)}</s>}
</p>
);
}
function Empty({ t }: { t: Texts }) {
return (
<div className="flex items-center gap-4 rounded-2xl border border-dashed border-border-strong p-6">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-muted text-muted-foreground">
<Icon name="history" size={20} />
</span>
<p className="text-[14px] text-muted-foreground">{t.empty}</p>
</div>
);
}
/* ------------------------------------------------------------------ */
/* carousel */
/* ------------------------------------------------------------------ */
function Carousel(props: RecentlyViewedProps & { t: Texts; currency: string }) {
const { t, currency } = props;
const h = useHistory(props.items ?? [], props);
const row = useRef<HTMLUListElement>(null);
const [edge, setEdge] = useState<'start' | 'mid' | 'end' | 'none'>('start');
const update = () => {
const el = row.current;
if (!el) return;
if (el.scrollWidth <= el.clientWidth + 2) return setEdge('none');
setEdge(el.scrollLeft < 4 ? 'start' : el.scrollLeft + el.clientWidth >= el.scrollWidth - 4 ? 'end' : 'mid');
};
useEffect(() => {
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [h.items.length]);
const step = (d: 1 | -1) => row.current?.scrollBy({ left: d * row.current.clientWidth * 0.85, behavior: 'smooth' });
return (
<section className={cn('py-14 sm:py-16 lg:py-20', props.className)}>
<ShopMotion />
<div className="kit-container">
<div className="mb-6 flex items-end justify-between gap-4">
<div>
{props.eyebrow && <p className="mb-2 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{props.eyebrow}</p>}
<h2 className="text-h3 font-semibold text-foreground">{props.title ?? t.title}</h2>
</div>
<div className="flex items-center gap-1">
{h.items.length > 0 && (
<button type="button" onClick={() => h.clear(t)} className="mr-2 inline-flex h-10 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-sm:h-11 max-sm:min-w-11 max-sm:justify-center">
<Icon name="trash" size={14} />
<span className="max-sm:sr-only">{t.clear}</span>
</button>
)}
<button type="button" onClick={() => step(-1)} disabled={edge === 'start' || edge === 'none'} aria-label={t.previous} className="grid size-10 place-items-center rounded-full border border-border text-foreground transition-colors hover:bg-accent disabled:opacity-35 max-sm:hidden">
<Icon name="chevron-left" size={17} />
</button>
<button type="button" onClick={() => step(1)} disabled={edge === 'end' || edge === 'none'} aria-label={t.next} className="grid size-10 place-items-center rounded-full border border-border text-foreground transition-colors hover:bg-accent disabled:opacity-35 max-sm:hidden">
<Icon name="chevron-right" size={17} />
</button>
</div>
</div>
<UndoBar h={h} t={t} />
{h.items.length ? (
<ul
ref={row}
onScroll={update}
tabIndex={0}
aria-label={props.title ?? t.title}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }
}}
className={cn('kit-no-scrollbar relative flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2 sm:gap-4', edge === 'start' && 'kit-fade-end', edge === 'mid' && 'kit-fade-x', edge === 'end' && 'kit-fade-start')}
>
{h.items.map((i, n) => (
<li key={i.id} className="kit-rise group/card relative w-[44%] shrink-0 snap-start sm:w-[30%] md:w-[23%] lg:w-[calc((100%-5rem)/6)]" style={{ '--kit-delay': `${Math.min(n, 6) * 60}ms` } as React.CSSProperties}>
<a href={i.href ?? '#'} className="block rounded-xl">
<ProductMedia className="aspect-[4/5] rounded-xl [&>*]:transition-transform [&>*]:duration-700 [&>*]:ease-[var(--ease-kit)] group-hover/card:[&>*]:scale-[1.05] motion-reduce:[&>*]:transition-none">{i.image}</ProductMedia>
<p className="mt-2.5 line-clamp-1 text-[14px] font-medium text-foreground">{i.name}</p>
</a>
<PriceLine i={i} currency={currency} />
<button
type="button"
onClick={() => h.remove(i, t)}
aria-label={fill(t.remove, { name: i.name })}
className="absolute right-2 top-2 grid size-8 place-items-center rounded-full bg-background/90 text-muted-foreground shadow-[var(--shadow-sm)] backdrop-blur transition-[opacity,color] after:absolute after:-inset-1.5 hover:text-foreground sm:opacity-0 sm:group-hover/card:opacity-100 sm:group-focus-within/card:opacity-100 sm:focus-visible:opacity-100 [@media(hover:none)]:opacity-100"
>
<Icon name="x" size={14} />
</button>
</li>
))}
</ul>
) : (
<Empty t={t} />
)}
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* strip */
/* ------------------------------------------------------------------ */
function Strip(props: RecentlyViewedProps & { t: Texts; currency: string }) {
const { t, currency } = props;
const items = props.items ?? [];
const [hover, setHover] = useState<number | null>(null);
const base = useId();
return (
<section className={cn('py-10 sm:py-12', props.className)}>
<div className="kit-container">
<div className="rounded-2xl border border-border bg-card px-4 py-3 shadow-[var(--highlight),var(--shadow-sm)] sm:px-5">
<div className="flex items-center gap-4">
<h2 className="flex shrink-0 items-center gap-2 text-[13px] font-medium text-foreground">
<Icon name="history" size={16} className="text-muted-foreground" />
<span className="max-sm:sr-only">{props.title ?? t.title}</span>
</h2>
<span aria-hidden className="h-8 w-px shrink-0 bg-border max-sm:hidden" />
<ul className="kit-no-scrollbar relative -my-2 flex min-w-0 flex-1 gap-2 overflow-x-auto py-2 sm:gap-2.5 sm:overflow-visible" aria-label={props.title ?? t.title}>
{items.map((i, n) => (
<li key={i.id} className="relative shrink-0" onMouseEnter={() => setHover(n)} onMouseLeave={() => setHover(null)}>
<a
href={i.href ?? '#'}
aria-describedby={`${base}-${n}`}
onFocus={() => setHover(n)}
onBlur={() => setHover(null)}
className="kit-rise block rounded-full"
style={{ '--kit-delay': `${n * 50}ms` } as React.CSSProperties}
>
<ProductMedia className={cn('size-11 rounded-full ring-2 transition-[box-shadow,transform] duration-300 ease-[var(--ease-kit)] lg:size-14', hover === n ? 'scale-105 ring-primary' : 'ring-transparent')}>{i.image}</ProductMedia>
<span className="sr-only">{i.name}</span>
{dropOf(i) > 0 && <span aria-hidden className="absolute right-0 top-0 size-3 rounded-full bg-[var(--destructive)] ring-2 ring-card" />}
</a>
{/* Name and price above the picture, from 640 px. On phones the picture is the link. */}
<div
id={`${base}-${n}`}
role="tooltip"
data-clip-ok
className={cn(
'pointer-events-none absolute bottom-[calc(100%+0.5rem)] left-1/2 z-20 w-48 -translate-x-1/2 rounded-xl border border-border bg-popover p-3 text-popover-foreground shadow-[var(--shadow-lg)] transition-[opacity,translate,visibility] duration-200 max-sm:sr-only',
hover === n ? 'visible translate-y-0 opacity-100' : 'invisible translate-y-1 opacity-0',
n === 0 && 'left-0 translate-x-0',
)}
>
<p className="text-[13px] font-medium text-foreground">{i.name}</p>
<PriceLine i={i} currency={currency} />
{dropOf(i) > 0 && <p className="mt-1 text-[11px] font-medium text-[color-mix(in_oklch,var(--destructive)_80%,var(--foreground))]">{t.drops}</p>}
</div>
</li>
))}
</ul>
<a href={props.historyHref ?? '#'} className="ml-auto inline-flex h-11 shrink-0 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-foreground hover:bg-accent max-lg:hidden">
{fill(t.viewAll, { n: items.length })}
<Icon name="arrow-right" size={14} />
</a>
</div>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* timeline */
/* ------------------------------------------------------------------ */
function Timeline(props: RecentlyViewedProps & { t: Texts; currency: string }) {
const { t, currency } = props;
const locale = useLocale();
const h = useHistory(props.items ?? [], props);
const [filter, setFilter] = useState<'all' | 'drops' | 'low'>('all');
const today = props.today ?? new Date().toISOString().slice(0, 10);
const yesterday = new Date(new Date(today + 'T12:00:00Z').getTime() - 86400000).toISOString().slice(0, 10);
const shown = h.items.filter((i) => (filter === 'drops' ? dropOf(i) > 0 : filter === 'low' ? i.lowStock : true));
const groups = [
{ key: 'today', label: t.today, items: shown.filter((i) => i.date === today) },
{ key: 'yesterday', label: t.yesterday, items: shown.filter((i) => i.date === yesterday) },
{ key: 'earlier', label: t.earlier, items: shown.filter((i) => i.date !== today && i.date !== yesterday) },
].filter((g) => g.items.length);
const counts = { all: h.items.length, drops: h.items.filter((i) => dropOf(i) > 0).length, low: h.items.filter((i) => i.lowStock).length };
const name = useId();
return (
<section className={cn('py-14 sm:py-16 lg:py-20', props.className)}>
<ShopMotion />
<div className="kit-container">
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
{props.eyebrow && <p className="mb-2 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{props.eyebrow}</p>}
<h2 className="text-h3 font-semibold text-foreground">{props.title ?? t.title}</h2>
</div>
{h.items.length > 0 && (
<button type="button" onClick={() => h.clear(t)} className="inline-flex h-10 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-sm:h-11 max-sm:min-w-11 max-sm:justify-center">
<Icon name="trash" size={14} />
{t.clear}
</button>
)}
</div>
<fieldset className="mb-6 min-w-0">
<legend className="sr-only">{t.filter}</legend>
<div className="kit-no-scrollbar relative flex gap-2 overflow-x-auto max-sm:kit-fade-end max-sm:pr-6">
{(['all', 'drops', 'low'] as const).map((f) => (
<label key={f} className={cn('flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-full border px-4 text-[13px] font-medium transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid max-sm:h-11', filter === f ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-foreground hover:border-foreground/50')}>
<input type="radio" name={name} checked={filter === f} onChange={() => setFilter(f)} className="sr-only" />
{f === 'drops' && <Icon name="trending-down" size={14} />}
{f === 'low' && <Icon name="hourglass" size={14} />}
{t[f]}
<span className={cn('tabular-nums', filter === f ? 'text-background/70' : 'text-muted-foreground')}>{counts[f]}</span>
</label>
))}
</div>
</fieldset>
<UndoBar h={h} t={t} />
{!h.items.length ? (
<Empty t={t} />
) : !groups.length ? (
<p className="rounded-xl border border-dashed border-border-strong p-6 text-center text-[14px] text-muted-foreground">{t.none}</p>
) : (
<div className="space-y-10">
{groups.map((g) => (
<section key={g.key + filter} aria-label={g.label}>
<h3 className="mb-4 flex items-center gap-3 text-[14px] font-semibold text-foreground">
{g.label}
<span className="h-px flex-1 bg-border" aria-hidden />
</h3>
<Stagger as="ul" step={60} className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{g.items.map((i) => (
<li key={i.id} className="group/row relative flex gap-4 rounded-xl border border-border bg-card p-3 transition-shadow hover:shadow-[var(--shadow-md)]">
<ProductMedia className="size-24 shrink-0 rounded-lg">{i.image}</ProductMedia>
<div className="flex min-w-0 flex-1 flex-col py-0.5 pr-8">
<a href={i.href ?? '#'} className="text-[14px] font-medium text-foreground after:absolute after:inset-0 after:rounded-xl hover:underline hover:underline-offset-4">
{i.name}
</a>
{i.time && <p className="text-[12px] text-muted-foreground">{fill(t.viewedAt, { time: formatTime(i.time, locale) })}</p>}
<div className="mt-auto pt-2">
<PriceLine i={i} currency={currency} />
{dropOf(i) > 0 && <p className="text-[12px] font-medium text-[color-mix(in_oklch,var(--destructive)_80%,var(--foreground))]">{fill(t.dropBy, { amount: formatPrice(locale, dropOf(i), currency) })}</p>}
{i.lowStock && (
<p className="flex items-center gap-1 text-[12px] font-medium text-warning-foreground">
<Icon name="hourglass" size={12} />
{t.lowStock}
</p>
)}
</div>
</div>
<button type="button" onClick={() => h.remove(i, t)} aria-label={fill(t.remove, { name: i.name })} className="absolute right-1.5 top-1.5 z-10 grid size-9 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-sm:size-11">
<Icon name="x" size={15} />
</button>
</li>
))}
</Stagger>
</section>
))}
</div>
)}
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* continue */
/* ------------------------------------------------------------------ */
function Continue(props: RecentlyViewedProps & { t: Texts; currency: string }) {
const { t, currency } = props;
const [first, ...rest] = props.items ?? [];
const [added, setAdded] = useState(false);
const [fly, setFly] = useState(0);
if (!first) return null;
return (
<section className={cn('py-14 sm:py-16 lg:py-20', props.className)}>
<ShopMotion />
<div className="kit-container">
<h2 className="mb-6 text-h3 font-semibold text-foreground">{props.title ?? t.continueTitle}</h2>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)] lg:gap-6">
<article className="kit-rise grid grid-cols-1 overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)] sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<a href={first.href ?? '#'} className="group/hero block" tabIndex={-1} aria-hidden>
<ProductMedia className="aspect-[4/3] rounded-none sm:aspect-auto sm:h-full [&>*]:transition-transform [&>*]:duration-700 [&>*]:ease-[var(--ease-kit)] group-hover/hero:[&>*]:scale-[1.04] motion-reduce:[&>*]:transition-none">{first.image}</ProductMedia>
</a>
<div className="flex flex-col p-5 sm:p-7">
<h3 className="text-[20px] font-semibold tracking-[-0.02em] text-foreground">{first.name}</h3>
{first.subtitle && <p className="mt-1 text-[14px] text-muted-foreground">{first.subtitle}</p>}
<div className="mt-3">
<PriceLine i={first} currency={currency} size="md" />
</div>
{first.picked && (
<div className="mt-5 rounded-xl bg-surface-2 p-3.5">
<p className="text-[12px] font-medium text-muted-foreground">{t.youPicked}</p>
<p className="mt-0.5 text-[14px] font-medium text-foreground">{first.picked}</p>
</div>
)}
<div className="mt-6 flex flex-col gap-2 sm:mt-auto sm:pt-6">
<span className="relative">
<Button onClick={() => { setAdded(true); setFly((f) => f + 1); props.onAdd?.(first); }} className="w-full overflow-hidden">
<Swap value={added} className="gap-2">
<Icon name={added ? 'check' : 'bag'} size={16} />
{added ? t.added : t.add}
</Swap>
</Button>
<FlyUp count={fly} className="-top-5" />
</span>
<Button href={first.href ?? '#'} variant="ghost" icon="arrow-right">
{t.back}
</Button>
</div>
</div>
</article>
{rest.length > 0 && (
<div>
<h3 className="mb-3 text-[13px] font-medium text-muted-foreground lg:sr-only">{t.more}</h3>
<Stagger as="ul" step={80} className="grid grid-cols-1 gap-3 min-[480px]:grid-cols-3 lg:grid-cols-1">
{rest.slice(0, 3).map((i) => (
<li key={i.id} className="group/mini relative flex gap-3 rounded-xl border border-border bg-card p-2.5 transition-shadow hover:shadow-[var(--shadow-md)] min-[480px]:max-lg:flex-col">
<ProductMedia className="size-20 shrink-0 rounded-lg min-[480px]:max-lg:aspect-square min-[480px]:max-lg:size-auto">{i.image}</ProductMedia>
<div className="min-w-0 py-1">
<a href={i.href ?? '#'} className="line-clamp-2 text-[14px] font-medium text-foreground after:absolute after:inset-0 after:rounded-xl">
{i.name}
</a>
{i.picked && <p className="mt-0.5 truncate text-[12px] text-muted-foreground">{i.picked}</p>}
<div className="mt-1">
<PriceLine i={i} currency={currency} />
</div>
</div>
</li>
))}
</Stagger>
</div>
)}
</div>
</div>
</section>
);
}
/**
* Recently viewed products, always with pictures: a carousel with remove
* and undo, a slim strip of round pictures, a timeline by day with price
* drops, or the last product large to pick up where the visitor left off.
*/
export function RecentlyViewed(props: RecentlyViewedProps) {
const locale = useLocale();
const t = useTexts(TEXTS, props.labels);
const currency = currencyOf(locale, props.currency);
const { variant = 'carousel' } = props;
if (variant === 'strip') return <Strip {...props} t={t} currency={currency} />;
if (variant === 'timeline') return <Timeline {...props} t={t} currency={currency} />;
if (variant === 'continue') return <Continue {...props} t={t} currency={currency} />;
return <Carousel {...props} t={t} currency={currency} />;
}Frequent questions
How do I install Recently viewed?
With aiw add @ai-website/recently-viewed. The command puts components/ui/recently-viewed.tsx into your project, the code is yours from then on. It brings along button, icon, product-image and motion. It needs no further npm packages.
Which variants does Recently viewed have?
4 variants, Carousel, Slim strip, By day and Pick up again. You pick one with the variant prop, for example variant="strip".
Can I translate the texts of Recently viewed?
Yes. Recently viewed carries 27 texts of its own in English and German, such as “Recently viewed”, “Clear history” and “History cleared.”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Recently viewed?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Recently viewed with aiw_components and install it with aiw_add @ai-website/recently-viewed. Name the variant in your prompt, for example variant="carousel", and the assistant writes your content straight into the props.