Source Code

<div class="demo">
  <div class="card">
    <div class="card-header">
      <h3 class="card-title">MacBook Pro Workspace</h3>
      <span class="badge">Click hotspots to explore</span>
    </div>
    <div class="hotspot-wrap" id="hotspotWrap">
      <!-- CSS-drawn workspace scene -->
      <div class="scene">
        <div class="desk"></div>
        <div class="laptop">
          <div class="screen">
            <div class="screen-content">
              <div class="code-line w80"></div>
              <div class="code-line w60"></div>
              <div class="code-line w90"></div>
              <div class="code-line w50"></div>
              <div class="code-line w70"></div>
              <div class="code-line w40"></div>
            </div>
          </div>
          <div class="laptop-base"></div>
        </div>
        <div class="monitor">
          <div class="mon-screen">
            <div class="mon-bar"></div>
            <div class="mon-content">
              <div class="chart-bar" style="height:60%"></div>
              <div class="chart-bar" style="height:85%"></div>
              <div class="chart-bar" style="height:45%"></div>
              <div class="chart-bar" style="height:70%"></div>
              <div class="chart-bar" style="height:95%"></div>
            </div>
          </div>
          <div class="mon-stand"></div>
        </div>
        <div class="keyboard"></div>
        <div class="mouse"></div>
        <div class="coffee"></div>
      </div>

      <!-- Hotspot pins -->
      <button class="pin" style="left:38%;top:28%" data-tip="0" aria-label="Laptop info">
        <span class="pin-inner"></span>
      </button>
      <button class="pin" style="left:72%;top:20%" data-tip="1" aria-label="Monitor info">
        <span class="pin-inner"></span>
      </button>
      <button class="pin" style="left:45%;top:72%" data-tip="2" aria-label="Keyboard info">
        <span class="pin-inner"></span>
      </button>
      <button class="pin" style="left:82%;top:68%" data-tip="3" aria-label="Coffee info">
        <span class="pin-inner"></span>
      </button>

      <!-- Tooltips -->
      <div class="tip" id="tip0">
        <div class="tip-name">MacBook Pro 14"</div>
        <div class="tip-detail">M3 Pro · 18GB RAM · 512GB SSD</div>
        <div class="tip-price">from $1,999</div>
      </div>
      <div class="tip" id="tip1">
        <div class="tip-name">LG UltraWide 34"</div>
        <div class="tip-detail">3440×1440 · 144Hz · USB-C</div>
        <div class="tip-price">from $649</div>
      </div>
      <div class="tip" id="tip2">
        <div class="tip-name">Keychron K2 Pro</div>
        <div class="tip-detail">Wireless · Hot-swap · RGB</div>
        <div class="tip-price">from $99</div>
      </div>
      <div class="tip" id="tip3">
        <div class="tip-name">Specialty Coffee</div>
        <div class="tip-detail">Ethiopian Yirgacheffe · Light roast</div>
        <div class="tip-price">Fuel for coding</div>
      </div>
    </div>
  </div>
</div>

Image Hotspot Tooltips — HTML CSS JS Snippet

Image Hotspot with Tooltips · Layouts · Plain HTML, CSS & JS · Live preview

What's included

Features

Percentage-based pin positioning scales with container size
Continuous ripple pulse animation draws attention to pins
Active pin fills with accent colour via CSS class
Single tooltip open at a time — others close automatically
Outside-click document listener closes all tooltips
Tooltip positioned above pin with clamped overflow protection
CSS arrow pseudo-element on tooltip bottom
Zero dependencies — pure HTML, CSS, JavaScript

About this UI Snippet

Image Hotspot — Clickable Pin Markers, Ripple Pulse & Positioned Tooltip Cards

Screenshot of the Image Hotspot with Tooltips snippet rendered live

