Hotspot Image
An image with numbered hotspots: tap one to open its card, step through them in order, and see which you’ve visited.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/hotspot-image.json'use client'
import { useRef, useState } from 'react'
import './hotspot-image.css'
export interface Spot { x: number; y: number; title: string; text: string; meta?: string }
export interface HotspotImageProps {
spots: Spot[]
children: React.ReactNode
pulse?: boolean
fill?: 'dark' | 'light'
className?: string
}
/** Image with numbered hotspots and a mini tour. */
export function HotspotImage({ spots, children, pulse = true, fill = 'dark', className }: HotspotImageProps) {
const [open, setOpen] = useState(-1)
const [seen, setSeen] = useState<number[]>([])
const dots = useRef<(HTMLButtonElement | null)[]>([])
const go = (i: number) => { setOpen(i); if (i >= 0) setSeen((s) => (s.includes(i) ? s : [...s, i])) }
const s = spots[open]
return (
<div className={['sd-hsp', className].filter(Boolean).join(' ')} data-fill={fill} data-pulse={pulse || undefined}
onKeyDown={(e) => {
if (e.key === 'Escape' && open >= 0) { dots.current[open]?.focus(); go(-1) }
if (e.key === 'ArrowRight') go(Math.min(spots.length - 1, open + 1))
if (e.key === 'ArrowLeft') go(Math.max(0, open - 1))
}}>
<div className="sd-hsp__stage" onClick={(e) => { if (e.target === e.currentTarget) go(-1) }}>
{children}
{spots.map((p, i) => (
<button key={i} ref={(el) => { dots.current[i] = el }} type="button" className="sd-hsp__dot" style={{ left: `${p.x}%`, top: `${p.y}%` }} aria-expanded={open === i} data-seen={seen.includes(i) || undefined}
aria-label={`${i + 1}. ${p.title}`} onClick={() => go(open === i ? -1 : i)}>{i + 1}</button>
))}
{s && (
<div className="sd-hsp__card" role="dialog" aria-label={s.title}>
<h4>{s.title}{s.meta && <span>{s.meta}</span>}</h4>
<p>{s.text}</p>
</div>
)}
</div>
<div className="sd-hsp__nav">
<button type="button" disabled={open <= 0} onClick={() => go(open - 1)}>Previous</button>
<span aria-live="polite">{open >= 0 ? `${open + 1} of ${spots.length}` : `${seen.length}/${spots.length} explored`}</span>
<button type="button" disabled={open >= spots.length - 1} onClick={() => go(open + 1)}>{open < 0 ? 'Start tour' : 'Next'}</button>
</div>
</div>
)
}.sd-hsp { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-hsp[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-hsp :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-hsp :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-hsp .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-hsp { width: min(100%, 340px); outline: 0; }
.sd-hsp__stage { position: relative; aspect-ratio: 4 / 3; border-radius: 18px; overflow: hidden; background: var(--soft); }
.sd-hsp__stage > svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.sd-hsp__dot { position: absolute; display: grid; place-items: center; width: 28px; height: 28px; margin: -14px 0 0 -14px; border-radius: 99px; background: #fff !important; color: #111 !important; font-size: 12px; font-weight: 700; box-shadow: 0 2px 8px rgb(0 0 0 / 0.35); transition: transform 200ms cubic-bezier(0.3, 1.6, 0.4, 1), opacity 200ms; }
.sd-hsp__dot::before { content: ''; position: absolute; inset: -6px; border-radius: 99px; border: 1.5px solid #fff; opacity: 0; }
.sd-hsp[data-pulse] .sd-hsp__dot:not([data-seen])::before { animation: sd-hsp-ring 2s ease-out infinite; }
.sd-hsp__dot[data-seen] { opacity: 0.6; }
.sd-hsp__dot[aria-expanded='true'] { transform: scale(1.15); opacity: 1; background: #111 !important; color: #fff !important; box-shadow: 0 0 0 2px #fff; }
.sd-hsp__card { position: absolute; left: 12px; right: 12px; bottom: 12px; padding: 12px 14px; border-radius: 14px; background: var(--bg); box-shadow: 0 14px 30px -10px rgb(0 0 0 / 0.55), inset 0 0 0 1px var(--line); animation: sd-hsp-up 220ms cubic-bezier(0.3, 1.3, 0.4, 1); }
.sd-hsp__card h4 { margin: 0 0 4px; display: flex; justify-content: space-between; gap: 10px; font-size: 14.5px; }
.sd-hsp__card h4 span { font-weight: 600; font-variant-numeric: tabular-nums; }
.sd-hsp__card p { margin: 0; font-size: 13px; line-height: 1.45; color: var(--mute); }
.sd-hsp__nav { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; font-size: 12.5px; color: var(--mute); }
.sd-hsp__nav button { padding: 6px 12px; border-radius: 99px; box-shadow: inset 0 0 0 1px var(--line); font-size: 12.5px; color: var(--fg) !important; }
.sd-hsp__nav button:disabled { opacity: 0.35; }
@keyframes sd-hsp-ring { 0% { opacity: 0.8; transform: scale(0.8); } 100% { opacity: 0; transform: scale(1.5); } }
@keyframes sd-hsp-up { from { opacity: 0; transform: translateY(8px); } }
@media (prefers-reduced-motion: reduce) { .sd-hsp *, .sd-hsp *::before, .sd-hsp *::after { animation: none !important; transition: none !important; } }Overview
Hotspots sit at percentage positions so they stay put at any size. Opening one shows a card with title, text and an optional price or link.
Next and Previous walk the hotspots like a mini tour; visited dots dim.
When to use
- Product tours, shoppable rooms, annotated diagrams.
When not to use
- Dense diagrams with many labels: use a legend.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/hotspot-image.json$
npx shadcn@latest add https://sundaeui.dev/r/hotspot-image.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/hotspot-image.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/hotspot-image.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy hotspot-image.tsx and hotspot-image.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { HotspotImage } from '@/components/sundae/hotspot-image'
<HotspotImage />Props
| Prop | Type | Default |
|---|---|---|
spots*Hotspots in percent. | { x: number; y: number; title: string; text: string; meta?: string }[] | — |
children*The image. | ReactNode | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Hotspots are buttons numbered in reading order with their title as label.
- The open card is a non-modal dialog; Escape closes it and returns focus.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-hsp; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← / →
- Previous / next hotspot
- Escape
- Close card
FAQ
- Is Hotspot Image free?
- Yes. Hotspot Image is free for personal and commercial projects, with full source on this page.
- How do I install Hotspot Image?
- Run npx shadcn@latest add https://sundaeui.dev/r/hotspot-image.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Hotspot Image support keyboard and screen readers?
- Hotspots are buttons numbered in reading order with their title as label. The open card is a non-modal dialog; Escape closes it and returns focus.
- Does Hotspot Image respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →- Pro
Feature Tour
Components · Refined Image Cropper
Components · RefinedProduct Q&A
Components · RefinedRoadmap Votes
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined