E-commerce

Recently Viewed

A horizontally-scrolling recently-viewed product rail with scroll-snap and edge-aware paging arrows.

Install

npx shadcn@latest add @paragon/recently-viewed

recently-viewed.tsx

"use client";

import * as React from "react";
import { ChevronLeft, ChevronRight, Clock } from "lucide-react";
import { cn } from "@/lib/utils";

export interface RecentProduct {
  id: string;
  name: string;
  price: number;
  compareAt?: number;
  hue?: number;
  src?: string;
}

export interface RecentlyViewedProps extends React.ComponentProps<"div"> {
  items: RecentProduct[];
  title?: string;
  currency?: string;
  locale?: Intl.LocalesArgument;
}

function placeholder(hue: number) {
  return `linear-gradient(135deg, hsl(${hue} 55% 82%), hsl(${(hue + 40) % 360} 60% 66%))`;
}

/**
 * A horizontally-scrolling recently-viewed rail. Native scroll-snap keeps cards
 * aligned; the prev/next chevrons appear only when there's overflow in that
 * direction (tracked via a scroll listener) and page the rail by roughly one
 * viewport. Prices tabular. Scroll stays inside the rail so the page never
 * scrolls horizontally.
 */
export function RecentlyViewed({
  items,
  title = "Recently viewed",
  currency = "USD",
  locale = "en-US",
  className,
  ...props
}: RecentlyViewedProps) {
  const scrollerRef = React.useRef<HTMLDivElement>(null);
  const [edges, setEdges] = React.useState({ start: false, end: true });

  const format = React.useMemo(
    () => new Intl.NumberFormat(locale, { style: "currency", currency }),
    [locale, currency],
  );

  const update = React.useCallback(() => {
    const el = scrollerRef.current;
    if (!el) return;
    const start = el.scrollLeft > 4;
    const end = el.scrollLeft < el.scrollWidth - el.clientWidth - 4;
    setEdges({ start, end });
  }, []);

  React.useEffect(() => {
    update();
    const el = scrollerRef.current;
    if (!el) return;
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, [update]);

  const page = (dir: 1 | -1) => {
    const el = scrollerRef.current;
    if (!el) return;
    el.scrollBy({ left: dir * el.clientWidth * 0.8, behavior: "smooth" });
  };

  return (
    <div className={cn("w-full max-w-lg", className)} {...props}>
      <div className="mb-2 flex items-center gap-2">
        <Clock className="size-4 text-muted-foreground" aria-hidden />
        <h3 className="text-sm font-medium text-foreground">{title}</h3>
        <div className="ml-auto flex gap-1">
          <button
            type="button"
            aria-label="Scroll left"
            disabled={!edges.start}
            onClick={() => page(-1)}
            className="pressable flex size-7 items-center justify-center rounded-full text-muted-foreground shadow-border transition-[opacity,box-shadow] duration-150 hover:shadow-border-hover disabled:pointer-events-none disabled:opacity-30"
          >
            <ChevronLeft className="size-4" aria-hidden />
          </button>
          <button
            type="button"
            aria-label="Scroll right"
            disabled={!edges.end}
            onClick={() => page(1)}
            className="pressable flex size-7 items-center justify-center rounded-full text-muted-foreground shadow-border transition-[opacity,box-shadow] duration-150 hover:shadow-border-hover disabled:pointer-events-none disabled:opacity-30"
          >
            <ChevronRight className="size-4" aria-hidden />
          </button>
        </div>
      </div>

      <div
        ref={scrollerRef}
        onScroll={update}
        className="flex snap-x snap-mandatory gap-3 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
      >
        {items.map((item, i) => {
          const onSale = item.compareAt && item.compareAt > item.price;
          return (
            <a
              key={item.id}
              href="#"
              onClick={(e) => e.preventDefault()}
              className="group w-32 shrink-0 snap-start outline-none"
            >
              <span
                aria-hidden
                className="block aspect-square w-full rounded-xl shadow-border transition-[box-shadow] duration-150 group-hover:shadow-border-hover group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-background"
                style={{ background: placeholder(item.hue ?? i * 47) }}
              />
              <span className="mt-1.5 block truncate text-xs font-medium text-foreground">
                {item.name}
              </span>
              <span className="flex items-baseline gap-1.5">
                <span
                  className={cn(
                    "text-xs tabular-nums",
                    onSale ? "text-destructive" : "text-muted-foreground",
                  )}
                >
                  {format.format(item.price)}
                </span>
                {onSale && (
                  <span className="text-[11px] text-muted-foreground line-through tabular-nums">
                    {format.format(item.compareAt!)}
                  </span>
                )}
              </span>
            </a>
          );
        })}
      </div>
    </div>
  );
}