Design Lounge
Install

Nº 015 of 520

Filter chips product list

A 390px product list with a horizontally scrolling chip row (selected chip grows a check), a sort bottom sheet, a 2-column grid with SVG wishlist hearts, and a shimmer skeleton on every filter change.

Preview

Open full size
390 × 844 · 12.1 KB · 119 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Filter chips product list" (piece mobile-filter-chips-list) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

234 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

A category listing page for a fictional outdoor-gear shop (“Fjell”). A sticky header holds the title, a live item count, a horizontally scrolling row of filter chips and a round sort button. Below is a two-column card grid: tinted product image with an optional SALE badge, a heart button, name, meta line, price (with struck original on sale) and a rating. Changing a chip or sort swaps the grid for four shimmering skeleton cards for 550ms, then fades the real cards in two at a time. The chip that is worth copying: selecting it fills it dark and a 16px check icon animates its width from 0, so the chip visibly grows rather than snapping.

Reference behaviour

  1. Initial state: “All” chip pressed, sort “Featured”, 8 items in a 2-column grid, the “Bre 38L pack” heart already filled. Sheet and scrim hidden.
  2. Tap a chip (e.g. “Boots”): the previously pressed chip loses its fill; the tapped chip animates background --surface → --chip-on, text to --chip-on-ink, and its check icon grows from 0 to 16px wide with a 6px right margin over 160ms. The chip scrolls into view if partially clipped. The grid is replaced by 4 skeleton cards (aria-busy="true"), and 550ms later the filtered cards render with a 6px rise + fade over 300ms; even-numbered cards start 40ms later. The item count updates (“2 items”).
  3. “Sale” filters to items with a sale price; the empty state (“Nothing here yet”) spans both columns if a filter yields nothing.
  4. Tap the sort button: a scrim fades to 45% over 300ms and a bottom sheet slides up from below the 80px reserve over 380ms with the iOS-sheet easing. Focus moves to the checked option.
  5. Tap an option: its check icon scales from .6 to 1 and fades in; the sheet closes; the grid re-skeletons and re-renders sorted. A 7px orange dot appears on the sort button while the sort is not “Featured”.
  6. Tap the scrim or press Escape: the sheet closes and focus returns to the sort button.
  7. Tap a heart: it fills --accent, the stroke turns --accent and the icon pops (scale .7 → 1.25 → 1) over 300ms. Tapping again empties it. Wishlist state survives filter changes.
  8. Scroll the list: the header (title + chips) stays stuck at the top.

Structure

390 × 844 (54px status reserve above, 80px browser bar below)
┌──────────────────────────────────────┐
│ Shell jackets & more        8 items  │ sticky header (bg), h1 26px
│ [(ok) All][Jackets][Boots][Packs][Ten…] ((=)) │ 40px chips · 40px sort btn
├──────────────────────────────────────┤
│ ┌────────────┐  ┌────────────┐       │ 2 cols, 12 gap, 16 gutter
│ │ SALE   (heart)   │  │        (heart)   │       │ image 1:1.1, tint + curve
│ │            │  │            │       │
│ ├────────────┤  ├────────────┤       │
│ │ Ridge 3L…  │  │ Kvist down…│       │ name 14/600
│ │ Jackets·412│  │ Jackets·187│       │ meta 12
│ │ £289  (star)4.8 │  │ £299 £349  │       │ price 15 Syne / rating
│ └────────────┘  └────────────┘       │
│ ┌────────────┐  ┌────────────┐       │
│ …                                    │
├──────────────────────────────────────┤
│ ▲ sheet (on demand), bottom: 80px    │
│  ── grab ──                          │
│  Sort by                             │ 18px Syne
│  Featured                          (ok) │ 52px rows
│  Price: low to high                  │
│  Price: high to low                  │
│  Top rated                           │
└──────────────────────────────────────┘
  • <header class="head"> — position: sticky; top: 0, margin-top: 54px; .title row (<h1>, .count[aria-live=polite]) and .tools row (.chips[role=group] of <button class="chip" aria-pressed> + <button class="sortbtn" aria-haspopup="dialog" aria-expanded>).
  • <main class="grid" aria-live="polite" aria-busy> — 2-column grid of <article class="card">: .img (with --tint, optional .badge, <button class="wish" aria-pressed>), .info (.name, .meta, .row with .price and .rating).
  • Skeleton: four .sk blocks (.a image, .b and .c bars) replace the cards while loading.
  • .scrim (fixed) and .sheet[role=dialog][aria-modal] (fixed at bottom: 80px) with a .grab handle, <h2>, and a role="radiogroup" of <button role="radio" aria-checked> options.

