David Lewin David Lewin
E-Commerce, Research & Ads

Shoppable lookbooks and B2B catalogs in WooCommerce: hotspots as JSON post meta, without a SaaS script

Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.

Short answer

Store all of an image's or video's hotspots in one JSON post meta row on the media file, with x and y as percentages of its width and height, and render them as positioned HTML details elements. Percentages keep each hotspot on the same point at any screen size; in our test they were within 0.01% of the stored point at 390 px and 1280 px wide. Load every tagged product with one wc_get_products() call: for 12 hotspots that took 14 database queries on a cold cache, against 42 when each hotspot loaded its own product. Products that are deleted, drafted or hidden since they were tagged simply don't appear. On phones, the product card opens as a bottom sheet so it never covers the other hotspots. A SaaS platform such as Taggbox is the better choice when the content is customer photos and social posts rather than your own catalog shots.

A lookbook photo shows a whole outfit; a B2B catalog page shows a shelf of parts. Shoppers want to tap the jacket, or the valve, and see what it is and what it costs. That's a hotspot: a dot on the image linked to a product. This guide builds hotspots for WooCommerce in one small plugin, stores them so they cost the database almost nothing, and compares that with using a SaaS platform.

How this guide was made

We wrote the plugin and ran it on a real WordPress 7.1.3 and WooCommerce 11.2.1 store (PHP 8.4, MariaDB 11.4, the Twenty Twenty-Five block theme). Saving went through the WordPress REST API and the media screen's save path; rendering was checked in the HTML and in headless Microsoft Edge at 390 px and 1280 px wide. All 29 checks pass. We also read the source of two free hotspot plugins from WordPress.org and Taggbox's pricing page (October 2026). We didn't test a Taggbox account.

1. The short answer

QuestionAnswer
Where do hotspots live?One JSON post meta row on the image or video attachment
How are positions stored?x and y as percentages of the media's width and height, so they hold at any size
How are products loaded?One wc_get_products() call for all hotspots: 14 queries for 12 hotspots, against 42 one by one
Front endHTML details elements and CSS; a few lines of JavaScript for closing and for timed video hotspots
PhonesThe card opens as a bottom sheet, so it doesn't cover the other hotspots
VideosOptional from/to seconds per hotspot; opening a card pauses the video
Stale tagsDeleted, draft and hidden products are skipped at render time
When to use a SaaSFor customer photos and social posts (UGC), where collecting and rights are the hard part: Taggbox

2. Why the storage choice matters

A hotspot is tiny: two numbers and a product ID. How you store it still decides how much each page view costs:

  • One post per hotspot (a custom post type with meta for x, y and product): each hotspot is a row in wp_posts plus several in wp_postmeta, and showing a lookbook means a query for its hotspots.
  • One meta row per hotspot: fewer rows, but the same number of meta keys to manage and nothing to keep the list in order.
  • One JSON row per media file: WordPress loads all of a post's meta in one query and caches it, so the hotspots arrive with the attachment at no extra cost. Our test stored 12 hotspots in one row.

We read two free plugins on WordPress.org: WPC Shoppable Images 2.2.1 and Shoppable Images (Lookbook) Lite 1.3.1. Both already store their hotspots as JSON in post meta, on a custom post type. So storage isn't where they differ. What differs is the rest: WPC loads products in one batch but, in 2.2.1, enqueues jQuery, the Slick carousel and its own CSS and JS on every front-end page. The Lite plugin loads tagged products one at a time with wc_get_product(). The plugin below keeps the JSON row, batches the products, and adds its CSS and a small script only to pages that use it.

Lookbook hotspots: request to rendered page
Lookbook hotspots: request to rendered pagePage with [lookbook id=55] → Attachment 55 → wc_get_products(include = all IDs) → Responsive image with srcset / Hotspots as details elements → BrowserPage with [lookbook id=55]: shortcode or Shortcode blockPage with [lookbook id=55]shortcode or Shortcode blockAttachment 55: its post meta loads in one cached query: _lookbook_hotspots holds every hotspot as JSONAttachment 55its post meta loads in one cached query: _lookbook_hotspotsholds every hotspot as JSONwc_get_products(include = all IDs): one batch for every tagged product; deleted, draft and hidden ones drop outwc_get_products(include = all IDs)one batch for every tagged product; deleted, draft and hiddenones drop outResponsive image with srcsetResponsive image withsrcsetHotspots as details elements: left/top in %: the same point at any widthHotspots as detailselementsleft/top in %: the same pointat any widthBrowser: CSS places the dots; a tap opens one card (a bottom sheet on phones)BrowserCSS places the dots; a tap opens one card (a bottom sheet onphones)
Everything a lookbook needs comes from the attachment's meta and one product query. No custom post type, no extra table, no front-end library.

