MODRACXKENNETH D'SILVA

← Archive & Insights

UX Design Principles for High-Converting Ecommerce Stores

A category with 1,811 products offered two filters and converted at 0.31%. The fix was mostly attribute data, not design.

By Kenneth D'SilvaReading Time: 31 min readCategory: UX & Design

1. The Category That Had 1,800 Products And No Way In

A lighting retailer I picked up in March 2024 had a category called Lighting. It held 1,811 products. The only filters were Price and Brand. The default sort was "Position", which in their Magento install meant whatever order the products had been added to the category since 2016.

Their analytics told a clean story once I went looking. 68% of sessions that landed on that category left without opening a single product. Of the ones who did open a product, the median position clicked was 4 — nobody was paging. The category converted at 0.31% against a site average of 1.9%.

The buying team's view was that Lighting was a weak category and they should cut the range. The actual problem was that a customer who wanted a brass pendant light for a kitchen island had no way to express any part of that sentence. Not the material, not the fitting type, not the room, not the drop length. They could filter by "under £50" and by "Dar Lighting", and that was the entire vocabulary the site offered them.

We added six facets, rewrote the default sort, and put a 200-word intro block above the grid with links to four subcategories. Nothing else changed — same products, same prices, same photography. Over the following eight weeks that category went from 0.31% to 1.44%. Revenue from it roughly quadrupled, which sounds like a fabricated case study number and is mostly an artefact of how low the starting point was.

I have also done the opposite. On a tool distributor in 2022 I shipped a facet set with nineteen filters on the main category because the client's product data supported nineteen filters, and mobile filter engagement dropped from 22% to 9%. More vocabulary is not automatically better. Getting this right is a design problem with a data problem underneath it, and most of the work is not in the CSS.

This piece is about the browse and discover half of a storefront: how the taxonomy is shaped, how facets are chosen and built, how internal search behaves, how a product page is ordered, and why accessibility belongs in the design phase rather than in a remediation ticket. The checkout half — forms, guest flows, trust signals, measurement — I have written about separately in designing for conversion at the decision point. The layout mechanics of making any of this work across viewport sizes live in the responsive design piece, and I will not repeat them here.

2. Information Architecture Is A Naming Argument

Every IA project I have run eventually reduces to a room full of people arguing about what to call things, and that argument is the work. It is not a distraction from the work.

The retailer above called a category "Soft Furnishings". Their customers searched for "cushions", "throws", and "curtains". Nobody types "soft furnishings" into a search box unless they work in retail. The category name was correct in the merchandising sense — it matched how the buying team was organised, how the supplier contracts were structured, and how the P&L was reported — and it was useless as a navigation label.

That split shows up constantly. Your internal taxonomy exists to run the business. Your navigation taxonomy exists to match the words in a customer's head. When they are the same tree, one of them is compromised, and it is almost always the customer-facing one because the internal one has finance attached to it.

So I keep them separate. The product's category assignment in the PIM or the platform stays internal and stable. Navigation is a curated layer on top: a set of entry points, each of which resolves to a query rather than to a fixed category node. In Magento that means heavy use of dynamic category rules; in Shopify it means automated collections with condition sets; in a headless setup it means the navigation is a content model in the CMS that emits filter parameters.

// Navigation node as content, not as a category tree node.
// The label is what customers say. The query is what the catalogue understands.
{
  "label": "Cushions",
  "slug": "cushions",
  "intent": "browse",
  "resolves_to": {
    "category": "soft-furnishings",
    "filters": { "product_type": ["cushion", "cushion-cover"] }
  },
  // Editors control what appears above the grid without a deploy.
  "intro_block": "cms/blocks/cushions-intro",
  // Facets are chosen per node, not inherited from the parent category.
  "facet_order": ["colour", "size", "filling", "fabric", "price"]
}

The cost of this is that you now have two things to maintain, and I have watched them drift. On the homeware build, six months in, a navigation node pointed at a product_type value that the PIM had renamed, and the category quietly served zero products for eleven days before anyone noticed. Put a monitor on it. A nightly job that requests every navigation node and alerts if any returns fewer than a threshold number of products costs an hour to write and would have caught that on day one.

3. Card Sorting Without The Workshop Theatre

The standard advice is to run a card sort with users. I have run maybe a dozen and I would now do it differently in most cases, because a moderated card sort with eight participants costs about a week and gives you weaker data than three free sources you already have.

The first is your own site search log. Every query is a person telling you, in their own words, what they wanted and could not find by browsing. Six months of search logs on a mid-size store is tens of thousands of labelled intents. Group them by stem, sort by volume, and the top 200 queries are your navigation vocabulary handed to you.

The second is Search Console query data for the pages you already rank on. Not for SEO purposes — for language. If people arrive on your pendant lighting page from "kitchen island light", that is the phrase, regardless of what the buying team calls it.

The third is your support inbox and any chat transcripts. Slower to mine, but it is where you find the words people use when they are confused, which is different from the words they use when they are confident.

-- Magento: the queries people ran that returned nothing.
-- This is a list of things customers believe you sell and cannot find.
SELECT query_text, num_results, popularity
FROM search_query
WHERE num_results = 0
  AND popularity > 5
  AND updated_at > DATE_SUB(NOW(), INTERVAL 180 DAY)
ORDER BY popularity DESC
LIMIT 200;

-- And the inverse: high volume, high results, low click-through.
-- These are queries where the vocabulary matched but the ranking did not.
SELECT query_text, num_results, popularity
FROM search_query
WHERE num_results > 20 AND popularity > 50
ORDER BY popularity DESC;

I still run a card sort occasionally. The case where it earns its cost is a category you do not sell in yet and have no logs for.

4. Depth, Breadth, And The Three-Click Myth

Someone will tell you that everything must be reachable in three clicks. This has been repeated since the late nineties and there is no evidence for it. What the research actually shows is that people will happily click many times as long as each click feels like progress and the label they clicked matched what they got.

The real constraint is different: at each level, can the person confidently choose one option? A menu of six well-differentiated labels is easier than a menu of three vague ones, even though the six-item menu is wider.

My default shape for a catalogue between roughly 2,000 and 50,000 SKUs is three levels of navigable hierarchy with facets doing the fourth and fifth level of refinement. Department, category, subcategory — then facets. Going deeper than that in the tree produces category pages with forty products on them, which is a worse experience than a filtered view of a bigger page, and it produces a URL structure that is miserable to maintain.

5. Facets: Choosing Which Six

The nineteen-facet mistake I made on the tool distributor taught me the thing I now lead with. The number of facets you display is a design decision constrained by attention, not by data availability. Having the data is a prerequisite, not a reason.

Four questions I put every candidate facet through.

Does the customer know the answer before they arrive? Colour, size, price and brand pass. "Lumens" fails for a domestic lighting buyer and passes for a commercial one. If the customer has to learn what the attribute means before they can filter by it, it is not a facet, it is education, and it belongs in the content on the page.

Does it partition the set usefully? A facet where 94% of products share one value does nothing. A facet where every product has a unique value does nothing either. I want the largest value to hold less than about 60% and the facet to have between three and about twelve values after grouping.

Is the data complete? This is the one that kills facets in practice, and I will come back to it because it deserves its own section.

Does it reflect a real decision or an internal one? "Supplier" is not a facet. "Available for next-day delivery" absolutely is, and it is the single highest-engagement facet I have ever shipped on a trade site.

Order matters as much as selection. The facet a customer is most likely to use goes first, and "first" on mobile means "visible in the panel without scrolling". I sort by measured engagement after launch, but for launch I sort by the frequency of that attribute appearing in search queries — if people type "black" a lot, colour goes to the top.

6. Your Attribute Data Is Worse Than You Think

Every facet project I have run has spent more time on data than on interface, and I now quote it that way explicitly because it is the part that surprises clients.

On the lighting retailer, the colour attribute had 341 distinct values across 12,000 products. Not 341 colours — 341 strings. "Grey", "grey", "Gray", "Light Grey", "Lt Grey", "Charcoal Grey", "Grey/Silver", "grey ". Three of those were the same product family from the same supplier, entered by three different people over four years.

A facet built directly on that field is unusable. You get a filter list 341 items long where each entry matches four products. So you need a mapping layer: a normalised facet value derived from the raw attribute, maintained separately, with an explicit fallback for unmapped values so that new products do not silently vanish from filtered views.

#!/usr/bin/env python3
"""Normalise raw colour strings to a facet vocabulary.

Run nightly. Anything unmapped is reported, not guessed — a silent wrong
mapping is worse than a product that is temporarily unfiltered.
"""
import csv, re, sys, collections

CANON = {
    "grey": {"grey", "gray", "charcoal", "slate", "graphite", "pewter", "ash"},
    "blue": {"blue", "navy", "teal", "indigo", "cobalt", "denim"},
    "neutral": {"cream", "ivory", "beige", "sand", "oatmeal", "stone", "taupe"},
}
# Reverse index once, so the per-product loop is a dict lookup.
LOOKUP = {token: canon for canon, tokens in CANON.items() for token in tokens}

def normalise(raw):
    # Multi-colour products ("Grey/Silver") map to every colour they contain,
    # so a customer filtering "grey" still sees them. This is deliberate:
    # false positives cost a click, false negatives cost the sale.
    tokens = re.split(r"[\s/,&-]+", raw.lower().strip())
    hits = {LOOKUP[t] for t in tokens if t in LOOKUP}
    return sorted(hits)

unmapped = collections.Counter()
for row in csv.DictReader(sys.stdin):
    values = normalise(row["colour"])
    if not values:
        unmapped[row["colour"]] += 1
        continue
    print(f'{row["sku"]}\t{",".join(values)}')

for value, count in unmapped.most_common(50):
    print(f"UNMAPPED {count:5d}  {value!r}", file=sys.stderr)

The rule I enforce after being burned by it: a product missing a facet value must still appear in the unfiltered category. The failure mode where an incomplete attribute silently removes products from the default view is common and expensive, because the default view is where most of your traffic sits.

There is a related governance point. Once you build facets on an attribute, that attribute becomes a customer-facing surface, and it needs a required-field rule in whatever system product data is entered in. Otherwise the facet degrades one product launch at a time and nobody notices until a buyer asks why the new range does not show under "waterproof".

7. Which Control For Which Attribute

The control type is not decoration. It changes what the filter means and how many results the customer expects.

Attribute shapeControlLogicNotes
Few values, multi-select natural (colour, brand)Checkbox listOR within, AND across facetsThe default. Show counts.
Mutually exclusive (gender, condition)Radio or pill groupSingle valueAlways include an explicit "any".
Continuous numeric (price, wattage)Range slider plus typed inputsBetweenSlider alone is unusable with a keyboard and imprecise on touch.
Many values, known vocabulary (brand on a marketplace)Checkbox list with a search boxOR withinType-ahead over values, not over products.
Visual (colour on fashion)Swatch gridOR withinSwatch needs a text label too — colour alone fails WCAG 1.4.1.
Binary merchandising (in stock, on sale)ToggleBooleanHighest engagement per pixel of any control I ship.
Hierarchical (category within category)Nested links, not checkboxesNavigationTreating category as a facet confuses the URL model.

Two opinions I will defend. Price sliders without typed inputs are a persistent accessibility and usability failure — I have watched people on mobile try four times to land on £50 and give up. And result counts next to each value are worth the query cost. They turn a filter list into a map of the inventory, and they prevent the dead-end click that produces an empty grid.

Counts are expensive to compute correctly, because the count for a value within a facet must be calculated with that facet's own selections excluded — otherwise selecting "blue" makes every other colour show zero and the customer cannot switch. Elasticsearch handles this with post-filters and per-facet aggregation scopes, and getting it wrong is the single most common facet bug I am called in to fix.

// Elasticsearch: counts that survive multi-select.
// The colour aggregation is scoped so it ignores the colour filter itself,
// which is what lets a customer add "green" after choosing "blue".
{
  "query": { "bool": { "filter": [
    { "term": { "category_ids": 42 } },
    { "range": { "price": { "lte": 200 } } }
  ]}},
  "post_filter": { "terms": { "colour": ["blue"] } },
  "aggs": {
    "colour_counts": {
      "filter": { "bool": { "filter": [
        { "range": { "price": { "lte": 200 } } }
      ]}},
      "aggs": { "values": { "terms": { "field": "colour", "size": 30 } } }
    },
    "price_counts": {
      "filter": { "bool": { "filter": [
        { "terms": { "colour": ["blue"] } }
      ]}},
      "aggs": { "ranges": { "range": { "field": "price", "ranges": [
        { "to": 50 }, { "from": 50, "to": 150 }, { "from": 150 }
      ]}}}
    }
  },
  "size": 24
}

8. Filter State Belongs In The URL

A filtered view is a place. People bookmark it, share it in WhatsApp, open it in a new tab, and press back expecting to return to it. If your filter state lives only in JavaScript memory, every one of those behaviours breaks, and the back button breaking is the one that costs you money because it happens after a product view.

So filters go in the URL. The question is how, and there are three defensible schemes.

Query parameters

The simplest and my default. ?colour=blue,green&size=large&price=50-150. Readable, trivially parseable, and it composes. Use a consistent separator and sort the values so that the same selection always produces the same URL — otherwise you generate infinite distinct URLs for identical content, which is a crawl budget problem before it is anything else.

Path segments for high-value combinations

Where a filtered view has genuine search demand — "blue velvet cushions" gets searched, "cushions filtered by fill weight" does not — promote it to a real page with a path, its own title, its own copy, and its own place in the sitemap. These are landing pages that happen to be implemented as filters. I typically promote between ten and fifty per site, chosen from keyword data, never generated combinatorially.

Everything else stays noindexed

The combinatorial explosion is real. Six facets with five values each is 15,625 combinations, most of which return two products and none of which anyone searched for. Those URLs must be crawlable for users and invisible to search engines.

<!-- On any filtered view that is not a promoted landing page. -->
<meta name="robots" content="noindex,follow">
<link rel="canonical" href="https://example.com/lighting/pendant-lights/">

<!-- On a promoted combination, it is a page in its own right: -->
<link rel="canonical" href="https://example.com/lighting/brass-pendant-lights/">
<!-- and it gets real copy, not just a heading with the filter values
     interpolated into it, which reads as generated because it is. -->

The pagination interaction is where this gets fiddly. Page two of a filtered view should be noindex,follow as well; rel=prev/next has been ignored by Google since 2019 and I stopped emitting it. What matters more is that every product is reachable from some crawlable path, which is what the "follow" is doing. There is more on how this interacts with the wider crawl surface in the technical SEO and structured data piece.

One implementation detail that matters for perceived speed: update the URL with history.replaceState while the user is toggling filters rapidly, and pushState only when they settle. Otherwise the back button walks through nineteen intermediate filter states and the customer has to hammer it to escape.

9. The Grid Itself

Less to say here than people expect, because the grid is mostly a solved problem and the interesting decisions are about what goes on the card.

A product card should carry: image, name, price, and the one attribute that differentiates products in this category. On fashion that is available colours. On lighting it was fitting type. On the tool distributor it was whether the item was body-only or included a battery, because that was the source of half their returns.

What I leave off: description snippets, which nobody reads at card size; multiple badges, because when everything has a badge nothing does; and hover-swap secondary images, which are invisible on touch and cost you a second image request per card on the devices least able to afford it.

Pagination versus infinite scroll is an argument I have had many times. My position: numbered pagination for category pages, a "load more" button for search results, never automatic infinite scroll on a page that has a footer. Automatic infinite scroll makes the footer unreachable, breaks the back button unless you do significant extra work, and makes it impossible for a customer to describe where they saw something. Load-more is 90% of the benefit with none of that.

10. Sort Defaults Are A Merchandising Decision

The lighting retailer's default sort was insertion order from 2016. I have seen worse: a client whose default was price ascending, which meant every category opened on their cheapest, lowest-margin, worst-reviewed products, and their merchandiser had never questioned it because "customers want cheap".

The default sort is the most valuable piece of real estate on a category page, because most people never change it. Somewhere between 85% and 95% of sessions in every store I have measured keep the default.

