Accessibility pattern · Forms and flows
Search filters
Each group of filters is a fieldset with a legend, every option says how many products it would show, and the results change in place while focus stays where you are. The new count is announced once you stop changing filters, applied filters become chips you can remove one at a time, and on a narrow screen the panel folds into a Filters button.
- WCAG criteria
- 7
- Keyboard rules
- 5
- Checked with
- axe, keyboard and the inspector
Live demo
Try it
Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.
Shirts and kurtas
Cotton and linen, made in India.
Applied filters
- White linen shirt Loomcraft ₹1,499
- Navy oxford shirt Nila ₹1,299
- Olive mandarin-collar shirt Monsoon Mill ₹1,199
- Rust camp-collar shirt Banyan ₹999
- Sky blue linen shirt Nila ₹1,549
- Black short kurta Loomcraft ₹899
- White chikankari kurta Monsoon Mill ₹2,299
- Navy long kurta Banyan ₹1,599
- Olive Nehru jacket Loomcraft ₹2,499
- Sky blue oxford shirt Monsoon Mill ₹1,349
- Rust short kurta Nila ₹949
- Black linen shirt Banyan ₹1,449
- White oxford shirt Loomcraft ₹1,299
- Navy mandarin-collar shirt Nila ₹1,249
- Olive camp-collar shirt Monsoon Mill ₹1,049
- Rust long kurta Banyan ₹1,699
- Sky blue chikankari kurta Loomcraft ₹2,199
- Black Nehru jacket Nila ₹2,399
- White camp-collar shirt Banyan ₹949
- Navy short kurta Monsoon Mill ₹849
- Olive long kurta Nila ₹1,649
- Rust oxford shirt Loomcraft ₹1,399
- Sky blue mandarin-collar shirt Banyan ₹1,149
- Black chikankari kurta Monsoon Mill ₹2,349
No products match these filters. Remove one to see more.
The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.
Keyboard
Keys it answers to
Every action works without a pointer.
| Key | What it does |
|---|---|
| Tab or ShiftTab | Moves through the filters, the sort, the chips and the products. Changing a filter never moves focus. |
| Space | On a filter checkbox, adds or removes that filter; the results update at once. |
| Arrow Up or Arrow Down | In a price box, steps the price by ₹100; in Sort by, changes the order. |
| Enter or Space | On a chip, removes that filter and moves focus to the next chip; on Clear all, removes every filter. |
| Enter or Space | On a narrow screen, Filters opens or closes the panel; Show results closes it and moves focus to the count. |
Screen readers
What it announces
Written from the roles, names and states in the markup.
| When | Expected announcement |
|---|---|
| Focus reaches a filter | Colour, group. Navy 4 products, checkbox, not checked |
| Navy and Rust are ticked in quick succession | 8 products (once, after the second) |
| Sort by changes | Sorted by Price: low to high |
| Focus reaches a chip | Remove Navy, button |
| The filters leave nothing | No products match these filters. Remove one to see more. |
| The panel button on a narrow screen | Filters 2, button, collapsed |
These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.
Code
Copy the code
The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.
<div class="ap-filters" data-ap-filters>
<div class="ap-filters__top">
<h3 class="ap-filters__name" id="flt-name">Shirts and kurtas</h3>
<p class="ap-filters__sub">Cotton and linen, made in India.</p>
</div>
<div class="ap-filters__layout">
<div class="ap-filters__side">
<button type="button" class="ap-btn ap-filters__toggle" aria-expanded="false" aria-controls="flt-panel" data-ap-toggle>
<svg class="ap-btn__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 6h16M7 12h10M10 18h4"/></svg>
<span>Filters</span>
<span class="ap-filters__badge" data-ap-applied hidden>0</span>
</button>
<form class="ap-filters__panel" id="flt-panel" aria-labelledby="flt-panel-name" data-ap-panel>
<p class="ap-filters__panel-name" id="flt-panel-name">Filter products</p>
<fieldset class="ap-filters__group">
<legend><span data-ap-group>Colour</span></legend>
<ul class="ap-filters__opts">
<li><label class="ap-filters__opt">
<input type="checkbox" name="colour" value="white" />
<span class="ap-filters__swatch" data-swatch="white" aria-hidden="true"></span>
<span class="ap-filters__opt-name">White</span>
<span class="ap-filters__n"><span data-ap-n>4</span> <span class="ap-filters__vh" data-ap-word>products</span></span>
</label></li>
<li><label class="ap-filters__opt">
<input type="checkbox" name="colour" value="black" />
<span class="ap-filters__swatch" data-swatch="black" aria-hidden="true"></span>
<span class="ap-filters__opt-name">Black</span>
<span class="ap-filters__n"><span data-ap-n>4</span> <span class="ap-filters__vh" data-ap-word>products</span></span>
</label></li>
<li><label class="ap-filters__opt">
<input type="checkbox" name="colour" value="navy" />
<span class="ap-filters__swatch" data-swatch="navy" aria-hidden="true"></span>
<span class="ap-filters__opt-name">Navy</span>
<span class="ap-filters__n"><span data-ap-n>4</span> <span class="ap-filters__vh" data-ap-word>products</span></span>
</label></li>
<li><label class="ap-filters__opt">
<input type="checkbox" name="colour" value="olive" />
<span class="ap-filters__swatch" data-swatch="olive" aria-hidden="true"></span>
<span class="ap-filters__opt-name">Olive</span>
<span class="ap-filters__n"><span data-ap-n>4</span> <span class="ap-filters__vh" data-ap-word>products</span></span>
</label></li>
<li><label class="ap-filters__opt">
<input type="checkbox" name="colour" value="rust" />
<span class="ap-filters__swatch" data-swatch="rust" aria-hidden="true"></span>
<span class="ap-filters__opt-name">Rust</span>
<span class="ap-filters__n"><span data-ap-n>4</span> <span class="ap-filters__vh" data-ap-word>products</span></span>
</label></li>
<li><label class="ap-filters__opt">
<input type="checkbox" name="colour" value="sky" />
<span class="ap-filters__swatch" data-swatch="sky" aria-hidden="true"></span>
<span class="ap-filters__opt-name">Sky blue</span>
<span class="ap-filters__n"><span data-ap-n>4</span> <span class="ap-filters__vh" data-ap-word>products</span></span>
</label></li>
</ul>
</fieldset>
<fieldset class="ap-filters__group" data-ap-prefixed>
<legend><span data-ap-group>Size</span></legend>
<ul class="ap-filters__opts ap-filters__opts--sizes">
<li><label class="ap-filters__opt">
<input type="checkbox" name="size" value="S" />
<span class="ap-filters__opt-name">S</span>
<span class="ap-filters__n"><span data-ap-n>12</span> <span class="ap-filters__vh" data-ap-word>products</span></span>
</label></li>
<li><label class="ap-filters__opt">
<input type="checkbox" name="size" value="M" />
<span class="ap-filters__opt-name">M</span>
<span class="ap-filters__n"><span data-ap-n>22</span> <span class="ap-filters__vh" data-ap-word>products</span></span>
</label></li>
<li><label class="ap-filters__opt">
<input type="checkbox" name="size" value="L" />
<span class="ap-filters__opt-name">L</span>
<span class="ap-filters__n"><span data-ap-n>23</span> <span class="ap-filters__vh" data-ap-word>products</span></span>
</label></li>
<li><label class="ap-filters__opt">
<input type="checkbox" name="size" value="XL" />
<span class="ap-filters__opt-name">XL</span>
<span class="ap-filters__n"><span data-ap-n>18</span> <span class="ap-filters__vh" data-ap-word>products</span></span>
</label></li>
<li><label class="ap-filters__opt">
<input type="checkbox" name="size" value="XXL" />
<span class="ap-filters__opt-name">XXL</span>
<span class="ap-filters__n"><span data-ap-n>7</span> <span class="ap-filters__vh" data-ap-word>products</span></span>
</label></li>
</ul>
</fieldset>
<fieldset class="ap-filters__group">
<legend><span data-ap-group>Brand</span></legend>
<ul class="ap-filters__opts">
<li><label class="ap-filters__opt">
<input type="checkbox" name="brand" value="loomcraft" />
<span class="ap-filters__opt-name">Loomcraft</span>
<span class="ap-filters__n"><span data-ap-n>6</span> <span class="ap-filters__vh" data-ap-word>products</span></span>
</label></li>
<li><label class="ap-filters__opt">
<input type="checkbox" name="brand" value="nila" />
<span class="ap-filters__opt-name">Nila</span>
<span class="ap-filters__n"><span data-ap-n>6</span> <span class="ap-filters__vh" data-ap-word>products</span></span>
</label></li>
<li><label class="ap-filters__opt">
<input type="checkbox" name="brand" value="monsoon" />
<span class="ap-filters__opt-name">Monsoon Mill</span>
<span class="ap-filters__n"><span data-ap-n>6</span> <span class="ap-filters__vh" data-ap-word>products</span></span>
</label></li>
<li><label class="ap-filters__opt">
<input type="checkbox" name="brand" value="banyan" />
<span class="ap-filters__opt-name">Banyan</span>
<span class="ap-filters__n"><span data-ap-n>6</span> <span class="ap-filters__vh" data-ap-word>products</span></span>
</label></li>
</ul>
</fieldset>
<fieldset class="ap-filters__group" data-ap-prefixed>
<legend><span data-ap-group>Price</span> <span class="ap-filters__unit">in rupees</span></legend>
<p class="ap-hint ap-filters__hint" id="flt-price-hint">Prices here run from ₹849 to ₹2,499.</p>
<div class="ap-filters__range">
<div class="ap-field">
<label class="ap-label" for="flt-min">From</label>
<input class="ap-input" id="flt-min" name="min" type="number" inputmode="numeric" min="0" step="100" aria-describedby="flt-price-hint" />
</div>
<span class="ap-filters__dash" aria-hidden="true">–</span>
<div class="ap-field">
<label class="ap-label" for="flt-max">To</label>
<input class="ap-input" id="flt-max" name="max" type="number" inputmode="numeric" min="0" step="100" aria-describedby="flt-price-hint" />
</div>
</div>
<p class="ap-error ap-filters__error" id="flt-price-error" hidden>
<svg class="ap-filters__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg>
<span>The From price must be lower than the To price.</span>
</p>
</fieldset>
<button type="button" class="ap-btn ap-btn--primary ap-filters__done" data-ap-done>Show results</button>
</form>
</div>
<div class="ap-filters__results">
<div class="ap-filters__bar">
<h4 class="ap-filters__count" tabindex="-1" data-ap-count><span data-ap-total>24</span> <span data-ap-total-word>products</span></h4>
<div class="ap-filters__sort">
<label class="ap-label" for="flt-sort">Sort by</label>
<select class="ap-input" id="flt-sort" data-ap-sort>
<option value="featured">Featured</option>
<option value="low">Price: low to high</option>
<option value="high">Price: high to low</option>
<option value="new">Newest first</option>
</select>
</div>
</div>
<div class="ap-filters__applied" data-ap-chips hidden>
<p class="ap-filters__applied-name" id="flt-applied-name">Applied filters</p>
<ul class="ap-filters__chips" aria-labelledby="flt-applied-name" data-ap-chip-list></ul>
<button type="button" class="ap-filters__clear" data-ap-clear-all>Clear all</button>
</div>
<p class="ap-filters__vh" role="status" data-ap-say></p>
<ul class="ap-filters__grid" aria-labelledby="flt-name" data-ap-grid>
<li class="ap-filters__card" data-ap-product data-colour="white" data-brand="loomcraft" data-sizes="S M L XL" data-price="1499" data-rank="9">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="white"><path d="M17 5 9 8 4 17l6 3 3-4v24h22V16l3 4 6-3-5-9-8-3c-1 3-4 5-7 5s-6-2-7-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-1">White linen shirt</a>
<span class="ap-filters__maker">Loomcraft</span>
<span class="ap-filters__price">₹1,499</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="navy" data-brand="nila" data-sizes="M L XL XXL" data-price="1299" data-rank="14">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="navy"><path d="M17 5 9 8 4 17l6 3 3-4v24h22V16l3 4 6-3-5-9-8-3c-1 3-4 5-7 5s-6-2-7-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-2">Navy oxford shirt</a>
<span class="ap-filters__maker">Nila</span>
<span class="ap-filters__price">₹1,299</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="olive" data-brand="monsoon" data-sizes="S M L" data-price="1199" data-rank="3">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="olive"><path d="M17 5 9 8 4 17l6 3 3-4v24h22V16l3 4 6-3-5-9-8-3c-1 3-4 5-7 5s-6-2-7-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-3">Olive mandarin-collar shirt</a>
<span class="ap-filters__maker">Monsoon Mill</span>
<span class="ap-filters__price">₹1,199</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="rust" data-brand="banyan" data-sizes="M L XL" data-price="999" data-rank="18">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="rust"><path d="M17 5 9 8 4 17l6 3 3-4v24h22V16l3 4 6-3-5-9-8-3c-1 3-4 5-7 5s-6-2-7-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-4">Rust camp-collar shirt</a>
<span class="ap-filters__maker">Banyan</span>
<span class="ap-filters__price">₹999</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="sky" data-brand="nila" data-sizes="S M L XL XXL" data-price="1549" data-rank="1">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="sky"><path d="M17 5 9 8 4 17l6 3 3-4v24h22V16l3 4 6-3-5-9-8-3c-1 3-4 5-7 5s-6-2-7-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-5">Sky blue linen shirt</a>
<span class="ap-filters__maker">Nila</span>
<span class="ap-filters__price">₹1,549</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="black" data-brand="loomcraft" data-sizes="S M L XL" data-price="899" data-rank="22">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="black"><path d="M18 4 11 7 6 17l6 3 2-4v26h20V16l2 4 6-3-5-10-7-3c-1 3-3 5-6 5s-5-2-6-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-6">Black short kurta</a>
<span class="ap-filters__maker">Loomcraft</span>
<span class="ap-filters__price">₹899</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="white" data-brand="monsoon" data-sizes="M L XL" data-price="2299" data-rank="6">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="white"><path d="M18 4 11 7 6 17l6 3 2-4v26h20V16l2 4 6-3-5-10-7-3c-1 3-3 5-6 5s-5-2-6-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-7">White chikankari kurta</a>
<span class="ap-filters__maker">Monsoon Mill</span>
<span class="ap-filters__price">₹2,299</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="navy" data-brand="banyan" data-sizes="L XL XXL" data-price="1599" data-rank="11">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="navy"><path d="M18 4 11 7 6 17l6 3 2-4v26h20V16l2 4 6-3-5-10-7-3c-1 3-3 5-6 5s-5-2-6-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-8">Navy long kurta</a>
<span class="ap-filters__maker">Banyan</span>
<span class="ap-filters__price">₹1,599</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="olive" data-brand="loomcraft" data-sizes="M L XL" data-price="2499" data-rank="2">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="olive"><path d="M17 5 10 8 7 16v24h12V18l5 6 5-6v22h12V16l-3-8-7-3-7 9-7-9Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-9">Olive Nehru jacket</a>
<span class="ap-filters__maker">Loomcraft</span>
<span class="ap-filters__price">₹2,499</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="sky" data-brand="monsoon" data-sizes="S M L" data-price="1349" data-rank="16">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="sky"><path d="M17 5 9 8 4 17l6 3 3-4v24h22V16l3 4 6-3-5-9-8-3c-1 3-4 5-7 5s-6-2-7-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-10">Sky blue oxford shirt</a>
<span class="ap-filters__maker">Monsoon Mill</span>
<span class="ap-filters__price">₹1,349</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="rust" data-brand="nila" data-sizes="S M L" data-price="949" data-rank="8">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="rust"><path d="M18 4 11 7 6 17l6 3 2-4v26h20V16l2 4 6-3-5-10-7-3c-1 3-3 5-6 5s-5-2-6-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-11">Rust short kurta</a>
<span class="ap-filters__maker">Nila</span>
<span class="ap-filters__price">₹949</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="black" data-brand="banyan" data-sizes="M L XL XXL" data-price="1449" data-rank="20">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="black"><path d="M17 5 9 8 4 17l6 3 3-4v24h22V16l3 4 6-3-5-9-8-3c-1 3-4 5-7 5s-6-2-7-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-12">Black linen shirt</a>
<span class="ap-filters__maker">Banyan</span>
<span class="ap-filters__price">₹1,449</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="white" data-brand="loomcraft" data-sizes="S M L XL XXL" data-price="1299" data-rank="24">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="white"><path d="M17 5 9 8 4 17l6 3 3-4v24h22V16l3 4 6-3-5-9-8-3c-1 3-4 5-7 5s-6-2-7-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-13">White oxford shirt</a>
<span class="ap-filters__maker">Loomcraft</span>
<span class="ap-filters__price">₹1,299</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="navy" data-brand="nila" data-sizes="M L" data-price="1249" data-rank="5">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="navy"><path d="M17 5 9 8 4 17l6 3 3-4v24h22V16l3 4 6-3-5-9-8-3c-1 3-4 5-7 5s-6-2-7-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-14">Navy mandarin-collar shirt</a>
<span class="ap-filters__maker">Nila</span>
<span class="ap-filters__price">₹1,249</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="olive" data-brand="monsoon" data-sizes="S M L XL" data-price="1049" data-rank="13">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="olive"><path d="M17 5 9 8 4 17l6 3 3-4v24h22V16l3 4 6-3-5-9-8-3c-1 3-4 5-7 5s-6-2-7-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-15">Olive camp-collar shirt</a>
<span class="ap-filters__maker">Monsoon Mill</span>
<span class="ap-filters__price">₹1,049</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="rust" data-brand="banyan" data-sizes="M L XL" data-price="1699" data-rank="4">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="rust"><path d="M18 4 11 7 6 17l6 3 2-4v26h20V16l2 4 6-3-5-10-7-3c-1 3-3 5-6 5s-5-2-6-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-16">Rust long kurta</a>
<span class="ap-filters__maker">Banyan</span>
<span class="ap-filters__price">₹1,699</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="sky" data-brand="loomcraft" data-sizes="S M L" data-price="2199" data-rank="10">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="sky"><path d="M18 4 11 7 6 17l6 3 2-4v26h20V16l2 4 6-3-5-10-7-3c-1 3-3 5-6 5s-5-2-6-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-17">Sky blue chikankari kurta</a>
<span class="ap-filters__maker">Loomcraft</span>
<span class="ap-filters__price">₹2,199</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="black" data-brand="nila" data-sizes="L XL XXL" data-price="2399" data-rank="7">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="black"><path d="M17 5 10 8 7 16v24h12V18l5 6 5-6v22h12V16l-3-8-7-3-7 9-7-9Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-18">Black Nehru jacket</a>
<span class="ap-filters__maker">Nila</span>
<span class="ap-filters__price">₹2,399</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="white" data-brand="banyan" data-sizes="S M" data-price="949" data-rank="19">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="white"><path d="M17 5 9 8 4 17l6 3 3-4v24h22V16l3 4 6-3-5-9-8-3c-1 3-4 5-7 5s-6-2-7-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-19">White camp-collar shirt</a>
<span class="ap-filters__maker">Banyan</span>
<span class="ap-filters__price">₹949</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="navy" data-brand="monsoon" data-sizes="M L XL XXL" data-price="849" data-rank="15">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="navy"><path d="M18 4 11 7 6 17l6 3 2-4v26h20V16l2 4 6-3-5-10-7-3c-1 3-3 5-6 5s-5-2-6-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-20">Navy short kurta</a>
<span class="ap-filters__maker">Monsoon Mill</span>
<span class="ap-filters__price">₹849</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="olive" data-brand="nila" data-sizes="S M L XL" data-price="1649" data-rank="12">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="olive"><path d="M18 4 11 7 6 17l6 3 2-4v26h20V16l2 4 6-3-5-10-7-3c-1 3-3 5-6 5s-5-2-6-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-21">Olive long kurta</a>
<span class="ap-filters__maker">Nila</span>
<span class="ap-filters__price">₹1,649</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="rust" data-brand="loomcraft" data-sizes="M L XL" data-price="1399" data-rank="21">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="rust"><path d="M17 5 9 8 4 17l6 3 3-4v24h22V16l3 4 6-3-5-9-8-3c-1 3-4 5-7 5s-6-2-7-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-22">Rust oxford shirt</a>
<span class="ap-filters__maker">Loomcraft</span>
<span class="ap-filters__price">₹1,399</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="sky" data-brand="banyan" data-sizes="S M L XL" data-price="1149" data-rank="17">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="sky"><path d="M17 5 9 8 4 17l6 3 3-4v24h22V16l3 4 6-3-5-9-8-3c-1 3-4 5-7 5s-6-2-7-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-23">Sky blue mandarin-collar shirt</a>
<span class="ap-filters__maker">Banyan</span>
<span class="ap-filters__price">₹1,149</span>
</li>
<li class="ap-filters__card" data-ap-product data-colour="black" data-brand="monsoon" data-sizes="M L XL" data-price="2349" data-rank="23">
<span class="ap-filters__tile" aria-hidden="true"><svg class="ap-filters__garment" viewBox="0 0 48 44" focusable="false" data-swatch="black"><path d="M18 4 11 7 6 17l6 3 2-4v26h20V16l2 4 6-3-5-10-7-3c-1 3-3 5-6 5s-5-2-6-5Z"/></svg></span>
<a class="ap-filters__product" href="#flt-product-24">Black chikankari kurta</a>
<span class="ap-filters__maker">Monsoon Mill</span>
<span class="ap-filters__price">₹2,349</span>
</li>
</ul>
<p class="ap-filters__empty" data-ap-empty hidden>No products match these filters. Remove one to see more.</p>
</div>
</div>
</div>
/* Shared primitives: buttons and form fields used across the patterns.
Uses the --ap-* design tokens. */
.ap-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
padding: 0 16px;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-sm);
background: var(--ap-surface);
color: var(--ap-text);
font: inherit;
font-weight: 600;
line-height: 1.2;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: background-color var(--ap-duration) var(--ap-ease), border-color var(--ap-duration) var(--ap-ease), box-shadow var(--ap-duration) var(--ap-ease);
}
.ap-btn:hover {
background: var(--ap-surface-2);
}
.ap-btn:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-btn:disabled,
.ap-btn[aria-disabled="true"] {
opacity: .55;
cursor: not-allowed;
}
.ap-btn--primary {
border-color: var(--ap-accent);
background: var(--ap-accent);
color: var(--ap-on-accent);
box-shadow: var(--ap-shadow-sm);
}
.ap-btn--primary:hover {
border-color: var(--ap-accent-hover);
background: var(--ap-accent-hover);
}
.ap-btn--danger {
border-color: var(--ap-danger);
background: var(--ap-danger);
color: var(--ap-surface);
}
.ap-btn--danger:hover {
filter: brightness(1.08);
}
.ap-btn--ghost {
border-color: transparent;
background: transparent;
}
.ap-btn--ghost:hover {
background: var(--ap-surface-2);
}
.ap-btn__icon {
width: 18px;
height: 18px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.ap-field {
display: grid;
gap: 6px;
}
.ap-label {
color: var(--ap-text);
font-weight: 600;
font-size: .9375rem;
}
.ap-hint {
margin: 0;
color: var(--ap-text-3);
font-size: .875rem;
line-height: 1.45;
}
.ap-error {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 0;
color: var(--ap-danger);
font-size: .875rem;
font-weight: 600;
line-height: 1.45;
}
.ap-input {
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-sm);
background: var(--ap-surface);
color: var(--ap-text);
font: inherit;
line-height: 1.4;
transition: border-color var(--ap-duration) var(--ap-ease), box-shadow var(--ap-duration) var(--ap-ease);
}
.ap-input::placeholder {
color: var(--ap-text-3);
}
.ap-input:hover {
border-color: var(--ap-text-2);
}
.ap-input:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 1px;
border-color: var(--ap-focus);
}
.ap-input[aria-invalid="true"] {
border-color: var(--ap-danger);
box-shadow: inset 4px 0 0 var(--ap-danger);
}
textarea.ap-input {
resize: vertical;
min-height: 88px;
}
@media (prefers-reduced-motion: reduce) {
.ap-btn,
.ap-input { transition: none; }
}
/* Search filters. Uses the --ap-* design tokens and the shared primitives. */
.ap-filters {
width: 100%;
color: var(--ap-text);
container-type: inline-size;
}
.ap-filters [hidden] {
display: none;
}
.ap-filters__top {
margin-bottom: 16px;
}
.ap-filters__name {
margin: 0;
font-size: 1.375rem;
font-weight: 700;
letter-spacing: -.01em;
line-height: 1.25;
}
.ap-filters__sub {
margin: 2px 0 0;
color: var(--ap-text-3);
font-size: .9375rem;
}
.ap-filters__layout {
display: grid;
grid-template-columns: 224px minmax(0, 1fr);
align-items: start;
gap: 20px;
}
/* The panel: one fieldset per group, a legend that names it. */
.ap-filters__toggle {
display: none;
}
.ap-filters__badge {
display: inline-grid;
place-items: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: var(--ap-radius-full);
background: var(--ap-accent);
color: var(--ap-on-accent);
font-size: .75rem;
font-weight: 700;
}
.ap-filters__panel {
padding: 6px 14px 14px;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius-lg);
background: var(--ap-surface);
box-shadow: var(--ap-shadow-sm);
}
.ap-filters__panel-name {
margin: 8px 0 2px;
color: var(--ap-text-3);
font-size: .75rem;
font-weight: 650;
letter-spacing: .06em;
text-transform: uppercase;
}
.ap-filters__group {
min-width: 0;
margin: 0;
padding: 12px 0;
border: 0;
}
.ap-filters__group + .ap-filters__group {
border-top: 1px solid var(--ap-border);
}
/* Floated so the legend sits above the options, not in the border. */
.ap-filters__group legend {
float: left;
width: 100%;
margin: 0 0 6px;
padding: 0;
font-size: .875rem;
font-weight: 650;
}
.ap-filters__group legend + * {
clear: both;
}
.ap-filters__unit {
color: var(--ap-text-3);
font-weight: 500;
}
.ap-filters__opts {
display: grid;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.ap-filters__opt {
display: flex;
align-items: center;
gap: 10px;
min-height: 32px;
padding: 4px 6px;
border-radius: var(--ap-radius-sm);
font-size: .9375rem;
cursor: pointer;
}
.ap-filters__opt:hover {
background: var(--ap-surface-2);
}
.ap-filters__opt input {
flex-shrink: 0;
width: 18px;
height: 18px;
margin: 0;
accent-color: var(--ap-accent);
cursor: pointer;
}
.ap-filters__opt input:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-filters__swatch {
flex-shrink: 0;
width: 16px;
height: 16px;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-full);
background: var(--ap-filters-swatch, var(--ap-surface-3));
}
.ap-filters__opt-name {
flex: 1;
min-width: 0;
}
.ap-filters__n {
color: var(--ap-text-3);
font-size: .8125rem;
font-variant-numeric: tabular-nums;
}
/* An option that would leave nothing: dimmer, and its count says 0. */
.ap-filters__opt.is-none .ap-filters__opt-name {
color: var(--ap-text-3);
}
/* Sizes: the same checkboxes, laid out as pills. */
.ap-filters__opts--sizes {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ap-filters__opts--sizes .ap-filters__opt {
gap: 6px;
min-height: 32px;
padding: 4px 10px 4px 8px;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-full);
}
.ap-filters__opts--sizes .ap-filters__opt-name {
flex: none;
font-weight: 600;
}
.ap-filters__opts--sizes .ap-filters__opt:has(input:checked) {
border-color: var(--ap-accent);
background: var(--ap-accent-soft);
color: var(--ap-accent-soft-text);
}
.ap-filters__opts--sizes .ap-filters__opt:has(input:checked) .ap-filters__n {
color: var(--ap-accent-soft-text);
}
.ap-filters__hint {
margin-bottom: 8px;
font-size: .8125rem;
}
.ap-filters__range {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: end;
gap: 8px;
}
.ap-filters__range .ap-label {
font-size: .8125rem;
}
.ap-filters__range .ap-input {
min-height: 40px;
padding: 8px;
font-variant-numeric: tabular-nums;
}
.ap-filters__dash {
padding-bottom: 10px;
color: var(--ap-text-3);
}
.ap-filters__error {
margin-top: 8px;
}
.ap-filters__icon {
width: 16px;
height: 16px;
flex-shrink: 0;
margin-top: 2px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.ap-filters__done {
display: none;
}
/* Results. */
.ap-filters__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 16px;
}
.ap-filters__count {
margin: 0;
font-size: 1rem;
font-weight: 650;
font-variant-numeric: tabular-nums;
border-radius: 4px;
}
.ap-filters__count:focus {
outline: none;
}
.ap-filters__count:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 4px;
}
.ap-filters__sort {
display: flex;
align-items: center;
gap: 8px;
}
.ap-filters__sort .ap-label {
color: var(--ap-text-2);
font-size: .875rem;
white-space: nowrap;
}
.ap-filters__sort .ap-input {
width: auto;
min-height: 40px;
padding: 6px 10px;
}
.ap-filters__applied {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.ap-filters__applied-name {
margin: 0;
color: var(--ap-text-3);
font-size: .8125rem;
font-weight: 600;
}
.ap-filters__chips {
display: contents;
margin: 0;
padding: 0;
list-style: none;
}
.ap-filters__chip {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 32px;
padding: 0 8px 0 12px;
border: 1px solid var(--ap-accent);
border-radius: var(--ap-radius-full);
background: var(--ap-accent-soft);
color: var(--ap-accent-soft-text);
font: inherit;
font-size: .875rem;
font-weight: 600;
cursor: pointer;
}
.ap-filters__chip:hover {
border-width: 2px;
padding: 0 7px 0 11px;
}
.ap-filters__chip:focus-visible,
.ap-filters__clear:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-filters__chip-group {
font-weight: 500;
}
.ap-filters__x {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
}
.ap-filters__clear {
min-height: 32px;
padding: 0 6px;
border: 0;
border-radius: var(--ap-radius-sm);
background: transparent;
color: var(--ap-accent-text);
font: inherit;
font-size: .875rem;
font-weight: 600;
text-decoration: underline;
text-underline-offset: .18em;
cursor: pointer;
}
.ap-filters__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr));
gap: 10px;
margin: 14px 0 0;
padding: 0;
list-style: none;
}
/* A compact card: the garment beside its name, maker and price. */
.ap-filters__card {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
grid-template-rows: auto auto 1fr;
align-items: start;
column-gap: 12px;
padding: 10px;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius);
background: var(--ap-surface);
animation: ap-filters-in var(--ap-duration) var(--ap-ease);
}
.ap-filters__tile {
display: grid;
grid-row: 1 / 4;
place-items: center;
width: 64px;
height: 64px;
border-radius: var(--ap-radius-sm);
background: var(--ap-surface-2);
}
.ap-filters__garment {
width: 42px;
height: 39px;
fill: var(--ap-filters-swatch, var(--ap-surface-3));
stroke: var(--ap-border-strong);
stroke-width: 1.4;
stroke-linejoin: round;
}
.ap-filters__product {
color: var(--ap-text);
font-size: .9375rem;
font-weight: 600;
line-height: 1.35;
text-decoration: none;
border-radius: 2px;
}
.ap-filters__product:hover {
text-decoration: underline;
text-underline-offset: .18em;
}
.ap-filters__product:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-filters__maker {
color: var(--ap-text-3);
font-size: .8125rem;
}
.ap-filters__price {
margin-top: 2px;
font-weight: 650;
font-variant-numeric: tabular-nums;
}
.ap-filters__empty {
margin: 14px 0 0;
padding: 28px 20px;
border: 1px dashed var(--ap-border-strong);
border-radius: var(--ap-radius);
color: var(--ap-text-2);
text-align: center;
}
.ap-filters__vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@keyframes ap-filters-in {
from { opacity: 0; transform: translateY(4px); }
}
/* Narrow: the panel folds under a Filters button. */
@container (max-width: 580px) {
.ap-filters__layout { grid-template-columns: minmax(0, 1fr); gap: 14px; }
.ap-filters__toggle { display: inline-flex; min-height: var(--ap-target); }
.ap-filters__toggle[aria-expanded="true"] { border-color: var(--ap-accent); background: var(--ap-accent-soft); color: var(--ap-accent-soft-text); }
.ap-filters__panel { margin-top: 10px; }
.ap-filters__panel:not(.is-open) { display: none; }
.ap-filters__done { display: flex; width: 100%; min-height: var(--ap-target); margin-top: 6px; }
.ap-filters__grid { gap: 8px; }
}
@media (forced-colors: active) {
.ap-filters__opts--sizes .ap-filters__opt:has(input:checked) { border: 2px solid Highlight; }
.ap-filters__chip { border: 1px solid ButtonText; }
}
@media (prefers-reduced-motion: reduce) {
.ap-filters__card { animation: none; }
}
/**
* Search filters: grouped checkboxes with faceted counts, a price range,
* sort, removable chips, and a result count announced once changes pause.
*
* Markup: [data-ap-filters] holding
* - form[data-ap-panel] with fieldsets of input[type=checkbox][name][value]
* inside label.ap-filters__opt (with span[data-ap-n] and
* span[data-ap-word] for the count), number inputs named min and max, a
* hidden .ap-filters__error, and a Show results button [data-ap-done];
* fieldsets with data-ap-prefixed put their legend's [data-ap-group]
* before the value on a chip;
* - button[data-ap-toggle][aria-controls] that shows the panel on narrow
* screens, with span[data-ap-applied] for the number of filters;
* - select[data-ap-sort], the visible count h4[data-ap-count][tabindex=-1],
* a hidden [data-ap-chips] box with ul[data-ap-chip-list] and
* [data-ap-clear-all], a visually hidden role=status [data-ap-say], and
* ul[data-ap-grid] of
* li[data-ap-product] with data-colour, data-sizes, data-brand,
* data-price and data-rank.
*/
// Product colours are content, like a photo: each swatch is drawn in its own.
const SWATCHES = { white: "#F4F2EC", black: "#1F1F23", navy: "#26335E", olive: "#6B7041", rust: "#B2552E", sky: "#8DB8E2" };
const SAY_AFTER = 700; // ms after the last change
const PRICE_AFTER = 400; // ms after the last key press in a price box
const MATCH = {
colour: (card, value) => card.dataset.colour === value,
size: (card, value) => card.dataset.sizes.split(" ").includes(value),
brand: (card, value) => card.dataset.brand === value,
};
export function init(root) {
const panel = root.querySelector("[data-ap-panel]");
const toggle = root.querySelector("[data-ap-toggle]");
const done = root.querySelector("[data-ap-done]");
const sort = root.querySelector("[data-ap-sort]");
const grid = root.querySelector("[data-ap-grid]");
const featured = [...grid.querySelectorAll("[data-ap-product]")];
const boxes = [...panel.querySelectorAll("input[type=checkbox]")];
const min = panel.elements.namedItem("min");
const max = panel.elements.namedItem("max");
const priceError = panel.querySelector(".ap-filters__error");
const chips = root.querySelector("[data-ap-chips]");
const chipList = root.querySelector("[data-ap-chip-list]");
const clearAll = root.querySelector("[data-ap-clear-all]");
const count = root.querySelector("[data-ap-count]");
const empty = root.querySelector("[data-ap-empty]");
const badge = root.querySelector("[data-ap-applied]");
// Look the live region up each time: a translated page may rebuild it.
const say = () => root.querySelector("[data-ap-say]");
const pending = new Set();
let sayTimer = 0;
let priceTimer = 0;
for (const el of root.querySelectorAll("[data-swatch]")) el.style.setProperty("--ap-filters-swatch", SWATCHES[el.dataset.swatch]);
const ticked = (group) => boxes.filter((b) => b.name === group && b.checked).map((b) => b.value);
function price() {
const lo = min.value === "" ? null : Number(min.value);
const hi = max.value === "" ? null : Number(max.value);
return { lo, hi, bad: lo !== null && hi !== null && lo > hi };
}
// A product passes when it matches every group; skipping one group gives
// that group's counts (what each of its options would add).
function passes(card, skip) {
for (const group of Object.keys(MATCH)) {
if (group === skip) continue;
const values = ticked(group);
if (values.length && !values.some((v) => MATCH[group](card, v))) return false;
}
const { lo, hi, bad } = price();
const p = Number(card.dataset.price);
if (!bad && ((lo !== null && p < lo) || (hi !== null && p > hi))) return false;
return true;
}
function span(text, cls) {
const s = document.createElement("span");
s.textContent = text;
if (cls) s.className = cls;
return s;
}
const rupees = (n) => `₹${n.toLocaleString("en-IN")}`;
const nameOf = (box) => box.closest("label").querySelector(".ap-filters__opt-name").textContent.trim();
const groupOf = (el) => {
const set = el.closest("fieldset");
return set.hasAttribute("data-ap-prefixed") ? set.querySelector("[data-ap-group]").textContent.trim() : "";
};
function chip(key, group, parts) {
const item = document.createElement("li");
const button = document.createElement("button");
button.type = "button";
button.className = "ap-filters__chip";
button.dataset.apKey = key;
button.append(span("Remove", "ap-filters__vh"), " ");
if (group) button.append(span(group, "ap-filters__chip-group"), " ");
button.append(...parts);
button.insertAdjacentHTML("beforeend", `<svg class="ap-filters__x" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 7l10 10M17 7 7 17"/></svg>`);
item.append(button);
return item;
}
function paintChips() {
const items = boxes.filter((b) => b.checked).map((b) => chip(`${b.name}:${b.value}`, groupOf(b), [span(nameOf(b))]));
const { lo, hi, bad } = price();
if (!bad && (lo !== null || hi !== null)) {
let parts;
if (lo !== null && hi !== null) parts = [span(rupees(lo)), " ", span("–"), " ", span(rupees(hi))];
else if (lo !== null) parts = [span("From"), " ", span(rupees(lo))];
else parts = [span("Up to"), " ", span(rupees(hi))];
items.push(chip("price", groupOf(min), parts));
}
chipList.replaceChildren(...items);
chips.hidden = items.length === 0;
badge.textContent = String(items.length);
badge.hidden = items.length === 0;
}
function setCount(numberEl, wordEl, n) {
numberEl.textContent = String(n);
wordEl.textContent = n === 1 ? "product" : "products";
}
function update() {
const { bad } = price();
priceError.hidden = !bad;
for (const input of [min, max]) {
// Keep the hint, and add or take off the error after it.
const ids = (input.getAttribute("aria-describedby") || "").split(/\s+/).filter((id) => id && id !== priceError.id);
if (bad) ids.push(priceError.id);
input.setAttribute("aria-describedby", ids.join(" "));
if (bad) input.setAttribute("aria-invalid", "true");
else input.removeAttribute("aria-invalid");
}
let shown = 0;
for (const card of featured) {
const ok = passes(card);
card.hidden = !ok;
if (ok) shown += 1;
}
setCount(count.querySelector("[data-ap-total]"), count.querySelector("[data-ap-total-word]"), shown);
empty.hidden = shown > 0;
for (const box of boxes) {
const n = featured.filter((card) => passes(card, box.name) && MATCH[box.name](card, box.value)).length;
const label = box.closest("label");
setCount(label.querySelector("[data-ap-n]"), label.querySelector("[data-ap-word]"), n);
label.classList.toggle("is-none", n === 0 && !box.checked);
}
paintChips();
}
// Say the latest result once changes pause, never once per change.
function queueSay(kind) {
pending.add(kind);
clearTimeout(sayTimer);
sayTimer = setTimeout(() => {
const parts = [];
if (pending.has("filter")) {
if (price().bad) parts.push(span(priceError.textContent.trim()));
else if (!empty.hidden) parts.push(span(empty.textContent.trim()));
else parts.push(span(count.querySelector("[data-ap-total]").textContent), " ", span(count.querySelector("[data-ap-total-word]").textContent));
}
if (pending.has("sort")) {
const before = parts.length ? parts[parts.length - 1].textContent : "";
if (before) parts.push(/[.!?]$/.test(before) ? " " : ". ");
parts.push(span("Sorted by"), " ", span(sort.selectedOptions[0].textContent.trim()));
}
pending.clear();
say().replaceChildren();
requestAnimationFrame(() => say().replaceChildren(...parts));
}, SAY_AFTER);
}
function changed() {
update();
queueSay("filter");
}
function onChange(event) {
if (event.target === min || event.target === max) clearTimeout(priceTimer);
changed();
}
function onInput(event) {
if (event.target !== min && event.target !== max) return;
clearTimeout(priceTimer);
priceTimer = setTimeout(changed, PRICE_AFTER);
}
// Enter in a price box applies it at once; nothing is sent.
function onSubmit(event) {
event.preventDefault();
clearTimeout(priceTimer);
changed();
}
function remove(key) {
if (key === "price") {
min.value = "";
max.value = "";
return;
}
const [name, value] = key.split(":");
const box = boxes.find((b) => b.name === name && b.value === value);
if (box) box.checked = false;
}
// The pressed chip goes away: focus moves to the next chip, else the one
// before, else the result count.
function onChipClick(event) {
const button = event.target.closest("[data-ap-key]");
if (!button) return;
const all = [...chipList.querySelectorAll("[data-ap-key]")];
const at = all.indexOf(button);
const nextKey = (all[at + 1] || all[at - 1])?.dataset.apKey;
remove(button.dataset.apKey);
changed();
const next = nextKey && [...chipList.querySelectorAll("[data-ap-key]")].find((b) => b.dataset.apKey === nextKey);
(next || count).focus();
}
function onClearAll() {
for (const box of boxes) box.checked = false;
remove("price");
changed();
count.focus();
}
// Reorders the products in the document, so reading order matches the screen.
function onSort() {
const list = [...featured];
if (sort.value === "low") list.sort((a, b) => a.dataset.price - b.dataset.price);
else if (sort.value === "high") list.sort((a, b) => b.dataset.price - a.dataset.price);
else if (sort.value === "new") list.sort((a, b) => a.dataset.rank - b.dataset.rank);
grid.append(...list);
queueSay("sort");
}
function setOpen(open) {
toggle.setAttribute("aria-expanded", String(open));
panel.classList.toggle("is-open", open);
}
const onToggle = () => setOpen(toggle.getAttribute("aria-expanded") !== "true");
const onDone = () => {
setOpen(false);
count.focus();
};
panel.addEventListener("change", onChange);
panel.addEventListener("input", onInput);
panel.addEventListener("submit", onSubmit);
sort.addEventListener("change", onSort);
chipList.addEventListener("click", onChipClick);
clearAll.addEventListener("click", onClearAll);
toggle.addEventListener("click", onToggle);
done.addEventListener("click", onDone);
update();
return () => {
clearTimeout(sayTimer);
clearTimeout(priceTimer);
panel.removeEventListener("change", onChange);
panel.removeEventListener("input", onInput);
panel.removeEventListener("submit", onSubmit);
sort.removeEventListener("change", onSort);
chipList.removeEventListener("click", onChipClick);
clearAll.removeEventListener("click", onClearAll);
toggle.removeEventListener("click", onToggle);
done.removeEventListener("click", onDone);
};
}
for (const root of document.querySelectorAll("[data-ap-filters]")) init(root);
/* Design tokens for the pattern components. Light by default, dark when the
system asks for it; set data-theme="dark" on :root to force dark. */
:root {
--ap-radius-sm: 8px;
--ap-radius: 12px;
--ap-radius-lg: 16px;
--ap-radius-full: 999px;
--ap-font: "Instrument Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--ap-mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace;
--ap-ease: cubic-bezier(.2, .8, .2, 1);
--ap-duration: 180ms;
--ap-target: 44px;
--ap-bg: #F4F4F5;
--ap-surface: #FFFFFF;
--ap-surface-2: #F4F4F5;
--ap-surface-3: #E4E4E7;
--ap-border: #E4E4E7;
--ap-border-strong: #76767F;
--ap-text: #18181B;
--ap-text-2: #3F3F46;
--ap-text-3: #5E5E66;
--ap-accent: #4F46E5;
--ap-accent-hover: #4338CA;
--ap-on-accent: #FFFFFF;
--ap-accent-text: #4338CA;
--ap-accent-soft: #EEF2FF;
--ap-accent-soft-text: #3730A3;
--ap-focus: #4F46E5;
--ap-danger: #B91C1C;
--ap-danger-soft: #FEF2F2;
--ap-success: #15803D;
--ap-success-soft: #F0FDF4;
--ap-warning: #A15C07;
--ap-warning-soft: #FEFCE8;
--ap-info: #1D4ED8;
--ap-info-soft: #EFF6FF;
--ap-scrim: rgb(9 9 11 / .48);
--ap-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-light-bg: #F4F4F5;
--ap-light-surface: #FFFFFF;
--ap-light-surface-2: #F4F4F5;
--ap-light-surface-3: #E4E4E7;
--ap-light-border: #E4E4E7;
--ap-light-border-strong: #76767F;
--ap-light-text: #18181B;
--ap-light-text-2: #3F3F46;
--ap-light-text-3: #5E5E66;
--ap-light-accent: #4F46E5;
--ap-light-accent-hover: #4338CA;
--ap-light-on-accent: #FFFFFF;
--ap-light-accent-text: #4338CA;
--ap-light-accent-soft: #EEF2FF;
--ap-light-accent-soft-text: #3730A3;
--ap-light-focus: #4F46E5;
--ap-light-danger: #B91C1C;
--ap-light-danger-soft: #FEF2F2;
--ap-light-success: #15803D;
--ap-light-success-soft: #F0FDF4;
--ap-light-warning: #A15C07;
--ap-light-warning-soft: #FEFCE8;
--ap-light-info: #1D4ED8;
--ap-light-info-soft: #EFF6FF;
--ap-light-scrim: rgb(9 9 11 / .48);
--ap-light-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-light-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-light-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-dark-bg: #09090B;
--ap-dark-surface: #18181B;
--ap-dark-surface-2: #27272A;
--ap-dark-surface-3: #3F3F46;
--ap-dark-border: #2E2E33;
--ap-dark-border-strong: #8E8E97;
--ap-dark-text: #FAFAFA;
--ap-dark-text-2: #D4D4D8;
--ap-dark-text-3: #A1A1AA;
--ap-dark-accent: #818CF8;
--ap-dark-accent-hover: #A5B4FC;
--ap-dark-on-accent: #0C0A1F;
--ap-dark-accent-text: #A5B4FC;
--ap-dark-accent-soft: #1E1B4B;
--ap-dark-accent-soft-text: #C7D2FE;
--ap-dark-focus: #A5B4FC;
--ap-dark-danger: #F87171;
--ap-dark-danger-soft: #2A1215;
--ap-dark-success: #4ADE80;
--ap-dark-success-soft: #0F2A1A;
--ap-dark-warning: #FACC15;
--ap-dark-warning-soft: #2A2410;
--ap-dark-info: #60A5FA;
--ap-dark-info-soft: #0F1D33;
--ap-dark-scrim: rgb(0 0 0 / .62);
--ap-dark-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-dark-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-dark-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
}
:root[data-theme="dark"] {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
WCAG 2.2
What it meets
The success criteria this pattern takes care of, and how.
-
1.3.1 Info and Relationships
Level A
Each group is a fieldset with a legend, so a checkbox is announced with its group, and the price boxes have their own labels.
-
2.4.3 Focus Order
Level A
Filtering never moves focus; removing a chip moves it to the next chip, and clearing everything moves it to the result count.
-
3.2.2 On Input
Level A
Ticking a filter changes the results in place, not the page or the focus, so it is not a change of context.
-
3.3.1 Error Identification
Level A
A price range that cannot match anything is named in text beside the boxes, and announced instead of a count.
-
4.1.2 Name, Role, Value
Level A
The narrow-screen Filters button reports expanded or collapsed and names the panel it controls; each chip is a button named Remove and its filter.
-
4.1.3 Status Messages
Level AA
The result count is announced from a polite live region, once changes pause, without moving focus.
-
2.5.8 Target Size (Minimum)
Level AA
Checkbox rows and chips are at least 24 pixels high, with the whole row as the target.
Usage
When to use it
Use it
- Lists of more than a screenful that people narrow by known attributes: products, listings, jobs, documents.
- Facets where seeing how many results an option leaves helps people choose.
Use something else
- Short lists that fit on one screen: filters add work there.
- Filters that each need a server round trip and a new page: then use a form with an Apply button, so nothing changes until people ask.
- Free-text questions: a search field answers those better.
Common failures
How it usually goes wrong
Moving focus to the results on every change
Jumping to the grid after each tick means tabbing back to the panel for the next filter. Focus stays put; the count is announced instead.
Announcing every change straight away
Ticking three boxes quickly would read three counts over each other. The announcement waits until changes stop, then says the latest count once.
Checkboxes with no group name
"M, checkbox" tells nobody it is a size. A fieldset and legend add the group, so it is read as Size, M.
Chips that say only the filter
A button named "Navy ×" does not say what pressing it does. Each chip is named Remove Navy.
Focus lost when a chip is removed
The pressed chip disappears, and focus falls to the top of the page. Here it moves to the next chip, or to the result count when none are left.
Options that lead to nothing, unannounced
Without counts, people tick a box and find zero results. Each option shows how many products it would leave.
Notes
Building it
- Counts are faceted: each option's number is what you would see if you added it to the filters in the other groups. They update with every change.
- The visible count changes at once; a separate, visually hidden polite region speaks it 700 milliseconds after the last change, and is emptied first so the same number can be heard again.
- Below 580 pixels of width (a container query, not a viewport one) the panel becomes a disclosure under a Filters button that shows how many filters are applied.
- Sorting reorders the products in the document, not with CSS order, so the reading and Tab order match what is on screen.
- Products link to #… anchors in this demo. A real listing also writes the filters into the URL, so a filtered list can be shared and the back button works.
Sources: WAI Tutorials: Grouping controls · Understanding SC 4.1.3: Status Messages · WAI-ARIA Authoring Practices: Disclosure
Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction