Shoppable hotspots hero

Lifestyle or flat-lay scene with a dot on each product; dots open a card with price and add to cart, and drive a product rail on mobile.

blockhotspotsanyatc
$npx shadcn@latest add @storekit/hero-hotspots
hero-hotspots

Live and interactive. Switch the brand in the frame header to see the same source under another token file.

Installation

$npx shadcn@latest add @storekit/hero-hotspots
$pnpm dlx shadcn@latest add @storekit/hero-hotspots
$yarn dlx shadcn@latest add @storekit/hero-hotspots
$bunx --bun shadcn@latest add @storekit/hero-hotspots

Source

components/storekit/hero/hero-hotspots.tsx
368 lines
"use client";

import { useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Plus, X } from "lucide-react";
import { cn, money } from "@/components/storekit/lib/cn";
import { useSkBlock } from "@/components/storekit/lib/sk-analytics";

/* Shoppable hotspots hero. A lifestyle or flat-lay scene with a dot on every
   product in it. Each dot is a real button: it opens a small card with the
   product image, price, add to cart and a link to the product page. Escape
   or a click outside closes it. Cards flip to the other side of the dot
   near the frame edges. Under md the cards would cover too much of the
   scene, so the dots select a product in a rail of chips under the photo
   instead, and the rail carries add to cart. */

const EASE = [0.23, 1, 0.32, 1] as const;

export type Hotspot = {
  x: number;
  y: number;
  product: {
    id: string;
    title: string;
    price: number;
    compareAt?: number;
    image?: string;
    href: string;
  };
};

const SAMPLE: Hotspot[] = [
  {
    x: 28,
    y: 32,
    product: { id: "a", title: "Daily Greens", price: 4900, href: "#" },
  },
  {
    x: 64,
    y: 24,
    product: { id: "b", title: "Electrolytes", price: 3200, href: "#" },
  },
  {
    x: 52,
    y: 58,
    product: { id: "c", title: "Magnesium Night", price: 2900, href: "#" },
  },
];