What I use as a default is a blended relevance score, computed nightly, not live. Live personalised sorting on a category page is expensive, hurts cacheability, and in the two cases where I have A/B tested it the effect was inside the noise. A nightly job that scores each product in each category on conversion rate, revenue per view, stock depth and recency, and writes a position integer, gets you most of the value and keeps the page cacheable at the edge.

-- Nightly category position score. Deliberately boring.
-- Weights are per-client and argued about; these are a starting point.
WITH stats AS (
  SELECT p.entity_id,
         SUM(oi.qty_ordered)                       AS units_90d,
         SUM(oi.row_total)                         AS revenue_90d,
         COUNT(DISTINCT v.session_id)              AS views_90d,
         MAX(p.created_at)                         AS created_at,
         MAX(s.qty)                                AS stock_qty
  FROM catalog_product_entity p
  LEFT JOIN sales_order_item oi
         ON oi.product_id = p.entity_id
        AND oi.created_at > DATE_SUB(NOW(), INTERVAL 90 DAY)
  LEFT JOIN report_product_viewed v ON v.product_id = p.entity_id
  LEFT JOIN cataloginventory_stock_item s ON s.product_id = p.entity_id
  GROUP BY p.entity_id
)
SELECT entity_id,
       ROUND(
         0.45 * LEAST(revenue_90d / NULLIF(views_90d, 0) / 5, 1)  -- revenue per view, capped
       + 0.25 * LEAST(units_90d / 50, 1)                          -- raw velocity
       + 0.20 * (stock_qty > 5)                                   -- do not lead with near-OOS
       + 0.10 * (created_at > DATE_SUB(NOW(), INTERVAL 30 DAY))   -- newness nudge
       , 4) * 10000 AS position_score
FROM stats
ORDER BY position_score DESC;

Out-of-stock products are the argument you will have with the buying team. My rule: sink them to the bottom rather than removing them, because a customer who finds the item and sees "back in stock 14 March" is a captured email address, and a customer who finds nothing assumes you never stocked it. Remove entirely only when the product is discontinued and not returning, and then 301 the URL to the nearest live equivalent.

11. Search Is A Third Of Your Sessions And Gets None Of The Budget

On the stores I have instrumented, between 18% and 41% of sessions use internal search. Those sessions convert at somewhere between two and six times the rate of browse sessions, because someone who types a query has already decided what they want.

And the search box is usually the last thing anyone works on, running whatever the platform shipped. Magento's default MySQL-backed search until 2.4 was genuinely bad — a LIKE query with relevance scoring that did not understand plurals, let alone synonyms. Shopify's native search improved substantially with the Search & Discovery app but still needs synonym work.

The things that make the largest difference, roughly in order of return on effort.

Synonyms and spelling. "Sofa" and "settee" and "couch". "Trainers" and "sneakers". Product names people misspell. Load these from your zero-result log rather than inventing them — the log tells you exactly which words your customers use that your catalogue does not.

Stemming and plurals. A search for "cushions" must match a product called "Cushion". This sounds trivial and it is where a shocking number of stores fail. Test it on day one.

Attribute-aware queries. "Black leather sofa" should be understood as three constraints, not a bag of words scored against the description. This is the biggest single jump in perceived quality and it requires the same normalised attribute data the facets need, which is why the facet data work pays twice.

Ranking that includes commercial signals. Pure text relevance puts a discontinued 2019 product above your bestseller because the old product's name matched more exactly. Blend in the same conversion signals the category sort uses.

// OpenSearch query template. Text relevance, then commercial reranking.
{
  "query": {
    "function_score": {
      "query": {
        "multi_match": {
          "query": "{{q}}",
          "fields": ["name^6", "sku^8", "brand^3", "colour^2", "description"],
          // fuzziness AUTO gives 1 edit for 3-5 chars, 2 for 6+.
          // Do NOT apply it to sku — a one-character SKU typo is a
          // different product, not a near match.
          "fuzziness": "AUTO",
          "prefix_length": 2,
          "type": "best_fields"
        }
      },
      "functions": [
        { "field_value_factor": {
            "field": "conversion_rate_90d", "factor": 12, "modifier": "ln1p",
            "missing": 0.01 } },
        { "filter": { "term": { "in_stock": false } }, "weight": 0.15 },
        { "filter": { "range": { "created_at": { "gte": "now-30d" } } },
          "weight": 1.15 }
      ],
      "score_mode": "multiply",
      "boost_mode": "multiply"
    }
  },
  "size": 24
}

Note the 0.15 weight rather than a filter on out-of-stock. Demoting by a factor of roughly seven pushes them off page one in almost every case while still letting an exact SKU search find the item.

12. Zero Results Is A Design Failure

An empty search results page that says "No products found for 'settee'" is the site telling a customer they used the wrong word. They will not conclude that. They will conclude you do not sell sofas.

The zero-result page should never be a dead end. What I put on it, in this order: a restatement of the query with a spelling suggestion if one is plausible; results for a relaxed version of the query, clearly labelled as such; the top categories the query's tokens partially matched; and a route to a human.

Relaxation is the mechanically useful part. If "black leather chesterfield sofa" returns nothing, drop the least selective term and try again, then the next, until you get results — and tell the customer what you did. "No exact matches for black leather chesterfield sofa. Showing 34 results for leather sofa."