Content

  • Title “Shell jackets & more”; count “8 items”.
  • Chips: All (pressed), Jackets, Boots, Packs, Tents, Sale.
  • Products (name · category · price · rating · reviews · tint · sale price): “Ridge 3L shell jacket” · Jackets · £289 · 4.8 · 412 · #8ea3b8; “Kvist down parka” · Jackets · £349 · 4.6 · 187 · #b8a48e · sale £299; “Tind trail boot” · Boots · £179 · 4.7 · 655 · #8e8b7e; “Skar approach shoe” · Boots · £139 · 4.4 · 230 · #a4b19b · sale £119; “Bre 38L pack” · Packs · £159 · 4.9 · 921 · #c7b6a1 (pre-wishlisted); “Vidde 22L daypack” · Packs · £89 · 4.5 · 304 · #9fb3b4; “Hei 2-person tent” · Tents · £429 · 4.7 · 142 · #b39c9c · sale £379; “Lyng tarp shelter” · Tents · £119 · 4.3 · 88 · #a9a58d.
  • Image: --surface-2 base with a tinted overlay (--tint, 90% opacity) whose bottom-left corner is rounded 46%.
  • Sort options: Featured (default), Price: low to high, Price: high to low, Top rated.
  • Empty state: “Nothing here yet” / “Try another category or clear the sort.”

Tokens