An image hotspot component overlays interactive pin markers on an image or scene, revealing detailed tooltip cards when clicked. It is widely used in product photography (shoppable lookbooks), interactive diagrams (hardware explainers, floor plans), and educational content (anatomy diagrams, historical maps). The key engineering challenge is positioning tooltips so they remain within the container bounds regardless of where on the image the pin falls.

CSS-drawn scene

Rather than requiring a real photograph (which creates licensing and file-size concerns for a snippet), the scene is drawn entirely in CSS: a dark gradient background, wooden desk surface, laptop with a screen containing coloured code lines, external monitor with a bar chart, keyboard, mouse, and coffee cup. Each element uses absolute positioning relative to the .scene container, which uses padding-bottom: 58% to maintain a fixed aspect ratio — the same technique used for responsive iframes and 16:9 video containers.

Pin placement and sizing

Each .pin button is positioned with position: absolute using percentage-based left and top values. transform: translate(-50%, -50%) centres the pin on its coordinates. This combination of percentage positioning and centring transform makes pins scale correctly as the container resizes. The pin itself is a circular .pin-inner div inside the button, giving a clickable area larger than the visual dot.

Ripple animation

Each pin has a ::before pseudo-element that runs @keyframes ripple: scaling from 1× to 1.5× while fading opacity from 0.5 to 0. This creates a continuous pulsing halo that draws attention to the interactive points without requiring user interaction. The animation uses 2s infinite so it runs permanently — useful for indicating interactivity on first load.

Active state: CSS class toggle