// Progressive query relaxation. Drop terms by ascending IDF —
// the most common word contributes least, so it goes first.
async function searchWithFallback(tokens, idf) {
  const ordered = [...tokens].sort((a, b) => idf(a) - idf(b));
  let attempt = [...tokens];

  for (let dropped = 0; dropped < ordered.length - 1; dropped++) {
    const hits = await search(attempt.join(' '));
    if (hits.total > 0) {
      return {
        hits,
        relaxed: dropped > 0,
        // Show the customer exactly which constraint was removed. Silent
        // relaxation is worse than none: they think you sell what you don't.
        droppedTerms: tokens.filter(t => !attempt.includes(t)),
      };
    }
    attempt = attempt.filter(t => t !== ordered[dropped]);
  }
  return { hits: { total: 0 }, relaxed: true, droppedTerms: [] };
}

Track the zero-result rate as a headline metric. Under 5% of searches is healthy. The lighting retailer was at 19% when I arrived, and about two thirds of that was vocabulary — words the catalogue simply did not contain anywhere. Adding 140 synonym pairs over two afternoons took it to 6%.

13. Autocomplete Is Ranking, Not Autocomplete

The suggestion dropdown is the highest-traffic ranked list on your site and most implementations treat it as a string prefix match against product names.

What it should return, in a specific order: previously searched queries by this user, popular queries matching the prefix, matching categories, matching brands, and then a small number of products. Products last, and no more than five, because the point of the dropdown is to help someone finish a thought, not to be a second search results page in a 320-pixel-wide box.

Two implementation details that matter more than the ranking. Debounce at around 150 milliseconds, not 300 — 300 feels laggy and 0 hammers your search cluster. And every request must be cancellable, because out-of-order responses produce a dropdown showing results for a prefix the user typed two keystrokes ago, which is one of those bugs that feels like the site is haunted.

// AbortController is the whole fix for out-of-order suggestions.
let controller = null;
let timer = null;

input.addEventListener('input', (e) => {
  const q = e.target.value.trim();
  clearTimeout(timer);
  if (controller) controller.abort();      // kill the in-flight request
  if (q.length < 2) return render([]);

  timer = setTimeout(async () => {
    controller = new AbortController();
    try {
      const r = await fetch(`/search/suggest?q=${encodeURIComponent(q)}`,
                            { signal: controller.signal });
      render(await r.json());
    } catch (err) {
      // AbortError is expected on every keystroke; anything else is real.
      if (err.name !== 'AbortError') reportError(err);
    }
  }, 150);
});

14. The Product Page Is An Argument In A Fixed Order

Once someone opens a product, the page has one job: answer, in descending order of how much they block the decision, the questions standing between them and buying.

The order I use, and I have not found a category where it is badly wrong:

Is this the thing I was looking for — image and name. What does it cost, including delivery — price and the delivery line, together, because a price without a delivery cost is an unfinished sentence. Can I get the version I want — variant selection and stock. When will it arrive — a date, not a shipping class. Is it any good — reviews summary. What exactly is it — specification. What if it is wrong — returns.

The two that get demoted and should not be: delivery cost and delivery date. Both are decision-blocking, both are typically buried in an accordion three screens down or, worse, only revealed at checkout. A store I audited in 2023 had a 41% cart abandonment spike that traced entirely to delivery cost appearing for the first time on the shipping step. Surfacing an estimated delivery cost on the product page, based on a geolocated guess with a clear "based on your location" caveat, took eight points off it.

Long descriptions are where I disagree with most designers. The instinct is to collapse everything into accordions for a tidy page. But the specification table is what a considered purchase runs on, and every accordion is a click plus a decision about whether the click is worth it. My rule is that on considered purchases — anything over about £100, or anything technical — the specification is open by default and the page is long. On impulse categories, collapse away.

15. Variant Selection Breaks More PDPs Than Anything Else

The single most common serious bug I find on product pages: selecting a variant that does not exist and getting either a silent failure, a wrong price, or an add-to-cart that errors after submission.

The problem is combinatorial. A t-shirt in five colours and six sizes has thirty combinations and you probably stock twenty-two of them. The interface must communicate which eight are unavailable, before the customer invests effort in choosing them.

Three approaches, in order of how much I like them. Disable-and-annotate: unavailable combinations are visibly present, greyed, and labelled "out of stock" — the customer learns the shape of your stock. Reorder-on-select: choosing red reduces the size list to sizes available in red, which is cleaner but hides information and makes people think you do not make that size at all. Allow-and-fail: never do this.

// Build an availability map once, client side, from the variant matrix.
// Then every option button knows its state without a round trip.
function buildMatrix(variants) {
  const bySignature = new Map();
  for (const v of variants) {
    // Sorted keys so { colour, size } and { size, colour } hash identically.
    const sig = Object.keys(v.options).sort()
      .map(k => `${k}:${v.options[k]}`).join('|');
    bySignature.set(sig, v);
  }
  return bySignature;
}

// Is any in-stock variant reachable given the current partial selection?
function isReachable(matrix, partial, axis, value) {
  const candidate = { ...partial, [axis]: value };
  for (const v of matrix.values()) {
    const matches = Object.entries(candidate)
      .every(([k, val]) => v.options[k] === val);
    if (matches && v.stock > 0) return true;
  }
  return false;   // grey it out, keep it visible, label it
}

The accessibility side of this is where implementations fall down hardest. Swatches are almost always built as <div> elements with click handlers, which means no keyboard access, no announced state, and no group label. They should be a radiogroup or a set of real buttons with aria-pressed, and an unavailable option should use aria-disabled="true" rather than the disabled attribute, so it stays focusable and a screen reader user can discover that it exists and is out of stock rather than skipping past it silently.

