The context

The control panel I built for the sales team includes [BRAND_POS] terminal management. The practical problem: whoever handles support often doesn't know what the companion phone app looks like. They've never installed it, never opened it.

The obvious solution would be a PDF with screenshots. But a PDF is static, boring, and nobody reads it. The idea was different: create something that feels like the app itself — you see the Home screen, you click the "Cards" button at the bottom, and the Cards screen appears. Exactly like on the real phone, but inside the browser, inside the control panel.

ℹ️

Stack: Firebase Storage for images, vanilla JavaScript inside an existing glassmorphism modal in the panel. No external libraries.

The technical idea: percentage-based hotspots on a state machine

The right technique isn't to slice images into multiple pieces. Each screen is a full image (the phone screenshot). On top of it you position invisible clickable rectangles — the hotspots — defined in percentage relative to the image, not in fixed pixels. This ensures they stay correctly aligned at any modal size.

The whole navigation is a minimal state machine: each screen has an ID, an image, and a list of hotspots. Each hotspot only specifies where it leads (target) and its percentage coordinates. The renderScreen(id) function draws the image and overlays the hotspots — nothing else.

javascript — BRAND_SCREENS data structure
const BRAND_SCREENS = {
  home: {
    img: 'Home.png',
    hotspots: [
      { top:88, left:0,  w:25, h:12, target:'carte',  label:'Cards'  },
      { top:88, left:25, w:25, h:12, target:'altro',  label:'More'  },
      { top:88, left:50, w:25, h:12, target:'disp',   label:'Devices' },
    ]
  },
  carte: { img: 'Carte.png', hotspots: [ // ... ] },
  // other screens...
};

top, left, w, h are all in percentage (0–100). A hotspot { top:88, left:0, w:25, h:12 } occupies the bottom-left quarter of the image — regardless of how tall the modal window is.

Phase 1: the hotspot editor

The practical challenge is measuring coordinates. Doing it by eye on an image would be slow and frustrating, especially with 20 screens and dozens of clickable areas. I built a standalone editor first — a single HTML file that needs no server, just open it directly in the browser.

  • 1
    Load a screenshot

    Add a screen with an ID (e.g. home) and load the image. The editor shows it full-screen on the canvas.

  • 2
    Draw hotspots with the mouse

    Drag on the canvas to draw a rectangle. Coordinates are automatically calculated as percentages relative to the image.

  • 3
    Link the destination

    For each area, pick the target screen from a dropdown. The menu populates with all existing screens. Until a destination is set, the area stays yellow with a "?".

  • 4
    Export the code

    "Generate code" produces the BRAND_SCREENS object ready to paste. Export/Import JSON to save work between sessions.

💡

Draggable and resizable hotspots: each area can be dragged to reposition or resized from the bottom-right corner. Delete with Del. Much more convenient than rewriting percentages by hand.

Phase 2: the interactive modal in the panel

Visible hotspots, not transparent ones

An important design choice: the hotspots in the final modal are not invisible. Since not all screens are available, the operator needs to know exactly where they can click and where the image is "silent". Solution: a pulsing blue glow overlay on each clickable area.

css — pulsing glow on hotspots
@keyframes mpgGlow {
  0%, 100% { box-shadow: 0 0 6px 2px rgba(14,165,233,.35); }
  50%       { box-shadow: 0 0 14px 5px rgba(14,165,233,.6); }
}
.mpg-hotspot {
  position: absolute;
  border: 1px solid rgba(14,165,233,.7);
  background: rgba(14,165,233,.12);
  border-radius: 6px;
  cursor: pointer;
  animation: mpgGlow 2s ease-in-out infinite;
}

Firebase Storage: getDownloadURL instead of img src

The first attempt was straightforward: build the Firebase Storage URL manually and use it as the src of the <img> tag. It didn't work — the images stayed white.

The reason: Firebase Storage can have security rules that require authentication. An <img src="..."> tag can't send the current user's auth token — it's a plain HTTP request with no credentials. The fix is to use the SDK directly:

javascript — after (auth-aware with SDK)
async function _guideLoadImg(screenId) {
  const imgFile = BRAND_SCREENS[screenId].img;
  // getDownloadURL handles the auth token automatically
  const url = await firebase.storage()
    .ref(`AppBRAND/${imgFile}`)
    .getDownloadURL();
  return url;
}

Auto-scale to fill the modal without a scrollbar

A phone screenshot is portrait — tall and narrow. The modal might be too short, causing a scrollbar, or too wide, leaving blank space on the sides. The solution is to measure the available space and apply a transform: scale() to the entire stage (image + hotspots together), so proportions and hotspot alignment stay perfect.

javascript — _guideFitStage()
function _guideFitStage() {
  const body  = document.getElementById('mpgBody');
  const stage = document.getElementById('mpgStage');
  const img   = stage.querySelector('img');
  if (!img || !img.naturalHeight) return;
  const availH = body.clientHeight - 8;
  const availW = body.clientWidth  - 8;
  const scaleH = availH / img.naturalHeight;
  const scaleW = availW / img.naturalWidth;
  const scale  = Math.min(scaleH, scaleW, 1); // never scale beyond 1:1
  stage.style.transform       = `scale(${scale})`;
  stage.style.transformOrigin = 'top center';
}

Problems I ran into

1. flex:1 won't expand without an explicit height on the parent

The modal body (the area containing the image) had flex: 1 to fill the available space. But it kept collapsing to zero height.

The reason is a somewhat counterintuitive CSS rule: flex: 1 on a child only works if the parent has an explicit heightmax-height alone isn't enough. With just max-height: 88vh on the container, the browser has no reference height to distribute space among flex children.

css — after (works)
#mpgBox {
  display: flex;
  flex-direction: column;
  height: min(94vh, 960px);  /* explicit height → flex:1 works */
}
#mpgBody {
  flex: 1;
  min-height: 0;   /* needed to prevent overflow in column layout */
  overflow: hidden;
}
⚠️

