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.
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.
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
| Question | Answer |
|---|---|
| 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 end | HTML details elements and CSS; a few lines of JavaScript for closing and for timed video hotspots |
| Phones | The card opens as a bottom sheet, so it doesn't cover the other hotspots |
| Videos | Optional from/to seconds per hotspot; opening a card pauses the video |
| Stale tags | Deleted, draft and hidden products are skipped at render time |
| When to use a SaaS | For 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_postsplus several inwp_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.
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 withleftandtopin 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
nameattribute: 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-leftto hotspots right of 55% andlb-upto 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
transformwith margins: a transformed parent would have positioned the "fixed" card relative to the dot.) - The image is WordPress's own
wp_get_attachment_image(), withsrcset,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
| Check | Result |
|---|---|
| REST save as an admin; logged-out save refused (401); subscriber refused (403) | Pass |
| Sanitizer keeps 12 of 15 entries; out-of-range positions clamped | Pass |
| 12 hotspots stored as one post meta row | Pass |
| Media screen save path (slashed POST data) stores video hotspots with a time window | Pass |
| Queries on a cold object cache: 14 batched vs 42 one product at a time | Pass |
| Draft and hidden-from-catalog products skipped; 10 image and 2 video hotspots rendered | Pass |
| Product name with HTML escaped and never run in the browser | Pass |
| Price, SKU and add-to-cart link in each card; image with width, height and srcset | Pass |
CSS in the <head> (block theme), script in the footer | Pass |
| 390 px and 1280 px: every hotspot within 0.5% of its stored point | Pass (largest error 0.01%) |
| 390 px and 1280 px: no layout shift from the lookbook | Pass |
| Opening a second card closes the first | Pass |
| 390 px: bottom sheet across the screen; tap outside closes; Escape closes and returns focus | Pass |
| 1280 px: a right-side card opens leftwards and stays inside the image | Pass |
| 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 shown | Pass |
| Opening a card pauses the video | Pass |
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 plugin | A SaaS platform (e.g. Taggbox) | |
|---|---|---|
| Best for | Your own catalog shots, lookbooks, B2B part diagrams | Customer photos and social posts, collected and moderated at scale |
| Where the data lives | One post meta row per image, in your database and backups | The vendor's platform, synced with your products |
| Front-end cost | Inline CSS and a small script; no requests to other hosts | The vendor's embed script and assets |
| Prices and stock | Live from WooCommerce at render time, including B2B price filters | From the vendor's product sync |
| Analytics | Yours to add (a click event per card) | Built in |
| Cost | Your time | A 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
| Tool | Role | Why it fits |
|---|---|---|
| Shoppable UGC galleries | Collects 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
nameattribute 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_restwithout anauth_callback: check edit rights on the attachment, not just on posts.- A
transformon the hotspot: it turnsposition: fixedinside 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.
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.