3. The data format

[
  {"x": 25, "y": 28.1, "product": 101},
  {"x": 68.75, "y": 46.86, "product": 102},
  {"x": 12.5, "y": 40, "product": 105, "from": 1, "to": 4}
]

x and y are percentages from the top-left corner. from and to are seconds and only matter on videos: the hotspot shows while the video is inside that window. To find a point, open the image at full size in any editor, read the pixel position and divide by the width or height.

The sanitizer runs on every save, from any path: it clamps positions to 0 to 100, rounds them, keeps only numeric values and a product ID, drops anything else and caps the list at 50. In the test, 15 submitted entries became 12: one without a product, one with "x": "left" and one that wasn't an object were dropped, and x: 150, y: -5 was stored as 100, 0.

4. The plugin

Install it as a plugin or drop it into wp-content/mu-plugins/. Then edit an image or video in the media library, fill in "Hotspots (JSON)", and add [lookbook id="55"] to any page.

<?php
/**
 * Plugin Name: Lookbook Hotspots
 * Description: Shoppable hotspots on images and videos. All of a media file's hotspots live in one JSON post meta row;
 *              the front end is HTML and CSS (a few lines of JS only for timed video hotspots).
 */

defined( 'ABSPATH' ) || exit;

const LB_META = '_lookbook_hotspots';

/**
 * Hotspot list as stored: [{"x":42.5,"y":61,"product":123}, {"x":10,"y":20,"product":7,"from":3,"to":8.5}]
 * x and y are percentages of the media's width and height, so a hotspot stays on the same spot at any size.
 * from/to (seconds) only apply to videos. Anything else is dropped.
 */
function lb_sanitize( $value ) {
	$list = is_string( $value ) ? json_decode( $value, true ) : $value;
	$out  = array();
	foreach ( is_array( $list ) ? array_slice( $list, 0, 50 ) : array() as $h ) {
		if ( ! is_array( $h ) || empty( $h['product'] ) || ! is_numeric( $h['x'] ?? null ) || ! is_numeric( $h['y'] ?? null ) ) {
			continue;
		}
		$spot = array(
			'x'       => round( min( 100, max( 0, (float) $h['x'] ) ), 2 ),
			'y'       => round( min( 100, max( 0, (float) $h['y'] ) ), 2 ),
			'product' => absint( $h['product'] ),
		);
		if ( is_numeric( $h['from'] ?? null ) && is_numeric( $h['to'] ?? null ) && $h['to'] > $h['from'] ) {
			$spot['from'] = round( max( 0, (float) $h['from'] ), 1 );
			$spot['to']   = round( (float) $h['to'], 1 );
		}
		$out[] = $spot;
	}
	return wp_json_encode( $out );
}

add_action( 'init', function () {
	register_post_meta( 'attachment', LB_META, array(
		'type'              => 'string',
		'single'            => true,
		'show_in_rest'      => true, // editable through /wp/v2/media/<id> by users who can edit that file
		'sanitize_callback' => 'lb_sanitize',
		'auth_callback'     => fn( $allowed, $key, $id ) => current_user_can( 'edit_post', $id ),
	) );
} );

// Editing: a JSON field on the attachment screen and in the media modal. Saving runs the same sanitizer.
add_filter( 'attachment_fields_to_edit', function ( $fields, $post ) {
	$fields['lookbook_hotspots'] = array(
		'label' => 'Hotspots (JSON)',
		'input' => 'textarea',
		'value' => get_post_meta( $post->ID, LB_META, true ),
		'helps' => 'x and y in % of width/height, a product ID, and for videos from/to in seconds. Shortcode: [lookbook id="' . $post->ID . '"]',
	);
	return $fields;
}, 10, 2 );