Rule to remember: in a flex container with flex-direction: column, always set an explicit height on the parent and min-height: 0 on children with flex: 1. Without min-height: 0, the child can overflow its container even when the height looks correct.

2. White images: Firebase Storage's silent error

With a manually constructed Firebase Storage URL, the <img> tag showed no errors in the DOM — it just stayed white. No console message. Only checking the Network tab revealed a 401 Unauthorized.

getDownloadURL() from the SDK solves it because it internally builds a URL with a temporary token already included.

3. Slow navigation: ~400ms per screen

The first working version called getDownloadURL() on every navigation click. Each call adds ~300–500ms of perceived latency — enough to make the guide feel sluggish.

Two-level solution:

  • In-memory cache — the first time you navigate to a screen, the URL is saved in _guideUrlCache. Next time, no Firebase call needed.
  • Background preload — as soon as the modal opens, _guidePreloadAll() fires all 20 getDownloadURL() calls in parallel. The modal opens on Home immediately; when the user navigates to the second screen, the URL is already cached.
javascript — cache + preload
var _guideUrlCache = {};
async function _guideGetUrl(imgFile) {
  if (_guideUrlCache[imgFile]) return _guideUrlCache[imgFile];
  const url = await firebase.storage().ref(`AppBRAND/${imgFile}`).getDownloadURL();
  _guideUrlCache[imgFile] = url;
  return url;
}
function _guidePreloadAll() {
  Object.values(BRAND_SCREENS).forEach(s => _guideGetUrl(s.img));
}

When an interactive hotspot guide makes sense (and when it doesn't)

When to use it

  • Onboarding a complex visual interface (POS, dashboard, panel with many controls): showing "click here" on a real screenshot is more effective than a text manual for someone learning quickly.
  • UI that rarely changes: percentage-based hotspots on the image stay valid as long as the layout doesn't change substantially, reducing maintenance.
  • A lightweight solution with no external tools is needed: a custom state machine in vanilla JS avoids introducing a paid SaaS onboarding tool for a relatively simple use case.

When not to use it

  • UI that changes frequently: every layout change requires manually updating hotspot coordinates, a maintenance cost that grows with redesign frequency.
  • An interactive tour on a live UI is needed (not a screenshot): libraries like Intro.js or Shepherd.js are designed for overlays on real DOM elements, more robust to layout changes than a hotspot on a static image.

Alternatives

For guided tours on interfaces that change often, an onboarding library that hooks directly into DOM elements (instead of coordinates on an image) adapts better to layout changes over time.

Common mistakes

  • Using pixel coordinates instead of percentages: absolute-pixel hotspots misalign on screens with different resolutions — percentages relative to the image stay accurate regardless of viewport.
  • Not testing the state machine across all possible user paths: a multi-phase interactive flow needs verification for every plausible click sequence, not just the intended "happy path".

The final result

The guide is accessible from the panel's Utility page with a dedicated button (📱 icon, teal/cyan gradient). Clicking it opens a glassmorphism modal with:

  • 20 screens of the [BRAND_POS] app, navigated just like on the real phone
  • 52 hotspots with a pulsing blue glow — the operator always knows exactly where to click
  • ← Back button in the header whenever you're not on Home, with a full history stack
  • Instant navigation after the first load, thanks to URL cache + background preload
  • Auto-scale to always fill the modal without a scrollbar, on any screen size

Takeaway

  1. Percentage hotspots, not pixels. Percentage coordinates stay aligned at any container size. Fixed pixels break on the first resize.
  2. Firebase Storage requires getDownloadURL. Don't build the URL manually for <img src> — if Storage has auth rules, the image won't load with no visible error.
  3. flex: 1 in column layout needs an explicit height on the parent. max-height isn't enough. Add min-height: 0 on children too, to prevent silent overflow.
  4. Cache + preload eliminate perceived latency. One call per screen on first access is fine; doing it on every navigation click isn't. A simple in-memory dictionary and a parallel preload on modal open is all it takes.

Frequently asked questions

Why use percentages instead of pixels to position hotspots on screenshots?

Percentages scale automatically with any screen or modal size. A hotspot defined at 42% from the left always stays in the right position, whether on a desktop monitor or a phone, with no need to recalculate pixel coordinates for every breakpoint.

Why did images loaded from Firebase Storage appear completely white?

Building the image URL by hand meant the request didn't carry the logged-in user's JWT token, and Firebase Storage responded with a silent 401 Unauthorized with no visible error in the <img> tag. The fix is using the SDK's getDownloadURL(), which generates a URL with a temporary token already included.

How did you eliminate the ~400ms delay when navigating between guide screens?

With two layers: an in-memory cache that stores the URL the first time a screen is opened, and a background preload that fires all getDownloadURL() calls in parallel as soon as the modal opens, so by the time the user navigates the URL is already cached.

← Back to Blog