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.
From Figma 10 Instagram Storefront, Shoppable Post (7275:19815, with the orphan master 772:4373
the screens use), Post Badge (772:4419, an orphan master) and the post grid of the mobile
storefront (7275:20803). Uses Badge and the ShoppingBag and Layers
glyphs of @nizivo/icons.
Examples
Post
Figma 10's Shoppable Post (7275:19815): a post's media as one link, with the Post Badge (772:4419) "Shop" and the count of tagged products on it. The badge sits on a dark surface that stays dark in both themes, so its text holds its contrast over any photo. A server composition: it ships no JavaScript. The media block stands in for the post's Image.
import type { JSX } from 'react';
import { Badge } from '@nizivo/react/badge';
import { Layers, ShoppingBag } from '@nizivo/icons';
interface Post {
id: string;
href: string;
/** The post's date as the app prints it (fixed strings here, so the example has no time zone). */
date: string;
/** Products tagged in the post. */
products: number;
/** Photos in the post; more than one makes it a carousel. */
photos: number;
}
/** What the tile's link says: the post, its products and, for a carousel, its photos. */
function postName(post: Post): string {
const products =
post.products === 0
? 'no products'
: `${post.products} ${post.products === 1 ? 'product' : 'products'} to shop`;
const photos = post.photos > 1 ? `, ${post.photos} photos` : '';
return `Post from ${post.date}, ${products}${photos}`;
}
/** One post: a single link over its media, with the Shop badge when it has products. */
function PostTile({ post }: { post: Post }): JSX.Element {
return (
<a className="nzp-shoppable-post" href={post.href}>
<span className="nzp-shoppable-post-media" aria-hidden="true" />
{post.products > 0 && (
<Badge className="nzp-shoppable-post-badge" dot={false} aria-hidden="true">
<ShoppingBag size={16} />
Shop
<span className="nzp-shoppable-post-count">{post.products}</span>
</Badge>
)}
{post.photos > 1 && (
<span className="nzp-shoppable-post-carousel" aria-hidden="true">
<Layers size={16} />
</span>
)}
<span className="nz-visually-hidden">{postName(post)}</span>
</a>
);
}
export function ShoppablePostExample(): JSX.Element {
const post: Post = { id: 'p1', href: '/posts/p1', date: '12 October', products: 3, photos: 1 };
return (
<div className="nzp-shoppable-post-single">
<PostTile post={post} />
</div>
);
}
Post grid
The storefront's grid: three columns of 4:5 tiles with 2 px between them, as on Figma's mobile storefront (7275:20803). A post with several photos shows the layers glyph; a post with no tagged product shows no badge.
import type { JSX } from 'react';
import { Badge } from '@nizivo/react/badge';
import { Layers, ShoppingBag } from '@nizivo/icons';
interface Post {
id: string;
href: string;
/** The post's date as the app prints it (fixed strings here, so the example has no time zone). */
date: string;
/** Products tagged in the post. */
products: number;
/** Photos in the post; more than one makes it a carousel. */
photos: number;
}
const POSTS: Post[] = [
{ id: 'p1', href: '/posts/p1', date: '12 October', products: 3, photos: 1 },
{ id: 'p2', href: '/posts/p2', date: '9 October', products: 1, photos: 4 },
{ id: 'p3', href: '/posts/p3', date: '5 October', products: 2, photos: 1 },
{ id: 'p4', href: '/posts/p4', date: '30 September', products: 0, photos: 1 },
{ id: 'p5', href: '/posts/p5', date: '26 September', products: 4, photos: 3 },
{ id: 'p6', href: '/posts/p6', date: '21 September', products: 1, photos: 1 },
];
/** What the tile's link says: the post, its products and, for a carousel, its photos. */
function postName(post: Post): string {
const products =
post.products === 0
? 'no products'
: `${post.products} ${post.products === 1 ? 'product' : 'products'} to shop`;
const photos = post.photos > 1 ? `, ${post.photos} photos` : '';
return `Post from ${post.date}, ${products}${photos}`;
}
/** One post: a single link over its media, with the Shop badge when it has products. */
function PostTile({ post }: { post: Post }): JSX.Element {
return (
<a className="nzp-shoppable-post" href={post.href}>
<span className="nzp-shoppable-post-media" aria-hidden="true" />
{post.products > 0 && (
<Badge className="nzp-shoppable-post-badge" dot={false} aria-hidden="true">
<ShoppingBag size={16} />
Shop
<span className="nzp-shoppable-post-count">{post.products}</span>
</Badge>
)}
{post.photos > 1 && (
<span className="nzp-shoppable-post-carousel" aria-hidden="true">
<Layers size={16} />
</span>
)}
<span className="nz-visually-hidden">{postName(post)}</span>
</a>
);
}
export function ShoppablePostGridExample(): JSX.Element {
return (
<ul className="nzp-shoppable-post-grid" aria-label="Posts">
{POSTS.map((post) => (
<li key={post.id}>
<PostTile post={post} />
</li>
))}
</ul>
);
}
Stylesheet
@layer nz.tokens, nz.themes, nz.reset, nz.base, nz.components, nz.patterns, nz.utilities;
@layer nz.patterns {
.nzp-shoppable-post-single {
max-inline-size: 18.75rem;
}
/* The whole tile is one link; everything on it is decoration of that link. */
.nzp-shoppable-post {
position: relative;
display: block;
inline-size: 100%;
aspect-ratio: 4 / 5;
overflow: hidden;
border-radius: var(--nz-radius-xs);
color: inherit;
text-decoration: none;
&:focus-visible {
outline: var(--nz-border-width-focus) solid var(--nz-border-focus);
outline-offset: 2px;
}
}
.nzp-shoppable-post-media {
position: absolute;
inset: 0;
background: var(--nz-bg-muted);
}
/* On-media surface: bg/code and text/on-dark are dark and white in both themes, a pair the
tokens' contrast check holds at 17.4:1 (light) and 18.7:1 (dark). Opaque, so the photo under it
cannot lower the contrast (Figma's #141714 at 62 % with text/inverse falls to 1.12:1 in dark
mode, figma/10 §3.2). */
.nzp-shoppable-post-badge {
position: absolute;
inset-block-end: var(--nz-space-2);
inset-inline-start: var(--nz-space-2);
gap: var(--nz-space-1);
background: var(--nz-bg-code);
color: var(--nz-text-on-dark);
}
.nzp-shoppable-post-count {
font: var(--nz-font-label-m-strong);
}
.nzp-shoppable-post-carousel {
position: absolute;
inset-block-start: var(--nz-space-2);
inset-inline-end: var(--nz-space-2);
display: grid;
place-items: center;
inline-size: 1.5rem;
block-size: 1.5rem;
border-radius: var(--nz-radius-full);
background: var(--nz-bg-code);
color: var(--nz-text-on-dark);
}
.nzp-shoppable-post-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--nz-space-0-5);
max-inline-size: 40rem;
margin: 0;
padding: 0;
list-style: none;
}
}
Notes
- Text on media. The badge and the carousel glyph sit on
--nz-bg-codewith--nz-text-on-dark: both stay dark and white in light and dark mode, and the tokens' contrast check holds the pair at 17.4:1 in light and 18.7:1 in dark. The surface is opaque, so no photo under it can lower that. Figma's#141714at 62 % withtext/inversefalls to 1.12:1 in dark mode, becausetext/inverseturns dark there (figma/10 §3.2). The kit has no on-media token yet; this pair stands in for one. - One link per tile. The link's name says what the tile shows: "Post from 12 October, 3 products to shop", and ", 4 photos" for a carousel. The badge and the glyph are hidden from screen readers, so nothing is read twice and nothing in the tile is a second control.
- Where it differs from Figma. No hover-only "Shop 3 items" call to action: Figma shows it only
on hover and hides the count while it does (§3.2), which leaves touch and keyboard users without
it. The badge is always there and the tile has a focus ring. The corner radius is
radius/xs(4 px) for Figma's off-scale 6 px. The carousel glyph gets the same dark backing as the badge, so it shows on a light photo.