When a pin is clicked, JavaScript adds .active to it. The CSS rule .pin.active .pin-inner { background: #6366f1; transform: scale(1.2) } fills the pin with the accent colour and slightly enlarges it — confirming the selection. The transition on .pin-inner makes this change animate smoothly over 0.2s.

Tooltip positioning logic

The tooltip is positioned above its pin using the same percentage coordinates from the POSITIONS array. A small offset (tipTop = pt - 28) places it above the pin circle. The horizontal position is clamped with Math.min(Math.max(pl - 8, 2), 55) to prevent tooltips from overflowing the right edge of the container. The CSS arrow (::before pseudo-element with border-right and border-bottom) appears at the bottom-left of the tooltip, pointing toward the pin below.

Toggle and exclusive open

The click handler tracks activeTip. If the clicked pin's tip is already open, it closes it (toggle behaviour). Otherwise, all open tips are closed before opening the new one — only one tooltip visible at a time. document.addEventListener('click') closes all tooltips when clicking outside any pin, using e.stopPropagation() on pin clicks to prevent immediate close.

Tooltip entrance animation

@keyframes tipIn fades the tooltip in from opacity 0 and translates it up from 4px below its final position. The animation re-runs each time the .show class is added because CSS animations restart on display: none → block transitions.

React integration

Accept an image src prop and a hotspots array where each entry has x, y (percentage positions), and content (tooltip data object). Track openIndex with useState(null). Render a pin component for each hotspot using style={{ left: x+'%', top: y+'%' }}. The tooltip renders conditionally based on openIndex === index. Use a useEffect to attach a document click listener for outside-close, returning cleanup.

See also the image comparison snippet for before/after slider, the image lightbox snippet for full-screen zoom, and the image magnifier snippet for hover-zoom details.

Build with AI

Build, Understand, Optimize, and Extend It With AI

You don't have to work out the coordinate math by hand. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why the POSITIONS array in the JS must stay in sync with the inline left/top percentages on each pin button, or how the tipLeft clamping formula (Math.min(Math.max(pl - 8, 2), 55)) keeps a tooltip from overflowing the right edge of the container. The same assistant is useful for optimizing it — ask whether keeping pin coordinates duplicated in both the HTML inline styles and the JS POSITIONS array is a maintenance risk, and how you'd refactor it to a single source of truth (like reading the pin's own style at click time instead). It's just as handy for extending the component: ask it to detect overflow dynamically with getBoundingClientRect instead of hardcoded clamp values so it works at any container width, add smooth pan/zoom into the hotspot region on click, or support a hover-to-preview mode alongside the click-to-open behavior. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build an image hotspot component with clickable pin markers and positioned tooltip cards in plain HTML, CSS, and JavaScript — no library.

Requirements:
- A relatively-positioned image or scene container holding several absolutely-positioned circular pin buttons, each placed using percentage-based left and top values (not pixels) so they track the container proportionally at any size, centered on their coordinate using a translate transform.
- Each pin must have a continuously looping CSS ripple animation (a pseudo-element scaling up while fading out) to draw attention, and a distinct visual "active" state (different fill color and a slight scale-up) applied only while its tooltip is open.
- One tooltip card per pin, absolutely positioned and hidden by default, containing at least a name, a detail line, and a price or label line, with a small triangular arrow pseudo-element pointing down toward its pin.
- Clicking a pin must position its tooltip above the pin using the pin's own coordinate data, clamp the tooltip's horizontal position so it never overflows past the right edge of the container, close any other currently-open tooltip and deactivate its pin first, and toggle its own tooltip closed again if it was already open (so clicking the same pin twice opens then closes it).
- Clicking anywhere outside of a pin (on the document) must close every open tooltip and deactivate every pin, and clicking a pin itself must not trigger that outside-click close (event propagation must be stopped appropriately).
- Give the tooltip a brief entrance animation (fade and slight upward slide) that replays every time it's shown.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Replace the CSS scene with a real imageSwap the .scene div with an <img> tag or a div with background-image. Keep position:relative on the container and padding-bottom for aspect ratio.
  2. 2
    Set pin positionsSet each .pin button's left and top as percentages corresponding to where on the image the hotspot should appear. Keep data-tip matching the tip ID.
  3. 3
    Update tooltip contentEdit the .tip-name, .tip-detail, and .tip-price inside each #tipN div with your product or annotation content.
  4. 4
    Update POSITIONS in JSThe POSITIONS array in JS must match the CSS left/top values on each .pin. These are used for tooltip offset calculations.
  5. 5
    Adjust tooltip arrow directionThe ::before arrow points downward (toward the pin). For pins near the bottom of the image, flip the tooltip above by adjusting tipTop calculation and the ::before positioning.

Real-world uses

Common Use Cases

Shoppable Images
E-commerce product photography with clickable item annotations and prices
Hardware Explainers
Tech product pages with labelled components and spec callouts
Educational Diagrams
Interactive anatomy, geography, or engineering diagrams with labels
Office Floor Plans
Interactive workspace maps with room and equipment information
Related: Splide Thumbnail Gallery
See the Splide Thumbnail Gallery for a related layouts pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

Accept an image src and hotspots array (each with x, y, content props). Track openIndex with useState(null). Use useEffect for the document click listener with cleanup.

Replace the .scene div with a container div holding an <img> with width:100%. Keep position:relative on the container and adjust aspect ratio via padding-bottom or a fixed height.

Detect if the tooltip right edge exceeds the container width using getBoundingClientRect(). If it does, anchor the tooltip to the right instead of the left with right:X% and flip the ::before arrow.

Replace the click event listener with mouseenter/mouseleave on each .pin. Add pointer-events:none to .tip and pointer-events:auto when shown to allow clicking links inside.

Open the Export menu (or the Test Exports preview) in the snippet toolbar. It generates a plain React component, a React + Tailwind version where the pin and tooltip styles become utility classes, a Vue 3 single-file component with the open/close logic in script setup, and an Angular standalone component. Each converter preserves the markup, the ripple-pulse CSS, and the click-to-open behaviour — inline handlers map to the matching framework event bindings, so the hotspots and tooltips work identically across React, Vue, and Angular. Pass the hotspots in as a component prop or input instead of hardcoding them in the markup.

Position each pin with percentage left/top values rather than pixels, so the markers track the image as it scales. Keep the image container position:relative and the pins position:absolute inside it — the percentages then stay accurate at any width, including full-bleed mobile layouts where the scene resizes with the viewport.