Skip to content
Nizivo UI

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

collection-circles.css
@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/default when 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 seen from what the shopper has opened.

On this page