Components

Colour Pills

A horizontally scroll-snapping row of colour facets with result counts, single select, and an All pill that clears the selection.

Built for Loom Cairo

In the product this writes the selection into the page URL so a filtered view is shareable and the back button works. That is removed here, because a component embedded in a docs page has no business rewriting the URL of the page around it. The visible behaviour is otherwise unchanged, and the facet counts are frozen from the real catalogue.

Features

  • Single select by design: colour is a browse mode rather than a narrowing filter, and tapping the active pill turns it off so the row can never trap a state.
  • The row scrolls rather than wraps, so a longer facet list cannot push the results grid down by a whole row.
  • Proximity scroll snapping stops a pill coming to rest half cut off at the container edge.
  • Every pill is a real button with a pressed state, so the row is keyboard operable and announces itself.

Usage

import { ColorPills } from "@/components/showcase/color-pills/color-pills";

<ColorPills />

Source

The files this component is made of, as they run above. Copy them in, or take the whole page as markdown from the toolbar.

color-pills.tsx

"use client";

import { useCallback, useState } from "react";

import { COLOR_FACETS } from "./colors";
import "./color-pills.css";

/* The colour facet row from the Univyr marketplace search page: a horizontally
   scrolling, snapping strip of pills, single-select, with an "All" pill that
   clears the selection.

   Single-select rather than multi-select is deliberate. Colour is the one
   facet shoppers use as a browse mode rather than a narrowing filter: they
   want to see the black things, then the green things, not the intersection.
   Tapping the pill that is already on turns it off, so the row never traps you
   in a state you have to find the reset for.

   URL state, and why this copy does not have it
   --------------------------------------------
   The source drove this off the query string. It read `?colors=` with
   `useSearchParams` and wrote back with `window.history.pushState`, using
   pushState rather than a router push so the pill lit up immediately and the
   server component re-rendered the grid without a navigation. It also reset
   `?page=1` on every change, because a colour that has three pages of results
   will not have a page four.

   This showcase copy holds the same value in `useState` instead. It must not
   write to the page URL: it is one component among many on a portfolio page,
   and a facet row that rewrites the address bar would fight the page it is
   embedded in. The visible behaviour is identical, only the storage moved.
   There is no page reset here because there is no pager to reset. */

export function ColorPills() {
  const [selected, setSelected] = useState<string | null>(null);

  const toggleColor = useCallback((color: string) => {
    /* Selecting the active colour clears it, which is what makes the row feel
       like a set of toggles rather than a radio group you cannot escape. */
    setSelected((current) => (current === color ? null : color));
  }, []);

  const selectAllColors = useCallback(() => {
    setSelected(null);
  }, []);

  return (
    /* `text-body-sm` on the wrapper, which the pills' `font-size: inherit`
       then picks up. In the product that inherit reached the app's body size;
       embedded in a docs page there was nothing above it declaring one, so it
       resolved to the user agent's 16px with `letter-spacing: normal` and the
       row came out a step larger and visibly looser than the 17px paragraph
       directly above it. 15px is the rung this site sets embedded controls at,
       and it carries the -0.007em the rest of the page is tracked to. */
    <div
      className="color-pills-wrapper text-body-sm"
      role="group"
      aria-label="Filter by colour"
    >
      <button
        type="button"
        className={`color-pills-pill ${selected ? "" : "color-pills-pill-selected"}`}
        aria-pressed={selected === null}
        onClick={selectAllColors}
      >
        All
      </button>

      {COLOR_FACETS.map((color) => (
        <button
          key={color.name}
          type="button"
          className={`color-pills-pill ${
            color.name === selected ? "color-pills-pill-selected" : ""
          }`}
          aria-pressed={color.name === selected}
          onClick={() => toggleColor(color.name)}
        >
          {color.name} <span className="color-pills-count">({color.count})</span>
        </button>
      ))}
    </div>
  );
}

colors.ts

/* In the marketplace this array arrived as part of the search response's
   `metadata`, which is why the shape is `{ name, count }`: the counts are
   facet counts, the number of items that would survive the filter.

   The showcase copy has no network, so one representative response is frozen
   here. The counts are the real ones from a Univyr catalogue snapshot, kept
   uneven on purpose because a facet row where every count is round stops
   looking like data. */

export type ColorFacet = {
  name: string;
  count: number;
};

export const COLOR_FACETS: readonly ColorFacet[] = [
  { name: "black", count: 1482 },
  { name: "white", count: 1137 },
  { name: "blue", count: 604 },
  { name: "grey", count: 471 },
  { name: "beige", count: 338 },
  { name: "brown", count: 296 },
  { name: "green", count: 241 },
  { name: "pink", count: 187 },
  { name: "red", count: 153 },
  { name: "navy", count: 129 },
  { name: "cream", count: 96 },
  { name: "yellow", count: 74 },
  { name: "purple", count: 58 },
  { name: "orange", count: 41 },
  { name: "silver", count: 27 },
];

color-pills.css

/* Lifted from the Univyr marketplace. In the source these rules lived in a
   styled-jsx block inside the component; they are a plain colocated
   stylesheet here so the component does not depend on styled-jsx, and every
   selector is prefixed so nothing leaks into the host page.

   Colour note: the source read `--clr-gray-background`, `--clr-loom-lilac`,
   `--clr-neutral-900`, `--clr-neutral-100` and `--fw-regular` from the app's
   globals.css. The portfolio does not define those tokens, so the values are
   carried literally here and scoped to the component. */

.color-pills-wrapper {
  display: flex;
  gap: 0.5rem;
  margin-block: 1rem;

  /* A facet row is always wider than its container, so it scrolls rather than
     wrapping: wrapping would push the grid below it down by a whole row the
     moment one more colour came back from the API. */
  overflow-x: auto;
  scrollbar-width: none;

  /* Snapping keeps a pill from coming to rest half cut off at the edge. */
  scroll-snap-type: x proximity;

  /* The source computed this from the page grid so the row bled to the
     viewport edge while its first pill still lined up with the content
     column. The showcase copy has no page grid, so it is a flat gutter. */
  padding-inline: 1rem;
}

.color-pills-wrapper::-webkit-scrollbar {
  display: none;
}

.color-pills-pill {
  height: 40px;
  padding-inline: 1rem;
  padding-block: 0.5rem;
  flex-shrink: 0;
  scroll-snap-align: start;
  text-transform: capitalize;
  background-color: #e4e4e4;
  color: rgb(0 0 0);
  font-weight: 500;
  font-size: inherit;
  line-height: 1.5;
  font-family: inherit;
  border: 0;
  border-radius: 10px;
  cursor: pointer;
  user-select: none;

  /* Only the colours move. Transitioning `all` here would also animate the
     scroll-snap reflow, which reads as lag on the tap. */
  transition:
    background-color 150ms ease,
    color 150ms ease;
}

.color-pills-pill-selected {
  background-color: #a6a2de;
  color: rgb(255 255 255);
}

.color-pills-count {
  /* The count is a secondary read: it qualifies the label rather than
     competing with it, so it steps back in weight on both pill states. */
  font-weight: 400;
  opacity: 0.7;
}

@media (prefers-reduced-motion: reduce) {
  .color-pills-pill {
    transition: none;
  }
}