Collection circles
A row of story-ring links to a store's collections, a cover in a ring and the name under it, that scrolls sideways. A server composition; it ships no JavaScript.
From Figma 10 Instagram Storefront, Collection Circle (7275:19802, with the orphan master
772:4320 the screens use) and the collections rail on the mobile storefront (7275:20748). Uses
Text and the nz-visually-hidden utility of @nizivo/react/css/base.css.
Examples
Collections rail
Figma 10's Collection Circle (7275:19802) in a rail: one link per collection, its cover in a ring and its name under it. A new collection has a thick brand ring and a bold name, and its link says ", new picks" to screen readers; a seen one has a thin grey ring. The list scrolls sideways when it outgrows its container, and each link is a Tab stop, so the keyboard reaches every circle. A server composition: it ships no JavaScript. The cover stands in for the collection's Image.
import type { JSX } from 'react';
import { Text } from '@nizivo/react/text';
interface Collection {
id: string;
label: string;
href: string;
/** The shopper has opened it since its last new pick. */
seen: boolean;
}
const COLLECTIONS: Collection[] = [
{ id: 'mornings', label: 'Slow mornings', href: '/collections/slow-mornings', seen: false },
{ id: 'table', label: 'Set the table', href: '/collections/set-the-table', seen: false },
{
id: 'kiln',
label: 'Fresh from the kiln',
href: '/collections/fresh-from-the-kiln',
seen: true,
},
{ id: 'gifts', label: 'Small gifts', href: '/collections/small-gifts', seen: true },
{ id: 'seconds', label: 'Seconds', href: '/collections/seconds', seen: true },
{ id: 'linen', label: 'Linen', href: '/collections/linen', seen: true },
{ id: 'candles', label: 'Candles', href: '/collections/candles', seen: true },
];
export function CollectionCirclesExample(): JSX.Element {
return (
<ul className="nzp-collection-circles" aria-label="Collections">
{COLLECTIONS.map((collection) => (
<li key={collection.id}>
<a
className={
collection.seen
? 'nzp-collection-circles-link nzp-collection-circles-seen'
: 'nzp-collection-circles-link'
}
href={collection.href}
>
<span className="nzp-collection-circles-ring" aria-hidden="true">
<span className="nzp-collection-circles-cover" />
</span>
<Text
render={<span />}
className="nzp-collection-circles-label"
variant={collection.seen ? 'label-m' : 'label-m-strong'}
tone={collection.seen ? 'secondary' : 'primary'}
>
{collection.label}
</Text>
{!collection.seen && <span className="nz-visually-hidden">, new picks</span>}
</a>
</li>
))}
</ul>
);
}
Stylesheet
@layer nz.tokens, nz.themes, nz.reset, nz.base, nz.components, nz.patterns, nz.utilities;
@layer nz.patterns {
/* One row that scrolls sideways when the circles outgrow it, never wraps. */
.nzp-collection-circles {
display: flex;
gap: var(--nz-space-3);
margin: 0;
padding: var(--nz-space-1);
overflow: auto hidden;
overscroll-behavior-inline: contain;
scroll-snap-type: inline proximity;
list-style: none;
}
.nzp-collection-circles > li {
flex: none;
scroll-snap-align: start;
}
.nzp-collection-circles-link {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--nz-space-2);
inline-size: 5.5rem;
padding-block: var(--nz-space-1);
border-radius: var(--nz-radius-md);
color: inherit;
text-decoration: none;
&:focus-visible {
outline: var(--nz-border-width-focus) solid var(--nz-border-focus);
outline-offset: 2px;
}
}
/* A fixed box: the ring is a shadow inside it, so a new and a seen circle are the same size
(Figma's hugging frame grows 2 px with the thicker ring, figma/10 §3.1). */
.nzp-collection-circles-ring {
display: grid;
box-sizing: border-box;
place-items: center;
inline-size: 5rem;
block-size: 5rem;
border-radius: var(--nz-radius-full);
box-shadow: inset 0 0 0 calc(2 * var(--nz-border-width-default)) var(--nz-border-brand);
}
.nzp-collection-circles-seen .nzp-collection-circles-ring {
box-shadow: inset 0 0 0 var(--nz-border-width-default) var(--nz-border-default);
}
.nzp-collection-circles-cover {
inline-size: 4.5rem;
block-size: 4.5rem;
border-radius: var(--nz-radius-full);
background: var(--nz-bg-muted);
}
/* A long name is cut on one line; the link's accessible name keeps all of it. */
.nzp-collection-circles-label {
max-inline-size: 100%;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (forced-colors: active) {
.nzp-collection-circles-ring {
border: calc(2 * var(--nz-border-width-default)) solid CanvasText;
}
.nzp-collection-circles-seen .nzp-collection-circles-ring {
border-width: var(--nz-border-width-default);
}
}
}
Notes
- New and seen, not by colour alone. Figma's only signal is the ring: brand green when new,
border/defaultwhen seen, and the seen ring is 1.18:1 against the canvas in light mode and 1.39:1 in dark (figma/10 §3.1). Here a new circle also has a ring twice as thick, at 5.9:1 in light and 5.7:1 in dark, and a bold name, and its link says ", new picks" (visually hidden), so the accessible name is "Slow mornings, new picks". A seen circle keeps the thin ring and a regular name in secondary text. - Keyboard. Each circle is one link and one Tab stop. The list scrolls sideways when it
outgrows its container, and a focused link scrolls into view, so the keyboard reaches every
circle without a focusable scroller. The list is a
<ul>named "Collections". - Where it differs from Figma. The ring is a shadow inside a fixed 80 px box, so new and seen
circles are the same size and the rail does not jump when a collection turns seen (Figma's frame
grows 2 px with the thicker ring). The label is cut with an ellipsis on one line; the link's
accessible name keeps the whole name. Marking a collection seen is app state: the app sets
seenfrom what the shopper has opened.
Customer card
Who a customer is, what they are worth and how to reach them, an Avatar, their name and email, their orders and total spent, and their tags. A server composition; it ships no JavaScript.
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.