add_filter( 'attachment_fields_to_save', function ( $post, $data ) {
	if ( isset( $data['lookbook_hotspots'] ) ) {
		update_post_meta( $post['ID'], LB_META, $data['lookbook_hotspots'] );
	}
	return $post;
}, 10, 2 );

add_shortcode( 'lookbook', function ( $atts ) {
	$id       = absint( $atts['id'] ?? 0 );
	$is_video = wp_attachment_is( 'video', $id );
	if ( ! $is_video && ! wp_attachment_is( 'image', $id ) ) {
		return '';
	}
	$spots = json_decode( (string) get_post_meta( $id, LB_META, true ), true ) ?: array();

	// One query for every tagged product, however many hotspots there are.
	$products = array();
	if ( $spots ) {
		$found = wc_get_products( array( 'include' => array_column( $spots, 'product' ), 'status' => 'publish', 'limit' => -1 ) );
		foreach ( $found as $p ) {
			$products[ $p->get_id() ] = $p;
		}
	}

	$meta = wp_get_attachment_metadata( $id );
	if ( $is_video ) {
		$media = sprintf( '<video class="lb-media" src="%s" width="%d" height="%d" controls playsinline preload="metadata"></video>',
			esc_url( wp_get_attachment_url( $id ) ), $meta['width'] ?? 1280, $meta['height'] ?? 720 );
	} else {
		$media = wp_get_attachment_image( $id, 'large', false, array( 'class' => 'lb-media', 'sizes' => '(max-width: 900px) 100vw, 900px' ) );
	}

	$html = '';
	foreach ( $spots as $s ) {
		$p = $products[ $s['product'] ] ?? null;
		if ( ! $p || ! $p->is_visible() ) {
			continue; // deleted, unpublished or hidden since it was tagged
		}
		$classes = 'lb-spot' . ( $s['x'] > 55 ? ' lb-left' : '' ) . ( $s['y'] > 60 ? ' lb-up' : '' );
		$timed   = isset( $s['from'] ) ? sprintf( ' data-from="%s" data-to="%s" hidden', $s['from'], $s['to'] ) : '';
		$buy     = $p->is_type( 'simple' ) && $p->is_purchasable() && $p->is_in_stock()
			? sprintf( '<a class="lb-buy" href="%s" rel="nofollow">%s</a>', esc_url( $p->add_to_cart_url() ), esc_html( $p->add_to_cart_text() ) )
			: '';
		$html   .= sprintf(
			'<details class="%s" name="lb-%d" style="left:%s%%;top:%s%%"%s><summary aria-label="%s"></summary>'
			. '<div class="lb-card">%s<a class="lb-name" href="%s">%s</a>%s<span class="lb-price">%s</span>%s</div></details>',
			$classes, $id, $s['x'], $s['y'], $timed, esc_attr( 'Show product: ' . $p->get_name() ),
			$p->get_image( 'woocommerce_gallery_thumbnail', array( 'loading' => 'lazy' ) ), esc_url( $p->get_permalink() ), esc_html( $p->get_name() ),
			$p->get_sku() ? '<span class="lb-sku">SKU ' . esc_html( $p->get_sku() ) . '</span>' : '',
			$p->get_price_html(), $buy
		);
	}

	wp_enqueue_style( 'lookbook' );
	wp_enqueue_script( 'lookbook' );
	return sprintf( '<figure class="lb"%s>%s%s</figure>', $is_video ? ' data-video' : '', $media, $html );
} );