16. Imagery, And What People Actually Look At

The first image does most of the work and the rest are read as evidence. On a homeware or fashion PDP I want, in order: the product on white or a plain ground so its shape is legible; the product in a real setting so scale is legible; a detail shot at material level; something conveying size, either a dimension overlay or a human holding it.

Scale is the thing photography sets most often get wrong and it is the thing returns data most often blames. A rug photographed alone in a studio tells you nothing about whether it fits under a sofa. A dimension diagram is cheap to produce and I have seen it cut size-related returns on furniture by a double-digit percentage.

Zoom behaviour: click-to-open a full-screen viewer, always. Hover-zoom lenses are a desktop-only convention that does nothing on the majority of your traffic, and the pinch-zoom gesture inside a modal is what people already know. Do not disable pinch zoom on the page to make your gallery gesture work — that breaks WCAG 1.4.4 and it breaks it for exactly the people who need to enlarge things.

Alt text on product images is the accessibility item most often skipped and most easily fixed. It should describe the product as photographed, not repeat the product name, which the heading already provides. "Brass pendant light with opal glass shade, photographed above a kitchen island" is useful. "Brass pendant light" is redundant with the H1 six pixels away.

17. Accessibility Is A Design Constraint, Not A Remediation Ticket

I have been on both sides of this. I have shipped storefronts where accessibility was a pass at the end, and I have shipped ones where it was in the component specification, and the difference in cost is roughly an order of magnitude.

The specific pattern: retrofitting accessibility into a finished design usually requires changing the design. Contrast fails, so a brand colour has to move. A custom dropdown cannot be made keyboard-operable without becoming a different control. A filter panel that animates in as an overlay has no focus management and adding it changes the interaction model. Each of those is a conversation with a designer or a brand owner about a decision they consider settled, and those conversations are slow.

Put it in the design phase and it costs almost nothing, because the constraints are trivially satisfiable if you know them before you pick colours.

The constraints that actually bind on an ecommerce design, as opposed to the full WCAG list:

Contrast, 4.5:1 for body text and 3:1 for large text and UI component boundaries. The one that bites is UI components — WCAG 2.1 added 1.4.11, and it means your input borders, your unchecked checkboxes and your focus indicators all need 3:1 against their background. Pale grey borders on white fail. Almost every design system I inherit fails this.

Never colour alone. A red border on an invalid field, a green tick on the selected swatch, a "sale" price shown only in red. Each needs a second signal: text, an icon, a pattern.

Visible focus, on everything, at 3:1. The :focus-visible pseudo-class means you can give keyboard users a strong indicator without a ring appearing on every mouse click, which was the excuse designers used to remove it entirely.

Reflow to 320 CSS pixels at 400% zoom without a horizontal scrollbar. This is 1.4.10 and it is where fixed-width filter sidebars die.

/* Focus that a designer will accept and that passes 1.4.11.
   Two-tone ring so it is visible on both light and dark surfaces. */
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
  outline: 2px solid var(--focus-ink, #0b3d91);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px var(--focus-halo, rgba(255, 255, 255, 0.9));
  border-radius: 3px;
}

/* Windows High Contrast Mode drops box-shadow and most colours.
   Forcing a system colour keeps the ring visible there. */
@media (forced-colors: active) {
  :where(a, button, input, select, textarea, [tabindex]):focus-visible {
    outline: 3px solid Highlight;
  }
}

/* Component boundaries need 3:1 too. #949494 is the lightest
   grey that passes against #ffffff. Anything paler fails. */
:root { --border-min-contrast: #949494; }
.filter-checkbox { border: 1px solid var(--border-min-contrast); }

The business case, since someone always asks. In the UK the Equality Act 2010 applies to services provided online, and the European Accessibility Act's requirements have applied to consumer ecommerce since June 2025. But I would not lead with compliance, because compliance arguments produce minimum-viable accessibility. I lead with the fact that around one in five people has a condition affecting how they use a website, that keyboard and screen-reader users are disproportionately loyal to sites that work, and that every accessibility fix I have shipped has also fixed something for everyone — bigger touch targets, clearer errors, better contrast in sunlight.

18. Focus Management In Filter Panels And Modals

The mobile filter panel is where accessibility work concentrates, because it is a modal, it updates content behind it, and it is usually built as a div that slides in.

What has to happen when it opens: focus moves into the panel; focus is trapped inside it; the background is inert so a screen reader cannot wander into it; escape closes it; and on close, focus returns to the button that opened it. Miss the last one and a keyboard user is dumped at the top of the document, which on a long category page means tabbing through the entire header again.

The inert attribute reached all major browsers in 2023 and made this dramatically simpler. Before that everyone maintained a list of elements whose tabindex had been stashed. Now it is one attribute on the main content wrapper.

const panel  = document.querySelector('#filter-panel');
const main   = document.querySelector('main');
let opener   = null;

function openFilters(trigger) {
  opener = trigger;
  panel.hidden = false;
  main.inert = true;                 // removes background from a11y tree + tab order
  document.body.style.overflow = 'hidden';
  // Focus the heading, not the first control: the panel's purpose is
  // announced before the user is dropped into a list of checkboxes.
  panel.querySelector('h2').focus();
}

function closeFilters() {
  panel.hidden = true;
  main.inert = false;
  document.body.style.overflow = '';
  opener?.focus();                   // the step everyone forgets
}

panel.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') closeFilters();
});

