Skip to content
UI Kit
Accent color

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.

4 variantsOne of 4 components in Products

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

Slim stripNew in 1.2

Round pictures in one slim bar for the foot of a product page. Hover and focus open a card with name and price, a red dot marks a lower price, on phones the row scrolls.

By dayNew in 1.2

Grouped into today, yesterday and earlier with the time of each visit. Filters with counts show price drops or the last pieces, every entry says how much cheaper it is now.

Pick up againNew in 1.2

The last product large with the options picked back then and a button that adds exactly those. Three more stand beside it on desktop and under it on tablet and phone.

Details

What Recently viewed brings along, read from the registry and the source.

Category
Products
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

Props

The RecentlyViewedProps interface from components/ui/recently-viewed.tsx, with the comments from the code.

  • variantoptional
    RecentlyViewedVariant

    carouselstriptimelinecontinue

  • eyebrowoptional
    string
  • titleoptional
    string
  • itemsoptional
    ViewedItem[]
  • todayoptional
    string

    "Today" for the groups, ISO date. Default the real today.

  • historyHrefoptional
    string

    strip: link to the full history

  • onRemoveoptional
    (id: string) => void
  • onClearoptional
    () => void
  • onAddoptional
    (item: ViewedItem) => void
  • currencyoptional
    string

Built-in texts

Recently viewed writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 27 texts
titleEnglish Recently viewedGerman Zuletzt angesehen
clearEnglish Clear historyGerman Verlauf leeren
clearedEnglish History cleared.German Verlauf geleert.
undoEnglish UndoGerman Rückgängig
removeEnglish Remove {name} from historyGerman {name} aus dem Verlauf entfernen
removedEnglish {name} removed.German {name} entfernt.
previousEnglish Previous productsGerman Vorherige Produkte
nextEnglish Next productsGerman Nächste Produkte
emptyEnglish Products you look at show up here.German Produkte, die du ansiehst, erscheinen hier.
todayEnglish TodayGerman Heute
yesterdayEnglish YesterdayGerman Gestern
earlierEnglish EarlierGerman Früher
allEnglish AllGerman Alle
dropsEnglish Price droppedGerman Preis gesenkt
lowEnglish Few leftGerman Fast weg
dropByEnglish {amount} less than when you lookedGerman {amount} günstiger als bei deinem Besuch
lowStockEnglish Only a few leftGerman Nur noch wenige da
continueTitleEnglish Pick up where you left offGerman Mach weiter, wo du warst
youPickedEnglish You pickedGerman Deine Auswahl
backEnglish Back to the productGerman Zurück zum Produkt
addEnglish Add to cartGerman In den Warenkorb
addedEnglish In the cartGerman Im Warenkorb
viewedAtEnglish Viewed {time}German Angesehen {time}
moreEnglish Also viewedGerman Auch angesehen
filterEnglish FilterGerman Filter
noneEnglish Nothing matches this filter.German Zu diesem Filter passt nichts.
viewAllEnglish 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
components/ui/recently-viewed.tsx
'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.

Often searched

Jump to

Copied to the clipboard