:root {
  /* colour — cool grey-green neutrals, ink chips, vermilion accent */
  --bg: #f3f4f1;
  --surface: #ffffff;         /* cards, chips, sheet */
  --surface-2: #e6e8e2;       /* image base, skeleton */
  --line: #dcdfd7;
  --ink: #16201a;
  --ink-2: #5c665f;           /* rating text, empty copy */
  --ink-3: #8b948d;           /* meta, count, struck price */
  --accent: #e2523a;          /* hearts, sale badge, checks, focus */
  --accent-ink: #ffffff;
  --chip-on: #16201a;
  --chip-on-ink: #f3f4f1;
  --scrim: rgba(22, 32, 26, .45);
  --skeleton-hi: #eef0ea;     /* shimmer highlight */

  /* type */
  --display: "Syne", system-ui, sans-serif;
  --sans: "Manrope", system-ui, sans-serif;

  /* layout */
  --safe-top: 54px;
  --safe-bottom: 80px;
  --gutter: 16px;
  --gap: 12px;
  --chip-h: 40px;
  --wish: 40px;
  --img-ratio: 1 / 1.1;
  --r: 14px;                  /* cards */
  --r-chip: 999px;
  --r-sheet: 20px;
  --shadow-sheet: 0 -12px 40px rgba(22, 32, 26, .18);

  /* motion */
  --t-fast: 160ms;            /* chip fill, check grow, heart colour */
  --t-layout: 300ms;          /* card in, scrim, heart pop */
  --t-sheet: 380ms;
  --t-skeleton: 1200ms;       /* shimmer period */
  --t-load: 550ms;            /* simulated fetch */
  --ease: cubic-bezier(.2, .7, .2, 1);
  --ease-out: cubic-bezier(.16, 1, .3, 1);
  --ease-sheet: cubic-bezier(.32, .72, 0, 1);
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
BodyManrope14px4001.450sentence
Page titleSyne26px7001−0.02emsentence
Item countManrope13px4001.40sentence
ChipManrope14px50010sentence
Product nameManrope14px6001.30sentence, single line ellipsis
MetaManrope12px4001.40sentence
PriceSyne15px7001.2−0.01emtabular numerals
Struck priceManrope12px5001.20numerals
RatingManrope12px40010tabular numerals
Sale badgeSyne10px7001+0.08emUPPERCASE
Sheet titleSyne18px7001.2−0.01emsentence
Sheet optionManrope15px5001.30sentence
Empty titleSyne18px7001.20sentence

Motion

ElementTriggerPropertyFrom → ToDurationEasingNotes
.chippressedbackground, color, borderwhite/ink → --chip-on/--chip-on-ink160ms--ease
.chip svgpressedwidth, opacity, margin-right0, 0, 0 → 16px, 1, 6px160ms--easegrows the chip
.cardrenderopacity, translateY0, 6px → 1, 0300ms--ease-outeven cards +40ms
.sk divloadingbackground-position200% → −200%1200mslinear, infinite3-stop gradient
.scrimsheet openopacity0 → 1 (colour is 45% alpha)300ms--ease
.sheetsheet opentransformtranslateY(calc(100% + 80px)) → 0380ms--ease-sheetvisibility toggles with it
.opt svgcheckedopacity, scale0, .6 → 1, 1160ms--ease-out
.wish svgpressedfill, color, scalenone → --accent; .7 → 1.25 → 1160ms / 300ms--ease-out@keyframes pop
.sortbtn isort ≠ Featuredopacity0 → 1160mslinear7px dot

Reduced motion: all transitions and animations 1ms; the skeleton shimmer is removed (static --surface-2); the 550ms load delay stays.

States

  • Chip pressed: aria-pressed="true", dark fill, check visible. Only one chip pressed at a time.
  • Chip focus-visible / sort / heart / option focus-visible: 2px --accent outline, 2px offset.
  • Sort button dirty: .dirty shows the 7px accent dot at top-right (7px inset).
  • Sort button expanded: aria-expanded="true" while the sheet is open.
  • Heart pressed: aria-pressed="true", path filled and stroked --accent.
  • Grid loading: aria-busy="true", four skeleton cards, count unchanged until render.
  • Grid empty: a single full-width .empty block with title and hint.
  • Sheet open: .on on sheet and scrim, aria-hidden="false"; closed is visibility: hidden.
  • Option checked: aria-checked="true", accent check visible.

Accessibility

  • Chips are <button aria-pressed> inside role="group" aria-label="Filter by category"; the row scrolls horizontally with scroll-snap-type: x proximity and hides its scrollbar but stays keyboard scrollable via Tab through the chips (a pressed chip calls scrollIntoView).
  • The grid is aria-live="polite" with aria-busy toggled around loads so screen readers announce the new count, not every card. The count span is its own polite region.
  • Hearts are 40×40 buttons with aria-label="Save <product>" and aria-pressed.
  • The sheet is role="dialog" aria-modal="true" aria-labelledby; options are role="radio" buttons inside a role="radiogroup". Opening moves focus to the checked option; closing returns it to the sort button; Escape closes; scrim click closes.
  • Sale badge is real text (“SALE”), 10px but bold on --accent (4.9:1 with white).
  • Contrast: --ink-2 on white 6.4:1; --ink-3 on white 3.6:1 used only at 12–13px for meta and count; chip text on --chip-on 15.5:1.
  • Hit targets: chips 40px, sort 40px, hearts 40px, options 52px.

Responsive rules

  • 390 (reference): 2 columns, cards ≈ 173px wide.
  • 360 wide: same grid, cards ≈ 158px; the name line still ellipsises; badge and heart unchanged.
  • ≥ 600: 3 columns; header content capped at 720px centred; sheet becomes a centred 400px card (bottom: auto; top: 50%; transform: translate(-50%, -50%)) and --safe-bottom: 0.
  • ≥ 900: 4 columns.

Acceptance checklist

  • Header is sticky with a 54px top margin; chips row scrolls horizontally with no visible scrollbar.
  • Chips are 40px tall pills; the pressed chip is #16201a with #f3f4f1 text and a 16px check that animates its width from 0 over 160ms.
  • Only one chip is pressed at a time; aria-pressed reflects it.
  • Every filter or sort change shows exactly 4 skeleton cards for 550ms with a 1200ms shimmer, then renders cards with a 300ms rise; the grid carries aria-busy during the load.
  • Item count updates after each render and is a polite live region.
  • The grid is 2 columns with a 12px gap and 16px gutters; images are aspect-ratio: 1 / 1.1.
  • Hearts are 40px SVG buttons with aria-pressed; pressing fills the path #e2523a and pops it (.7 → 1.25 → 1 over 300ms); state survives filtering.
  • Sale items show a 10px “SALE” badge and a struck original price next to the sale price.
  • The sort sheet slides up from translateY(calc(100% + 80px)) over 380ms cubic-bezier(.32,.72,0,1) and sits at bottom: 80px; a 45% scrim fades over 300ms.
  • Sheet options are role="radio"; Escape and scrim close the sheet; focus returns to the sort button.
  • A 7px accent dot marks the sort button whenever the sort is not “Featured”.
  • Nothing fixed occupies the bottom 80px; grid bottom padding is 80px + 24px.
  • Reduced motion removes the shimmer and shortens every transition to ≤ 1ms.

Implementation notes

The chip grows via the icon’s width, not padding. Keep gap: 0 and animate width + margin-right on the SVG so the label shifts right by exactly 22px:

.chip { display: inline-flex; align-items: center; gap: 0; height: 40px; padding: 0 14px 0 12px;
        transition: background var(--t-fast) var(--ease), color var(--t-fast), border-color var(--t-fast); }
.chip svg { width: 0; height: 16px; opacity: 0;
            transition: width var(--t-fast) var(--ease), opacity var(--t-fast), margin var(--t-fast); }
.chip[aria-pressed="true"] { background: var(--chip-on); color: var(--chip-on-ink); border-color: var(--chip-on); }
.chip[aria-pressed="true"] svg { width: 16px; opacity: 1; margin-right: 6px; }

Skeleton swap with a cancellable timer, so rapid chip taps do not stack renders:

let t;
function refresh() {
  clearTimeout(t);
  grid.setAttribute('aria-busy', 'true');
  grid.innerHTML = '<div class="sk"><div class="a"></div><div class="b"></div><div class="c"></div></div>'.repeat(4);
  t = setTimeout(render, 550);
}

Wishlist state lives outside the DOM (a Set of product ids) so re-rendering the grid keeps hearts filled; delegate the click from the grid:

const wished = new Set([4]);
grid.addEventListener('click', e => {
  const w = e.target.closest('.wish'); if (!w) return;
  const id = +w.dataset.i, on = !wished.has(id);
  on ? wished.add(id) : wished.delete(id);
  w.setAttribute('aria-pressed', String(on));
});

Common mistakes: animating padding-left on the chip instead of the icon width (label jitters); binding heart handlers per card and losing them on re-render; forgetting visibility: hidden on the closed sheet so its options stay in the tab order; using scroll-snap-type: x mandatory on the chips row (it fights with scrollIntoView).

Details

Palette
Type
Syne · Manrope
Motion
Subtle motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

Stripe

A control. It studies this site for quiet, not for a show.

All sources