A horizontally-scrolling recently-viewed product rail with scroll-snap and edge-aware paging arrows.
npx shadcn@latest add @paragon/recently-viewed"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>
);
}