The control panel I built for a sales team includes managing a POS terminal companion app. The practical problem: whoever handles support often has never installed the app on their own phone, let alone used it.
A PDF with screenshots would have been the obvious answer. But nobody reads a PDF. I wanted something that feels like the app — you see the Home screen, 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. No external libraries.
The technical idea: percentage-based hotspots on a state machine
The right technique isn't slicing images into pieces. Each screen is a full image (the phone screenshot). On top of it you position invisible clickable rectangles — hotspots — defined in percentage relative to the image, not fixed pixels. That's what keeps them 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 knows where it leads (target) and its percentage coordinates. A single renderScreen(id) function draws the image and overlays the hotspots — nothing else.
const BRAND_SCREENS = {
home: {
img: 'Home.png',
hotspots: [
{ top:88, left:0, w:25, h:12, target:'cards', label:'Cards' },
{ top:88, left:25, w:25, h:12, target:'more', label:'More' },
{ top:88, left:50, w:25, h:12, target:'devices', label:'Devices' },
]
},
cards: { img: 'Cards.png', hotspots: [ /* ... */ ] },
// other screens...
};
top, left, w, h are all percentages (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 is.
Phase 1: the hotspot editor
Measuring coordinates by eye on an image is slow and frustrating, especially with 20 screens and dozens of clickable areas. So I built a standalone editor first: a single HTML file, no server, just open it in the browser.
- Load a screenshot — add a screen with an ID and load its image; shown full-screen on the canvas.
- Draw hotspots with the mouse — drag on the canvas to draw a rectangle; coordinates auto-calculate as percentages.
- Link the destination — pick the target screen from a dropdown populated with all existing screens. Unlinked areas stay yellow with a "?".
-
Export the code — a "Generate code" button produces the
BRAND_SCREENSobject ready to paste, plus JSON export/import to save work between sessions.
Each hotspot is draggable and resizable from the corner, deletable with Del. Far more convenient than editing raw percentages by hand.
Phase 2: the interactive modal in the panel
Visible hotspots, not invisible ones
An important design choice: hotspots in the final modal are not invisible. Since not every screen has full navigation coverage, the operator needs to see exactly where they can click. Solution: a pulsing blue glow overlay on each clickable area.
@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
First attempt: build the Firebase Storage URL manually and use it as src on an <img> tag. Didn't work — images stayed completely white, no visible error.
The reason: Firebase Storage can have security rules requiring authentication. A plain <img src="..."> request carries no credentials, so it can't send the current user's auth token. The fix is to go through the SDK, which builds a URL with a temporary token already included:
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
Phone screenshots are portrait — tall and narrow. The modal might be too short (scrollbar) or too wide (blank space on the sides). The fix: measure available space and apply transform: scale() to the whole stage (image + hotspots together), so proportions and hotspot alignment stay exact.
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 had flex: 1 to fill available space, but kept collapsing to zero height. The counterintuitive CSS rule: flex: 1 on a child only works if the parent has an explicit height — max-height alone isn't enough.
#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;
}
In a flex-direction: column container, always set an explicit height on the parent and min-height: 0 on flex children — without it, a child can overflow its container even when the height looks correct.
2. White images: Firebase Storage's silent error
With a manually built Storage URL, the <img> tag threw no DOM error — just stayed white. Only the Network tab revealed a 401 Unauthorized. getDownloadURL() solves it because it internally embeds a temporary token in the URL.
3. Slow navigation: ~400ms per screen
The first working version called getDownloadURL() on every navigation click — each call adding 300-500ms of perceived latency. Two-level fix:
-
In-memory cache — the URL is saved in
_guideUrlCacheon first visit; subsequent visits skip Firebase entirely. -
Background preload — as soon as the modal opens,
_guidePreloadAll()fires all 20getDownloadURL()calls in parallel. The modal opens on Home immediately; by the time the user navigates further, URLs are already cached.
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));
}
The final result
- 20 screens of the app, navigated just like on the real phone
- 52 hotspots with a pulsing blue glow — always clear where to click
- ← Back button with a full history stack
- Instant navigation after the first load, thanks to cache + preload
- Auto-scale to always fill the modal without a scrollbar, on any screen size
Takeaway
- Percentage hotspots, not pixels. They stay aligned at any container size; fixed pixels break on the first resize.
-
Firebase Storage requires getDownloadURL. Don't build the URL manually for
<img src>— with auth rules in place, the image won't load with no visible error. -
flex: 1in a column layout needs an explicit height on the parent, plusmin-height: 0on the flex children. - Cache + preload eliminate perceived latency. One call per screen on first access is fine; doing it on every click isn't.
Full write-up on my blog: roversia.it/blog-02-guida-app-interattiva-mypos.html
Top comments (0)