Demo: https://gistpreview.github.io/?d51778c2cbb4056c13d52d61bc6859bb/index.html
Generated with a one-shot prompt in ChatGPT 5 Pro:
Me:
You are a webcomponents expert and you do NOT use any frameworks for webcomponents, only vanilla HTML, CSS, and JS.
Create a webcomponent for the <image-map> tag that allows the user to pass coordinates and links as follows:
<image-map>
<area shape="rect" coords="0,0,600,25" href="/nav" alt="Link to Nav component">
<area shape="rect" coords="0,25,130,200" href="/nav" alt="Link to Menu component">
<area shape="rect" coords="480,25,600,200" href="/table-of-contents" alt="Link to Table of contents component">
<area shape="rect" coords="0,200,600,380" href="/footer" alt="Link to Footer component">
</image-map>
The <area>s inside already exist in HTML so you will support them.
The webcomponent listens to resize events on its parent using a ResizeObserver and automatically updates the coords of any <area> to the correct coords.
GPT:
Reasoned for 8m 2s.
Below is a vanilla Web Component for <image-map> that:
- Accepts standard
<area>children (shape,coords,href,alt, etc.) - Watches its parent with a
ResizeObserver - Recalculates and updates the
<area>coordson resize - Renders clickable hotspots (rect / circle / poly) over the parent using absolutely‑positioned anchors (no frameworks)
- Supports an optional
base-width/base-heightfor the coordinate system (defaults to the parent’s size at first connect) - Optional
fit="contain|cover"+intrinsic-width/intrinsic-heightfor letterboxed images (e.g.,object-fit: contain) - Exposes a
part="hotspot"for styling and a[debug]attribute to visualize hit areas
How to place it: put
<image-map>as a sibling overlay on top of whatever visual you’re mapping (typically an<img>). The component will ensure the parent is a positioning context and will expand to fill it.
<script type="module">
/**
* <image-map> — vanilla Web Component
*
* Usage:
* <div class="map-wrap">
* <img src="..." alt="" />
* <image-map base-width="600" base-height="380">
* <area shape="rect" coords="0,0,600,25" href="/nav" alt="Link to Nav component">
* <area shape="rect" coords="0,25,130,200" href="/nav" alt="Link to Menu component">
* <area shape="rect" coords="480,25,600,200" href="/table-of-contents" alt="Link to Table of contents component">
* <area shape="rect" coords="0,200,600,380" href="/footer" alt="Link to Footer component">
* </image-map>
* </div>
*
* Notes:
* - Coordinates are interpreted in the "base" coordinate system. Provide base-width/height
* (e.g., the image’s authoring size). If omitted, the component captures the parent size at connect time.
* - Set fit="contain" (or "cover") with intrinsic-width/height (natural image size) to handle letterboxing.
* - The component updates the real <area> coords AND overlays accessible anchors for interaction.
*/
(() => {
const TPL = document.createElement('template');
TPL.innerHTML = `
<style>
:host {
position: absolute;
inset: 0;
display: block;
z-index: var(--image-map-z, 1);
/* We let children receive events; the host is transparent */
pointer-events: none;
/* Improve paint performance for overlays */
contain: layout style paint;
}
/* Hide native areas (they’re parsed, not rendered) */
::slotted(area) { display: none !important; }
#hotspots {
position: absolute;
inset: 0;
}
a.hotspot {
position: absolute;
display: block;
pointer-events: auto; /* re-enable actual hit-testing */
text-decoration: none;
outline: none;
/* Make the whole region clickable, but no visual by default */
background: transparent;
}
a.hotspot:focus-visible {
outline: 2px solid;
outline-offset: 2px;
}
/* Polygons are drawn over the full box and clipped */
a.hotspot[data-shape="poly"] {
left: 0; top: 0; width: 100%; height: 100%;
}
/* Optional debug overlay to see the regions */
:host([debug]) a.hotspot {
box-shadow: inset 0 0 0 1px currentColor;
opacity: .35;
}
:host([debug]) a.hotspot::after {
content: attr(data-alt);
position: absolute; left: 0; top: 0;
padding: 2px 4px;
font: 12px/1.2 system-ui, -apple-system, Segoe UI, sans-serif;
background: rgba(0,0,0,.5);
color: white;
}
</style>
<!-- Keep <area> elements in light DOM -->
<slot></slot>
<!-- Overlay hotspot container -->
<div id="hotspots" part="hotspots"></div>
`;
class ImageMap extends HTMLElement {
static get observedAttributes() {
return ['base-width', 'base-height', 'fit', 'intrinsic-width', 'intrinsic-height', 'object-position'];
}
constructor() {
super();
this._root = this.attachShadow({ mode: 'open' });
this._root.appendChild(TPL.content.cloneNode(true));
this._hotspots = this._root.getElementById('hotspots');
/** @type {HTMLAreaElement[]} */
this._areas = [];
/** @type {WeakMap<Element, number[]>} raw coords per <area> */
this._raw = new WeakMap();
this._parentRO = null;
this._areasMO = null;
this._childrenMO = null;
this._updateRAF = 0;
}
connectedCallback() {
const parent = this.parentElement;
if (!parent) {
console.warn('<image-map> needs a parent element to size against.');
return;
}
// Ensure parent is a positioning context so :host absolute can fill it.
const cs = getComputedStyle(parent);
if (cs.position === 'static') {
parent.style.position = 'relative';
}
// Observe parent size
this._parentRO = new ResizeObserver(() => this._scheduleUpdate());
this._parentRO.observe(parent);
// Watch <area> additions/removals
this._childrenMO = new MutationObserver(muts => {
for (const m of muts) {
if (m.type === 'childList') {
this._refreshAreas();
this._scheduleUpdate();
break;
}
}
});
this._childrenMO.observe(this, { childList: true, subtree: true });
// Initial build & paint
this._refreshAreas();
this._scheduleUpdate();
}
disconnectedCallback() {
this._parentRO?.disconnect();
this._childrenMO?.disconnect();
this._areasMO?.disconnect();
if (this._updateRAF) cancelAnimationFrame(this._updateRAF);
}
attributeChangedCallback() {
this._scheduleUpdate();
}
/** Gather <area> elements and create overlay anchors */
_refreshAreas() {
// Reset overlays
this._hotspots.innerHTML = '';
// Collect areas from light DOM
this._areas = Array.from(this.querySelectorAll('area'));
// Observe attribute changes on areas (except 'coords' to avoid loops)
this._areasMO?.disconnect();
this._areasMO = new MutationObserver(() => {
// Attributes on any area changed; rebuild overlays to mirror href/alt/etc.
this._refreshAreas();
this._scheduleUpdate();
});
for (const area of this._areas) {
// Capture raw coords once; use dataset if already set
if (!this._raw.has(area)) {
const from = (area.dataset.rawCoords ?? area.getAttribute('coords') ?? '').trim();
const nums = from ? from.split(/[,\s]+/).map(Number).filter(n => !Number.isNaN(n)) : [];
this._raw.set(area, nums);
area.dataset.rawCoords = nums.join(','); // keep a copy in DOM (useful if moved)
}
// Mirror to overlay anchor
const a = document.createElement('a');
a.className = 'hotspot';
a.setAttribute('part', 'hotspot');
const href = area.getAttribute('href');
if (href) a.setAttribute('href', href);
const alt = area.getAttribute('alt') || area.getAttribute('aria-label') || '';
if (alt) a.setAttribute('aria-label', alt);
a.dataset.alt = alt;
const target = area.getAttribute('target');
if (target) {
a.setAttribute('target', target);
if (target === '_blank' && !a.hasAttribute('rel')) {
a.setAttribute('rel', 'noopener noreferrer');
}
}
const rel = area.getAttribute('rel');
if (rel) a.setAttribute('rel', rel);
const download = area.getAttribute('download');
if (download !== null) a.setAttribute('download', download || '');
const shape = (area.getAttribute('shape') || 'rect').toLowerCase();
a.dataset.shape = shape;
this._hotspots.appendChild(a);
// Observe attributes other than coords to keep anchor mirrored
this._areasMO.observe(area, {
attributes: true,
attributeFilter: ['shape', 'href', 'alt', 'aria-label', 'target', 'rel', 'download']
});
}
}
_scheduleUpdate() {
if (this._updateRAF) cancelAnimationFrame(this._updateRAF);
this._updateRAF = requestAnimationFrame(() => {
this._updateRAF = 0;
this._updateLayout();
});
}
get _parentBox() {
const p = this.parentElement;
return {
w: p ? p.clientWidth : 0,
h: p ? p.clientHeight : 0
};
}
get _baseW() {
const a = this.getAttribute('base-width');
if (a) return parseFloat(a) || 0;
// default to parent size when first connected (sticky baseline)
if (!this._stickyBaseW) {
this._stickyBaseW = this._parentBox.w || 1;
}
return this._stickyBaseW;
}
get _baseH() {
const a = this.getAttribute('base-height');
if (a) return parseFloat(a) || 0;
if (!this._stickyBaseH) {
this._stickyBaseH = this._parentBox.h || 1;
}
return this._stickyBaseH;
}
/** Compute scale + offsets (supports fit=contain|cover with intrinsic sizes) */
_computeScaleOffsets() {
const { w, h } = this._parentBox;
let scaleX = w / (this._baseW || 1);
let scaleY = h / (this._baseH || 1);
let offsetLeft = 0;
let offsetTop = 0;
const fit = (this.getAttribute('fit') || '').toLowerCase();
if ((fit === 'contain' || fit === 'cover')) {
const iw = parseFloat(this.getAttribute('intrinsic-width') || '') || this._baseW || 1;
const ih = parseFloat(this.getAttribute('intrinsic-height') || '') || this._baseH || 1;
const scale = fit === 'contain' ? Math.min(w / iw, h / ih) : Math.max(w / iw, h / ih);
const drawnW = iw * scale;
const drawnH = ih * scale;
// Parse object-position-like attribute (default center)
const pos = (this.getAttribute('object-position') || '50% 50%').trim().split(/\s+/);
const posX = this._parsePos(pos[0] || '50%', w, drawnW);
const posY = this._parsePos(pos[1] || '50%', h, drawnH);
offsetLeft = posX;
offsetTop = posY;
scaleX = scaleY = scale; // uniform scale for contain/cover mapping
}
return { scaleX, scaleY, offsetLeft, offsetTop, w, h };
}
_parsePos(token, total, drawn) {
token = token.toLowerCase();
if (token.endsWith('%')) return (total - drawn) * (parseFloat(token) / 100);
if (token.endsWith('px')) return parseFloat(token);
if (token === 'left' || token === 'top') return 0;
if (token === 'right' || token === 'bottom') return total - drawn;
if (token === 'center') return (total - drawn) / 2;
const n = parseFloat(token);
return Number.isFinite(n) ? n : (total - drawn) / 2;
}
/** Core layout/update step: position hotspots and rewrite <area> coords */
_updateLayout() {
const parent = this.parentElement;
if (!parent) return;
const { scaleX, scaleY, offsetLeft, offsetTop, w, h } = this._computeScaleOffsets();
// Expand host to parent
Object.assign(this.style, { left: '0px', top: '0px', width: w + 'px', height: h + 'px' });
const hotspots = this._hotspots.querySelectorAll('a.hotspot');
for (let i = 0; i < this._areas.length; i++) {
const area = this._areas[i];
const a = hotspots[i];
if (!area || !a) continue;
const shape = (area.getAttribute('shape') || 'rect').toLowerCase();
const raw = (area.dataset.rawCoords || '').split(/[,\s]+/).map(Number).filter(n => !Number.isNaN(n));
if (!raw.length) continue;
if (shape === 'rect') {
const [x1, y1, x2, y2] = raw;
const left = offsetLeft + x1 * scaleX;
const top = offsetTop + y1 * scaleY;
const ww = (x2 - x1) * scaleX;
const hh = (y2 - y1) * scaleY;
Object.assign(a.style, {
left: left + 'px',
top: top + 'px',
width: ww + 'px',
height: hh + 'px',
borderRadius: '0',
clipPath: 'none'
});
// Rewrite <area> coords (rounded ints)
const sx1 = Math.round(left);
const sy1 = Math.round(top);
const sx2 = Math.round(left + ww);
const sy2 = Math.round(top + hh);
area.setAttribute('coords', `${sx1},${sy1},${sx2},${sy2}`);
} else if (shape === 'circle') {
// coords: cx,cy,r
const [cx, cy, r] = raw;
const left = offsetLeft + (cx - r) * scaleX;
const top = offsetTop + (cy - r) * scaleY;
const ww = 2 * r * scaleX;
const hh = 2 * r * scaleY;
Object.assign(a.style, {
left: left + 'px',
top: top + 'px',
width: ww + 'px',
height: hh + 'px',
borderRadius: '50%',
clipPath: 'ellipse(50% 50% at 50% 50%)'
});
// HTML <area shape="circle"> is (cx, cy, r). When non-uniform scaling is in play,
// there isn't a single "true" r; we use the mean scale as a pragmatic approximation.
const scx = Math.round(offsetLeft + cx * scaleX);
const scy = Math.round(offsetTop + cy * scaleY);
const sr = Math.round(r * ((scaleX + scaleY) / 2));
area.setAttribute('coords', `${scx},${scy},${sr}`);
} else if (shape === 'poly' || shape === 'polygon' || shape === 'polyline') {
// coords: x1,y1,x2,y2,...
const pts = [];
const ints = [];
for (let j = 0; j < raw.length; j += 2) {
const px = offsetLeft + raw[j] * scaleX;
const py = offsetTop + raw[j + 1] * scaleY;
pts.push(`${px}px ${py}px`);
ints.push(Math.round(px), Math.round(py));
}
// Cover full parent and clip to polygon
Object.assign(a.style, {
left: '0px', top: '0px', width: w + 'px', height: h + 'px',
borderRadius: '0',
clipPath: `polygon(${pts.join(',')})`
});
area.setAttribute('coords', ints.join(','));
} else {
console.warn(`[image-map] Unsupported shape="${shape}" on <area>. Supported: rect|circle|poly.`);
}
}
}
}
customElements.define('image-map', ImageMap);
})();
</script><style>
.map-wrap {
/* The parent the component listens to */
position: relative; /* (the component will set this if missing) */
width: min(100%, 900px);
}
.map-wrap > img {
display: block;
width: 100%;
height: auto;
}
</style>
<div class="map-wrap">
<img src="/path/to/your/image-600x380.png" alt="Annotated image">
<!-- The coordinates below are authored for a 600×380 image -->
<image-map base-width="600" base-height="380" debug>
<area shape="rect" coords="0,0,600,25" href="/nav" alt="Link to Nav component">
<area shape="rect" coords="0,25,130,200" href="/nav" alt="Link to Menu component">
<area shape="rect" coords="480,25,600,200" href="/table-of-contents" alt="Link to Table of contents component">
<area shape="rect" coords="0,200,600,380" href="/footer" alt="Link to Footer component">
</image-map>
</div>-
Remove the
debugattribute once you’re happy with the hit areas. -
If your image uses letterboxing (e.g.,
object-fit: containinside the parent), set:<image-map fit="contain" intrinsic-width="600" intrinsic-height="380" object-position="50% 50%"> ... </image-map>
This makes the component compute a uniform scale and center offsets that match the drawn image.
- No frameworks: pure Custom Elements + Shadow DOM.
- Accessibility: Each hotspot is an anchor (
<a>) witharia-labeltaken from the<area alt>. Keyboard focus is supported via:focus-visible. - Spec alignment: On every resize, the component rewrites each
<area>’scoordswith scaled integers, preserving the source-of-truth inarea.dataset.rawCoords. This means your original values don’t drift. - Shapes:
rect,circle, andpolyare supported. Forcircleunder non-uniform scaling, HTML’scoordscan’t represent an ellipse; the component uses the mean scale forr(visual overlays remain elliptical and accurate). - Styling: Customize hit areas via the
part="hotspot"selector (image-map::part(hotspot)), or toggle a visual outline with the[debug]attribute.
If you want this to also mirror other <area> attributes (e.g., referrerpolicy), just add them to the attributeFilter in the _areasMO.observe call.