The heading needs tabindex="-1" to be focusable programmatically. That is the one line that makes the pattern work and it is invisible in a design review, which is why it does not get built unless it is in the component spec.

19. Announcing Results That Arrive Without A Page Load

Apply a filter, the grid updates via fetch, and a sighted user sees the change instantly. A screen reader user gets nothing at all. As far as their software is concerned the page did not change, and they are still standing in the filter list with no idea whether anything happened.

The fix is a live region announcing the result count, and it is four lines. It is also, in my experience, absent from about 90% of ecommerce filter implementations, including several built by agencies who put accessibility on their capabilities page.

<!-- Present in the DOM from first paint. A live region injected at the
     same moment as its content is frequently not announced at all. -->
<div id="results-status" role="status" aria-live="polite" aria-atomic="true"
     class="sr-only"></div>

<div id="product-grid" aria-busy="false">
  <!-- cards -->
</div>
const status = document.getElementById('results-status');
const grid   = document.getElementById('product-grid');

async function applyFilters(params) {
  grid.setAttribute('aria-busy', 'true');
  const data = await fetchResults(params);
  grid.innerHTML = data.html;
  grid.setAttribute('aria-busy', 'false');

  // Say what changed AND what is now active. "24 results" alone leaves
  // the user unsure which of their three checkboxes actually applied.
  const active = describeFilters(params);   // "Blue, Large, under £150"
  status.textContent = data.total === 0
    ? `No products match ${active}. Try removing a filter.`
    : `${data.total} products. Filters: ${active}.`;
}

Use role="status" with aria-live="polite" rather than assertive. Assertive interrupts whatever the user is currently reading, which on a filter panel means interrupting them mid-checkbox. Polite queues it. I got this wrong on an early build and a screen reader user told me, accurately, that the site was shouting at her.

20. The Worked Example, Including The Part That Failed

Back to the lighting retailer. Magento 2.4.6, roughly 12,000 SKUs, Elasticsearch 7.17, about 240,000 sessions a month, 61% mobile.

Weeks one and two: measurement. Instrumented facet interactions, sort changes, search usage, zero results, and grid depth. Baseline: 19% zero-result rate, 11% of sessions used any filter, 84% of category exits happened without a product view, median scroll depth on category pages was 1.6 screens.

Weeks three to six: attribute data. The unglamorous majority of the project. 341 colour strings mapped to 14 facet values. Material normalised from free text across 8,400 products, of which about 900 needed a human decision. Room, fitting type and dimension bands created as new attributes and backfilled from a mix of supplier feeds and a contractor working through a spreadsheet for three weeks.

Week seven: facets and sort. Six facets per category, ordered by search-log frequency. Counts on every value. Nightly position score replacing insertion order.

Week eight: search. 140 synonym pairs from the zero-result log. Attribute extraction from queries. Commercial reranking. Autocomplete rebuilt to lead with queries and categories.

Weeks nine and ten: accessibility. Filter panel focus management, live regions, swatch keyboard support, contrast fixes on four brand colours. This is where the schedule slipped, because two of the four colour fixes required brand sign-off and that took eleven days.

Results at twelve weeks, versus the same period the prior year. Zero-result rate 19% to 5.4%. Filter engagement 11% to 34%. Category-to-product click-through up 51%. Search-originated conversion rate up 28%. Overall site conversion rate from 1.9% to 2.4%, and I would attribute maybe two thirds of that to this work, since a delivery proposition change landed in the same window and I will not pretend I can separate them cleanly.

What went wrong. Two things, one small and one not.

The small one: the new facets generated a large number of crawlable filtered URLs before the noindex directives were deployed, because the facet release and the SEO release were separate tickets in separate sprints. Google indexed about 40,000 filter combinations in nine days. Cleaning that up took two months of watching Search Console and a spell of serving 410s. Ship the crawl controls in the same deploy as the facets. There is no version of "we will add the meta robots next sprint" that ends well.

The larger one: I ordered facets globally rather than per category. Colour first everywhere seemed sensible. On the Lighting category, colour is nearly irrelevant and fitting type is the whole decision, and for six weeks lighting had the wrong facet order because I had built a global config with no per-category override. Engagement on lighting facets was less than half the site average until I noticed. The fix was two hours of work and the delay was entirely my failure to look at the per-category numbers rather than the aggregate.

21. Measuring Discovery Without Fooling Yourself

The metrics I actually watch, and why the obvious ones mislead.

Category exit rate without a product view. The single most diagnostic number for browse quality. If someone lands on a category and leaves without opening anything, the grid failed to make a case.

Facet engagement rate, per category. Aggregate hides everything, as I learned. Per category, per facet, and specifically the ratio of panel opens to filter applications on mobile — a high open rate with a low apply rate means people looked at your filters and found nothing worth using.

Zero-result rate and, separately, the low-result rate. Searches returning one to three results are nearly as bad as zero and are invisible if you only track zero.

Search refinement rate. How often a search is immediately followed by another search. High means your first results were wrong. This is the search equivalent of pogo-sticking and it is more honest than click-through rate.

Product page bounce by traffic source. Split it. A PDP bounce from a Google search for the exact product name means something completely different from a PDP bounce from your own category page.

What I do not treat as a goal: time on site, pages per session, scroll depth. All three go up when people cannot find things. A store where discovery works well should see sessions get shorter and convert better, and I have had to talk more than one marketing lead through why their engagement dashboard went the wrong way after a successful project.

