Colour Pills
A horizontally scroll-snapping row of colour facets with result counts, single select, and an All pill that clears the selection.
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;
}
}