// Registered on init: block themes render shortcodes before wp_enqueue_scripts runs.
add_action( 'init', function () {
	wp_register_style( 'lookbook', false );
	wp_add_inline_style( 'lookbook', '
.lb{position:relative;margin:0 auto;max-width:900px}
.lb-media{display:block;width:100%;height:auto}
.lb-spot{position:absolute;margin:-16px 0 0 -16px}
.lb-spot[hidden]{display:none}
.lb-spot[open]{z-index:3}
.lb-spot>summary{list-style:none;width:32px;height:32px;box-sizing:border-box;border-radius:50%;background:#fff;border:3px solid #111;box-shadow:0 0 0 4px rgb(255 255 255/.45);cursor:pointer}
.lb-spot>summary::-webkit-details-marker{display:none}
.lb-spot>summary:focus-visible{outline:3px solid #2563eb;outline-offset:3px}
.lb-card{position:absolute;top:40px;left:0;display:grid;gap:4px;width:min(240px,70vw);padding:10px;background:#fff;color:#111;border-radius:8px;box-shadow:0 6px 24px rgb(0 0 0/.25);font-size:14px}
.lb-left .lb-card{left:auto;right:0}
.lb-up .lb-card{top:auto;bottom:40px}
.lb-card img{width:64px;height:64px;object-fit:cover}
.lb-sku{color:#555;font-size:12px}
.lb-buy{justify-self:start;padding:6px 12px;background:#111;color:#fff;border-radius:4px;text-decoration:none}
@media (max-width:600px){.lb-card,.lb-left .lb-card,.lb-up .lb-card{position:fixed;inset:auto 8px 8px;width:auto;grid-template-columns:64px 1fr;z-index:99}.lb-card img{grid-row:span 4}}' );

	// Close an open card on a tap outside it or on Escape. Timed video hotspots: show each one only inside its
	// from/to window, and pause the video when a card opens.
	wp_register_script( 'lookbook', false, array(), null, array( 'in_footer' => true ) );
	wp_add_inline_script( 'lookbook', '
const lbClose = (keep) => document.querySelectorAll(".lb-spot[open]").forEach((d) => d.contains(keep) || (d.open = false));
document.addEventListener("click", (e) => lbClose(e.target));
document.addEventListener("keydown", (e) => {
  const d = e.key === "Escape" && document.querySelector(".lb-spot[open]");
  if (d) { d.open = false; d.querySelector("summary").focus(); }
});
document.querySelectorAll(".lb[data-video]").forEach((fig) => {
  const v = fig.querySelector("video"), spots = fig.querySelectorAll(".lb-spot[data-from]");
  const sync = () => spots.forEach((s) => { s.hidden = !(v.currentTime >= +s.dataset.from && v.currentTime < +s.dataset.to); });
  v.addEventListener("timeupdate", sync);
  v.addEventListener("seeked", sync);
  fig.addEventListener("toggle", (e) => e.target.open && v.pause(), true);
});' );
} );

// Classic themes run shortcodes after <head> is printed: enqueue up front so the CSS isn't late.
add_action( 'wp_enqueue_scripts', function () {
	if ( is_singular() && has_shortcode( (string) get_post_field( 'post_content' ), 'lookbook' ) ) {
		wp_enqueue_style( 'lookbook' );
	}
} );

How it renders

  • Each hotspot is a <details> element positioned with left and top in percent. The summary is the dot, 32 px across, with an accessible name ("Show product: Linen shirt"), a focus ring, and keyboard support from the browser.
  • One card at a time comes from the name attribute: details elements with the same name close each other, with no script. A tap outside or Escape closes the open card and returns focus to its dot.
  • Cards open away from the edges. The plugin adds lb-left to hotspots right of 55% and lb-up to those below 60%, so on desktop the card opens towards the middle of the image.
  • On phones the card is a bottom sheet. In our first version the card, 70% of the screen wide, covered the neighbouring dots on a 390 px screen, so they couldn't be tapped until the card was closed. Below 600 px the card now sits fixed at the bottom of the screen. (That also meant replacing the hotspot's transform with margins: a transformed parent would have positioned the "fixed" card relative to the dot.)
  • The image is WordPress's own wp_get_attachment_image(), with srcset, sizes, width and height, so it never shifts the layout and phones get a small file.

B2B catalogs

Cards show the SKU when the product has one, because trade buyers order by part number. The price comes from get_price_html(), which most B2B and wholesale plugins filter, so a buyer sees their own price or a "log in for prices" message wherever the plugin replaces it. The add-to-cart link only appears for simple products that are purchasable and in stock; variable products link to the product page, where the buyer picks a variation.

The HTML includes prices, so a page cache serves the price from when the page was cached. Purge the lookbook pages when prices change, or keep them out of the cache if prices are per customer.

5. Test results

CheckResult
REST save as an admin; logged-out save refused (401); subscriber refused (403)Pass
Sanitizer keeps 12 of 15 entries; out-of-range positions clampedPass
12 hotspots stored as one post meta rowPass
Media screen save path (slashed POST data) stores video hotspots with a time windowPass
Queries on a cold object cache: 14 batched vs 42 one product at a timePass
Draft and hidden-from-catalog products skipped; 10 image and 2 video hotspots renderedPass
Product name with HTML escaped and never run in the browserPass
Price, SKU and add-to-cart link in each card; image with width, height and srcsetPass
CSS in the <head> (block theme), script in the footerPass
390 px and 1280 px: every hotspot within 0.5% of its stored pointPass (largest error 0.01%)
390 px and 1280 px: no layout shift from the lookbookPass
Opening a second card closes the firstPass
390 px: bottom sheet across the screen; tap outside closes; Escape closes and returns focusPass
1280 px: a right-side card opens leftwards and stays inside the imagePass
Video: timed hotspot (1 to 4 s) hidden at 0.5 s, shown at 2.1 s, hidden at 4.5 s; untimed one always shownPass
Opening a card pauses the videoPass

Two notes from testing. The Twenty Twenty-Five header shifts the page by itself on a phone (about 0.03 CLS on a page with no lookbook at all), so the layout-shift check counts only shifts inside the lookbook. And PHP's built-in server doesn't support HTTP range requests, so the video couldn't seek there; we tested timed hotspots during playback instead. Apache and Nginx support range requests.

Not tested: classic themes (the plugin enqueues its CSS early on pages whose content contains the shortcode, for that case), page builders, Safari and Firefox, and a full click-to-place editor. Placing hotspots by typing percentages works for a catalog team; a click-to-place admin screen is the obvious next step if editors place hundreds.

6. Plugin or SaaS?

This pluginA SaaS platform (e.g. Taggbox)
Best forYour own catalog shots, lookbooks, B2B part diagramsCustomer photos and social posts, collected and moderated at scale
Where the data livesOne post meta row per image, in your database and backupsThe vendor's platform, synced with your products
Front-end costInline CSS and a small script; no requests to other hostsThe vendor's embed script and assets
Prices and stockLive from WooCommerce at render time, including B2B price filtersFrom the vendor's product sync
AnalyticsYours to add (a click event per card)Built in
CostYour timeA subscription

Our view: if the images are yours, a plugin like this is enough, and it keeps catalog data in the catalog. If the images come from customers and Instagram, the work is collecting them, asking for rights and moderating, and that's what a platform is for. Many stores end up with both: hotspots on their own lookbook, a shoppable UGC gallery further down.

Related: shoppable video in a headless store and server-side social proof for WooCommerce.

7. Tools

Top pick

ToolRoleWhy it fits
TaggboxShoppable UGC galleriesCollects posts from Instagram, TikTok, YouTube and other sources, lets you tag products on them with hotspots, and embeds the gallery on WordPress and other platforms. Shoppable galleries, product tagging and hotspots start on the Growth plan: $29 a month billed yearly ($348) or $39 monthly, for 4 feeds and 50,000 views a month. The free plan (1 feed, 300 views a month, Taggbox branding) doesn't include them (pricing page, October 2026).

Alternatives: free tools and references

We don't earn anything from these links.

  • Spott: interactive images, videos and catalogs with clickable layers, priced by usage. A good fit for brands and agencies running interactive campaigns across many channels, not just one store.
  • WPC Shoppable Images for WooCommerce: a free plugin with a visual hotspot editor. Check its front-end assets on pages that don't use it.
  • MDN: the details element: including the name attribute for exclusive groups.
  • register_post_meta(): exposing meta to the REST API with a sanitizer and an auth callback.

8. Pitfalls

  • Pixel positions: they're only right at one image size. Store percentages.
  • One wc_get_product() per hotspot: three times the queries in our test, on every uncached view.
  • Trusting the JSON: anything that can write post meta can write it. Sanitize on save and escape on output.
  • show_in_rest without an auth_callback: check edit rights on the attachment, not just on posts.
  • A transform on the hotspot: it turns position: fixed inside it into "fixed to the hotspot".
  • Cards that cover other hotspots on phones: use a bottom sheet or close-on-outside-tap.
  • Tags for products that no longer sell: skip them at render time instead of showing a dead link.
  • Cached prices on per-customer B2B pricing: keep those pages out of the page cache.
Bottom line

Keep each image's hotspots in one JSON post meta row with percentage positions, load all tagged products in one query, and let HTML details elements do the interaction. It's one small plugin, it stays accurate at every screen size, and it adds a database query only for the products themselves. Use a SaaS platform like Taggbox when the content is your customers' photos rather than your own catalog.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.