Product tag
Hotspots on a post's photo, each a named button that opens a Popover with the product's name, price and link.
From Figma 10 Instagram Storefront, Product Tag (7275:19846, with the orphan master 772:4390
the screens use) on the mobile post view's media (7275:20923). Uses
Popover, Button,
NumberValue, Text and the Tag and
ArrowRight glyphs of @nizivo/icons.
Examples
Tagged photo
Figma 10's Product Tag (7275:19846) on a post's photo: each dot is a button named for its product ("Product: Moss mug") that opens a Popover with the name, the price and a link to the product. Escape or a press outside closes it, and focus returns to the dot. The dots sit at fractions of the photo's size, so they stay on their products at any width. The Popover is a client component; the photo stays server-rendered. The media block stands in for the post's Image.
import type { ComponentPropsWithRef, JSX } from 'react';
import { Button } from '@nizivo/react/button';
import { NumberValue } from '@nizivo/react/number-value';
import { Popover } from '@nizivo/react/popover';
import { Text } from '@nizivo/react/text';
import { ArrowRight, Tag } from '@nizivo/icons';
/** Money as the store's currency: Intl formats it, never string concatenation. */
const USD = { style: 'currency', currency: 'USD' } as const;
/** Stands in for the app's router link (Next `Link`, React Router `Link`). */
function Link({ children, ...props }: ComponentPropsWithRef<'a'>): JSX.Element {
return <a {...props}>{children}</a>;
}
interface ProductTag {
id: string;
name: string;
price: number;
href: string;
/** Where the product sits on the photo, 0 to 1 from the start edge and from the top. */
x: number;
y: number;
}
/** Sorted by y, then x, so the Tab order follows the reading order of the photo. */
const TAGS: ProductTag[] = [
{ id: 'vase', name: 'Ash bud vase', price: 34, href: '/products/ash-bud-vase', x: 0.68, y: 0.3 },
{ id: 'mug', name: 'Moss mug', price: 28, href: '/products/moss-mug', x: 0.32, y: 0.58 },
];
/** One hotspot: a named button on the photo that opens the product's name, price and link. */
function Hotspot({ tag }: { tag: ProductTag }): JSX.Element {
return (
<div
className="nzp-product-tag-spot"
style={{ insetInlineStart: `${tag.x * 100}%`, insetBlockStart: `${tag.y * 100}%` }}
>
<Popover.Root>
<Popover.Trigger className="nzp-product-tag-dot" aria-label={`Product: ${tag.name}`}>
<span className="nzp-product-tag-mark" />
</Popover.Trigger>
<Popover.Popup side="end" className="nzp-product-tag-popup">
<Popover.Title level={3}>{tag.name}</Popover.Title>
<Popover.Description>
<Text render={<span />} variant="numeric-m" tone="primary">
<NumberValue value={tag.price} format={USD} />
</Text>
</Popover.Description>
<Button
size="sm"
variant="primary"
trailingIcon={<ArrowRight />}
render={<Link href={tag.href} />}
>
View product
</Button>
</Popover.Popup>
</Popover.Root>
</div>
);
}
export function ProductTagExample(): JSX.Element {
return (
<figure className="nzp-product-tag" aria-label="Products tagged in this photo">
<span className="nzp-product-tag-media" aria-hidden="true" />
{TAGS.map((tag) => (
<Hotspot key={tag.id} tag={tag} />
))}
<figcaption className="nzp-product-tag-hint">
<Tag size={16} />
Tap a dot to shop
</figcaption>
</figure>
);
}
Stylesheet
@layer nz.tokens, nz.themes, nz.reset, nz.base, nz.components, nz.patterns, nz.utilities;
@layer nz.patterns {
/* The photo and its hotspots: the media fills the box, each spot is placed in % of it. */
.nzp-product-tag {
position: relative;
margin: 0;
max-inline-size: 23.75rem;
aspect-ratio: 4 / 5;
overflow: hidden;
border-radius: var(--nz-radius-lg);
}
.nzp-product-tag-media {
position: absolute;
inset: 0;
background: var(--nz-bg-muted);
}
/* A zero-size anchor at the product's point; the dot is centred on it. */
.nzp-product-tag-spot {
position: absolute;
inline-size: 0;
block-size: 0;
}
/* The Popover's own trigger: 24 px, and 44 px of hit area on a coarse pointer (its nz-hit-target).
On-media pair, as the hint: bg/code and text/on-dark stay dark and white in both themes. */
.nzp-product-tag-dot {
position: absolute;
display: grid;
box-sizing: border-box;
place-items: center;
inline-size: 1.5rem;
block-size: 1.5rem;
margin-block-start: -0.75rem;
margin-inline-start: -0.75rem;
padding: 0;
border: 0;
border-radius: var(--nz-radius-full);
background: var(--nz-bg-code);
box-shadow: var(--nz-elevation-2);
color: var(--nz-text-on-dark);
cursor: pointer;
&:focus-visible {
outline: var(--nz-border-width-focus) solid var(--nz-border-focus);
outline-offset: 2px;
}
}
.nzp-product-tag-mark {
inline-size: 0.625rem;
block-size: 0.625rem;
border-radius: var(--nz-radius-full);
background: currentcolor;
}
.nzp-product-tag-popup {
min-inline-size: 12rem;
}
.nzp-product-tag-hint {
position: absolute;
inset-block-end: var(--nz-space-3);
inset-inline-start: var(--nz-space-3);
display: inline-flex;
align-items: center;
gap: var(--nz-space-1-5);
margin: 0;
padding-block: var(--nz-space-1);
padding-inline: var(--nz-space-2) var(--nz-space-3);
border-radius: var(--nz-radius-full);
background: var(--nz-bg-code);
color: var(--nz-text-on-dark);
font: var(--nz-font-label-m);
}
@media (forced-colors: active) {
.nzp-product-tag-dot {
border: var(--nz-border-width-default) solid ButtonText;
}
.nzp-product-tag-mark {
background: ButtonText;
}
}
}
Notes
- A real button, named. Each dot is the Popover's own trigger, a
<button>named "Product: Moss mug" that says whether its popover is open. It is 24 px, and the trigger's hit area grows to 44 px on a coarse pointer, without changing the dot (Figma's dot is 24 px, undersize/touch-min, §3.3). The photo is a<figure>named "Products tagged in this photo", its caption the hint, and the dots are in reading order (by y, then x), so Tab moves through them as the eye does. - Open, close, focus. Opening moves focus to the product link; Escape or a press outside closes it and focus returns to the dot. Only the popover is client code: the photo stays server-rendered. Pair the photo with the Tagged product list, which gives the same products without the photo.
- Where it differs from Figma. The popover is the kit's Popover surface with a title, the price
in tabular figures and a link, not Figma's dark chip: the chip's price is
text/tertiaryonbg/inverse, 4.03:1, under AA (§3.3). The Popover moves to another side when the end side has no room, where Figma always opens to the right. The dot and the hint use the on-media pair of the Shoppable post (--nz-bg-codewith--nz-text-on-dark), so they read on any photo in both themes. There is no pulse: an endless animation needs a reduced motion story the kit does not have yet. Positions are fractions of the photo (0 to 1), so a tag stays on its product at any size.
Shoppable post
A post's media as one link, with a Shop badge that counts its tagged products and reads in both themes, alone and in the storefront's grid. A server composition; it ships no JavaScript.
Tagged product
The products in a post, each a row with a thumbnail, its name as a link, variant, price and a named add-to-bag IconButton.