For the analytics plumbing behind this — event schemas, sampling, and getting search terms into a warehouse rather than a UI you cannot query — the approach I use overlaps heavily with what I describe in the performance monitoring piece, because it is the same problem of instrumenting a storefront without slowing it down.

22. Personalisation: Where It Earns Its Keep And Where It Does Not

Somebody will want personalised category sorting. My honest position is that it is the most oversold feature in this space and the third or fourth thing you should do, not the first.

Where it works: recently viewed as a strip on the homepage and in the empty state of search. Related products on the PDP driven by co-purchase rather than same-category. Re-ordering for consumables. Location-based stock and delivery messaging, which is not really personalisation but gets sold as it.

Where I have measured no reliable effect: personalised category sort order, personalised facet ordering, and homepage hero personalisation for anonymous first-time visitors, which is most of your traffic and about whom you know essentially nothing.

The cost side is under-discussed. Personalised category output cannot be cached at the edge as a whole page, which means you either fragment the page and hydrate the personalised parts, or you give up full-page caching and accept the origin load. That trade is worth making for a proven uplift and is a bad trade for a hypothesis. I have written more about the mechanics of doing it at the edge without wrecking cache hit rates in the edge personalisation piece, and about the recommendation side in the product recommendations piece.

23. Questions I Get Asked

"How many facets should a category have?" Four to seven visible, with anything else behind a "more filters" control. Below four the panel is not worth opening. Above seven on mobile, engagement drops measurably, which is the nineteen-facet lesson. The right number varies by category and that is an argument for per-category configuration.

"Should filters apply immediately or after an Apply button?" Immediately on desktop, where the grid is visible and the feedback loop is instant. Behind an Apply button on mobile, where the panel covers the grid and each immediate application means a fetch the customer cannot see the result of. This is one of the few places where I genuinely want different behaviour at different sizes rather than one adaptive component.

"Is Algolia worth it over Elasticsearch?" If you have nobody who wants to own a search cluster, yes, comfortably. Algolia's defaults are good, the typo tolerance is better than what you will configure yourself in a week, and the analytics are usable by a merchandiser. If you have the capability in-house and your catalogue is large, Elasticsearch or OpenSearch gives you control over ranking that a hosted product will not, at maybe three times the operational effort. I would put a store under about 50,000 SKUs on a hosted product without much hesitation.

"Do breadcrumbs matter?" Yes, and more for the second reason than the first. As navigation they are used by a minority. As orientation — telling a customer arriving from Google where in your catalogue they have landed — they are one of the highest-value 40 pixels on the page. Mark them up with BreadcrumbList structured data; the SERP display alone justifies it.

"Mega menu or simple dropdown?" Mega menu if you have more than about three levels or want to promote imagery and seasonal entry points. Simple dropdown otherwise. The failure mode with mega menus is not the design, it is the hover behaviour: opening on hover with no intent delay makes the menu flash open as the cursor crosses it. Add a 150 to 250 millisecond intent delay and support keyboard opening with Enter, because a hover-only mega menu is unusable without a mouse.

"How much does accessibility work add to a build?" Designed in from the start, on the order of 3 to 5% of front-end effort. Retrofitted to a finished storefront, I have quoted between 15 and 30% depending on how custom the components are, and the number is higher when brand colours have to change because that adds a decision-maker.

"Should out-of-stock products be in the category grid?" Yes, sorted last, with a clear label and a notify-me capture. The exception is permanently discontinued lines, which should 301 to the nearest replacement. A category page full of unavailable products is worse than a shorter one, so if more than about 20% of a category is out of stock you have a merchandising problem that no sort order will fix.

"Does any of this affect SEO?" The facet URL handling does, substantially, and it is the part most likely to hurt you if done carelessly. Category intro copy does, modestly. Better internal search does not directly, but it produces a zero-result log that is the best keyword research asset you own, which I have argued at more length in the keyword research piece.

24. What I'd Do First

In this order, because each step makes the next one cheaper.

One. Export six months of internal search queries and sort by volume, then by zero-result volume. Read the top two hundred of each. This is a morning's work and it will change your opinion about what your customers call things.

Two. Audit your attribute data before designing any facet. Count distinct values per attribute and completeness per category. Whatever you were planning to build, the data will not support it yet, and finding that out in week one rather than week six is the difference between a schedule and a fiasco.

Three. Fix the default sort. It is a configuration change or a nightly script, it affects the majority of sessions that never touch a control, and it is the highest ratio of impact to effort in this entire article.

Four. Add synonyms from the zero-result log. Two afternoons, no design, no deploy risk on most platforms.

Five. Ship facets — with the noindex and canonical handling in the same release, not the next one.

Six. Rebuild the mobile filter panel with focus management and a live region. If you do nothing else on accessibility, do this, because the filter panel is where the most severe barriers concentrate.

Seven. Put the delivery cost and estimated arrival date on the product page. It is a decision-blocking question you are currently deferring to checkout, and deferring it does not make it go away, it just moves the abandonment to a more expensive point in the funnel.

Eight. Instrument category exit-without-view and facet engagement per category. Then look at them per category, not in aggregate, because the aggregate hid a six-week mistake from me and it will hide one from you.

The thing I would argue hardest against skipping is the second step. Every discovery project I have seen go badly went badly because somebody designed a beautiful facet panel against attribute data that did not exist, and then spent the back half of the schedule doing data entry while the design sat waiting. The interface is the easy part. It has always been the easy part.