export function HeroHotspots({
  image = "/placeholder.webp",
  imageAlt = "",
  aspect = "1 / 1",
  eyebrow = "Shop the shelf",
  headline = "Everything in this photo, one tap away.",
  subcopy = "Tap a dot to see what it is, what it costs and add it straight to your cart.",
  hotspots = SAMPLE,
  onAdd,
  className,
}: {
  image?: string;
  imageAlt?: string;
  /** Frame aspect ratio. Match the photo so the dots land on the products. */
  aspect?: string;
  eyebrow?: string;
  headline?: string;
  subcopy?: string;
  /** Dot positions in percent of the frame. */
  hotspots?: Hotspot[];
  onAdd?: (productId: string) => void;
  className?: string;
}) {
  const reduce = useReducedMotion();
  const sk = useSkBlock("hero", "hero-hotspots");
  const [open, setOpen] = useState<number | null>(null);
  const [added, setAdded] = useState<Record<string, boolean>>({});
  const frame = useRef<HTMLDivElement>(null);
  const rail = useRef<HTMLUListElement>(null);
  const uid = useId();

  // Under md the card is hidden, so bring the matching chip into view.
  useEffect(() => {
    if (open === null || !rail.current || rail.current.offsetParent === null)
      return;
    const el = rail.current.querySelector<HTMLElement>(`[data-i="${open}"]`);
    el?.scrollIntoView({
      behavior: reduce ? "auto" : "smooth",
      block: "nearest",
      inline: "center",
    });
  }, [open, reduce]);

  useEffect(() => {
    if (open === null) return;
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(null);
    const onDown = (e: PointerEvent) => {
      const t = e.target as Node;
      if (!frame.current?.contains(t) && !rail.current?.contains(t))
        setOpen(null);
    };
    window.addEventListener("keydown", onKey);
    window.addEventListener("pointerdown", onDown);
    return () => {
      window.removeEventListener("keydown", onKey);
      window.removeEventListener("pointerdown", onDown);
    };
  }, [open]);

  const add = (id: string, i: number) => {
    onAdd?.(id);
    setAdded((a) => ({ ...a, [id]: true }));
    sk.emit("convert", "add_to_cart", i);
  };

  const toggle = (i: number) => {
    setOpen((o) => (o === i ? null : i));
    sk.emit("interact", "hotspot", i);
  };

  const addButton = (h: Hotspot, i: number, full?: boolean) => {
    const done = added[h.product.id];
    return (
      <button
        type="button"
        onClick={() => add(h.product.id, i)}
        className={cn(
          "sk-btn inline-flex h-10 items-center justify-center gap-1.5 rounded-sk-btn bg-sk-accent px-4 text-[13px] font-medium text-sk-accent-ink outline-none transition-[filter,transform] duration-200 ease-sk-out hover:brightness-[1.06] focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-card active:scale-[0.97]",
          full && "w-full",
        )}
      >
        {done ? (
          <Check size={14} aria-hidden />
        ) : (
          <Plus size={14} aria-hidden />
        )}
        {done ? "Added" : "Add to cart"}
      </button>
    );
  };

  return (
    <section
      ref={sk.ref}
      {...sk.attrs}
      className={cn("bg-sk-paper text-sk-ink", className)}
    >
      <div className="mx-auto grid w-full max-w-6xl grid-cols-1 gap-8 px-4 py-10 sm:px-8 sm:py-14 lg:grid-cols-[0.75fr_1.25fr] lg:items-center lg:gap-12">
        <div className="flex flex-col gap-4">
          <div>
            <p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">
              {eyebrow}
            </p>
            <h1
              className="mt-3 max-w-xl text-balance font-sk-display font-semibold leading-[1.08] tracking-[-0.02em]"
              style={{ fontSize: "clamp(2rem, 3.8vw, 3rem)" }}
            >
              {headline}
            </h1>
          </div>
          <p className="max-w-sm text-[15px] leading-[1.55] text-sk-muted">
            {subcopy}
          </p>
          <p className="hidden items-center gap-2 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted md:flex">
            <span className="grid h-5 w-5 place-items-center rounded-full bg-sk-ink text-sk-paper">
              <Plus size={11} strokeWidth={2.5} aria-hidden />
            </span>
            {hotspots.length} products in this photo
          </p>
        </div>

        <div className="mx-auto w-full max-w-[640px] lg:max-w-none">
          <div
            ref={frame}
            className="relative overflow-visible"
            style={{ aspectRatio: aspect }}
          >
            <div className="absolute inset-0 overflow-hidden rounded-sk-card border border-sk-line bg-sk-surface">
              <img
                src={image}
                alt={imageAlt}
                className="h-full w-full object-cover"
              />
            </div>
            {hotspots.map((h, i) => {
              const isOpen = open === i;
              const right = h.x > 62;
              const below = h.y < 40;
              return (
                <div
                  key={h.product.id}
                  className={cn("absolute", isOpen ? "z-20" : "z-10")}
                  style={{ left: `${h.x}%`, top: `${h.y}%` }}
                >
                  <button
                    type="button"
                    onClick={() => toggle(i)}
                    aria-expanded={isOpen}
                    aria-label={`${h.product.title}, ${money(h.product.price)}`}
                    className="group relative -ml-[22px] -mt-[22px] grid h-11 w-11 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-sk-accent"
                  >
                    {!isOpen ? (
                      <span
                        aria-hidden
                        className="absolute h-7 w-7 animate-ping rounded-full bg-sk-paper/60 [animation-duration:2.4s] motion-reduce:hidden"
                      />
                    ) : null}
                    <motion.span
                      aria-hidden
                      animate={{
                        scale: isOpen ? 1.12 : 1,
                        rotate: isOpen ? 45 : 0,
                      }}
                      transition={{
                        type: "spring",
                        stiffness: 500,
                        damping: 28,
                      }}
                      className={cn(
                        "relative grid h-7 w-7 place-items-center rounded-full border-2 border-sk-paper shadow-[0_6px_18px_-6px_rgb(0_0_0/0.55)] transition-colors duration-200",
                        isOpen
                          ? "bg-sk-accent text-sk-accent-ink"
                          : "bg-sk-ink text-sk-paper group-hover:bg-sk-accent group-hover:text-sk-accent-ink",
                      )}
                    >
                      <Plus size={14} strokeWidth={2.5} />
                    </motion.span>
                  </button>

                  <AnimatePresence>
                    {isOpen ? (
                      <motion.div
                        role="dialog"
                        aria-label={h.product.title}
                        initial={{
                          opacity: 0,
                          y: reduce ? 0 : below ? -6 : 6,
                          scale: reduce ? 1 : 0.96,
                        }}
                        animate={{ opacity: 1, y: 0, scale: 1 }}
                        exit={{ opacity: 0, scale: reduce ? 1 : 0.97 }}
                        transition={{
                          type: "spring",
                          stiffness: 420,
                          damping: 32,
                        }}
                        style={{
                          transformOrigin: `${right ? "right" : "left"} ${below ? "top" : "bottom"}`,
                        }}
                        className={cn(
                          "absolute hidden w-64 rounded-sk-card border border-sk-line bg-sk-card p-3 text-sk-ink shadow-[0_28px_60px_-28px_rgb(0_0_0/0.55)] md:block",
                          right ? "right-3" : "left-3",
                          below ? "top-6" : "bottom-6",
                        )}
                      >
                        <div className="flex gap-3">
                          {h.product.image ? (
                            <img
                              src={h.product.image}
                              alt=""
                              className="h-16 w-16 shrink-0 rounded-sk-chip bg-sk-surface object-cover"
                            />
                          ) : null}
                          <div className="min-w-0 flex-1">
                            <a
                              href={h.product.href}
                              onClick={() =>
                                sk.emit("interact", "hotspot_product", i)
                              }
                              className="block truncate text-sm font-medium outline-none hover:underline focus-visible:underline"
                            >
                              {h.product.title}
                            </a>
                            <p className="mt-1 flex items-baseline gap-2 text-sm tabular-nums">
                              <span
                                className={
                                  h.product.compareAt
                                    ? "text-sk-sale"
                                    : "text-sk-price"
                                }
                              >
                                {money(h.product.price)}
                              </span>
                              {h.product.compareAt ? (
                                <s className="text-[12px] text-sk-muted">
                                  {money(h.product.compareAt)}
                                </s>
                              ) : null}
                            </p>
                          </div>
                          <button
                            type="button"
                            onClick={() => setOpen(null)}
                            aria-label="Close"
                            className="-mr-1 -mt-1 grid h-8 w-8 shrink-0 place-items-center rounded-full text-sk-muted outline-none hover:bg-sk-ink/[0.05] focus-visible:ring-2 focus-visible:ring-sk-accent"
                          >
                            <X size={14} aria-hidden />
                          </button>
                        </div>
                        <div className="mt-3">{addButton(h, i, true)}</div>
                      </motion.div>
                    ) : null}
                  </AnimatePresence>
                </div>
              );
            })}
          </div>

          <ul
            ref={rail}
            id={`${uid}-rail`}
            className="mt-4 flex snap-x gap-2 overflow-x-auto pb-1 md:hidden"
            aria-label="Products in this photo"
          >
            {hotspots.map((h, i) => {
              const active = open === i;
              return (
                <li key={h.product.id} data-i={i} className="snap-start">
                  <div
                    className={cn(
                      "flex w-[260px] items-center gap-3 rounded-sk-card border bg-sk-card p-2.5 transition-colors duration-200",
                      active ? "border-sk-accent" : "border-sk-line",
                    )}
                  >
                    {h.product.image ? (
                      <img
                        src={h.product.image}
                        alt=""
                        className="h-12 w-12 shrink-0 rounded-sk-chip bg-sk-surface object-cover"
                      />
                    ) : null}
                    <div className="min-w-0 flex-1">
                      <a
                        href={h.product.href}
                        className="block truncate text-[13px] font-medium outline-none focus-visible:underline"
                      >
                        {h.product.title}
                      </a>
                      <p className="text-[13px] tabular-nums text-sk-muted">
                        {money(h.product.price)}
                      </p>
                    </div>
                    <button
                      type="button"
                      onClick={() => add(h.product.id, i)}
                      aria-label={
                        added[h.product.id]
                          ? `${h.product.title} added`
                          : `Add ${h.product.title} to cart`
                      }
                      className="sk-btn grid h-10 w-10 shrink-0 place-items-center rounded-full bg-sk-accent text-sk-accent-ink outline-none transition-transform duration-200 ease-sk-out focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 active:scale-[0.94]"
                    >
                      {added[h.product.id] ? (
                        <Check size={15} aria-hidden />
                      ) : (
                        <Plus size={15} aria-hidden />
                      )}
                    </button>
                  </div>
                </li>
              );
            })}
          </ul>
        </div>
      </div>
    </section>
  );
}

Usage

Hotspot `x` and `y` are percentages of the frame. Wire `onAdd` to your cart.

Shoppable hotspots hero, StoreKit