By Mario V.W.B.R. Obst · Original learning example · September 2026

Open the full-page example → · Download complete recipe (ZIP)

Try the working example

The preview runs only our supplied example code. Entries stay in the page; the practice form cannot send them. For keyboard testing, use the full-page example.

Build it step by step

  1. Keep every project in the HTML so the collection is readable without JavaScript. Store sortable values in data attributes.
  2. Copy the card list before sorting. Convert years to numbers and use a collator for titles.
  3. Apply the filter and append the existing cards in their new order. The document order now matches the visual order.
  4. Update the count without moving focus away from the controls. Test no matches, mixed-case input, equal years and reset.

Why it works

Filtering decides which cards are visible; sorting decides their order. Both are calculated from the same full collection each time, so a previously hidden card can return. A title tie-breaker makes equal years predictable.

A mistake worth catching

Sorting only the currently visible cards can produce inconsistent results after the filter changes. A CSS order property alone changes visual order without changing the reading order.

Read the complete source

The ZIP also includes the local font and its licence, a README and any illustrations. The source below is the same code used in the preview.

HTML source
HTML · EXAMPLE
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="author" content="Mario V.W.B.R. Obst"><meta name="robots" content="noindex, follow"><meta name="referrer" content="no-referrer">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'none'; form-action 'none'; object-src 'none'; base-uri 'none'">
<title>Cards that filter and sort</title><link rel="stylesheet" href="style.css">
<script src="script.js" defer></script></head>
<body><main><h1>A shelf of small experiments.</h1><div id="controls" hidden><label for="query">Find a project</label><input id="query" type="search" autocomplete="off" placeholder="Try photo or code"><label for="order">Order</label><select id="order"><option value="title">Title A–Z</option><option value="newest">Newest first</option></select><button type="button" id="reset">Reset filter and order</button><p id="count" role="status" aria-atomic="true"></p></div><div class="cards" id="cards"><article class="panel" data-title="Window studies" data-year="2024" data-tags="photo city"><h2>Window studies</h2><p>Patterns found in everyday windows.</p><p>2024 · photo city</p></article><article class="panel" data-title="Tiny markup" data-year="2026" data-tags="code html"><h2>Tiny markup</h2><p>Experiments in readable page structure.</p><p>2026 · code html</p></article><article class="panel" data-title="Footpath notes" data-year="2025" data-tags="travel writing"><h2>Footpath notes</h2><p>Short observations from familiar routes.</p><p>2025 · travel writing</p></article><article class="panel" data-title="Colour walks" data-year="2026" data-tags="photo colour"><h2>Colour walks</h2><p>A neighbourhood recorded one colour at a time.</p><p>2026 · photo colour</p></article></div><noscript><p>All projects are shown. Filtering and sorting need JavaScript.</p></noscript></main></body></html>
CSS source
CSS · EXAMPLE
@font-face{font-family:FYI;src:url("inter.ttf") format("truetype");font-weight:100 900;font-display:swap}
*{box-sizing:border-box}html{color-scheme:dark}body{margin:0;background:#20212a;color:#efedf6;font-family:FYI,sans-serif;line-height:1.6}main{max-width:960px;margin:auto;padding:24px}h1,h2{line-height:1.15}a{color:#ff9b45}button,input,select,textarea{font:inherit;color:inherit;background:#282934;border:1px solid #777582;border-radius:6px;padding:10px}button{cursor:pointer}button:disabled{cursor:default;opacity:.5}:focus-visible{outline:3px solid #ff9b45;outline-offset:4px}label{display:block;margin-top:16px}button{margin:12px 8px 12px 0}p{max-width:70ch}.hint{color:#c7c3d2} [hidden]{display:none!important}.panel{padding:20px;border:1px solid #555361;border-radius:12px;background:#282934}img{max-width:100%;height:auto}code{overflow-wrap:anywhere}input,select,textarea{max-width:100%}a,button{touch-action:manipulation}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,230px),1fr));gap:16px}
JavaScript source
JavaScript · EXAMPLE
const container = document.querySelector('#cards');
const cards = [...container.querySelectorAll('[data-title]')];
const query = document.querySelector('#query');
const order = document.querySelector('#order');
const count = document.querySelector('#count');
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true });
function update() {
  const term = query.value.trim().toLocaleLowerCase('en');
  const sorted = [...cards].sort((a, b) => {
    const alphabetical = collator.compare(a.dataset.title, b.dataset.title);
    return order.value === 'newest' ? Number(b.dataset.year) - Number(a.dataset.year) || alphabetical : alphabetical;
  });
  let visible = 0;
  for (const card of sorted) {
    const text = `${card.textContent} ${card.dataset.tags}`.toLocaleLowerCase('en');
    card.hidden = !text.includes(term);
    if (!card.hidden) visible++;
    container.append(card);
  }
  count.textContent = visible ? `${visible} of ${cards.length} projects shown. ${order.value === 'newest' ? 'Newest first.' : 'Title A–Z.'}` : 'No matches. Clear the filter or try another word.';
}
query.addEventListener('input', update);
order.addEventListener('change', update);
document.querySelector('#reset').addEventListener('click', () => {
  query.value = '';
  order.value = 'title';
  update();
  query.focus();
});
document.querySelector('#controls').hidden = false;
update();

Take it one step further

Add an oldest-first option. Give two projects the same year and explain how the title tie-breaker affects their order.

Check your work

Try a narrow window, 200% zoom and keyboard-only operation. Disable JavaScript to inspect the fallback. For motion, test your reduced-motion preference. Do not use real personal details in learning forms.

Keep exploring

Read the companion guide · Practise finding small mistakes · All code recipes

Technical reference and authorship

Read the underlying platform documentation (new tab). The explanation, composition and example were written for this site; this link is a factual reference, not a source of a copied template. Inter has its own font licence in the download.

Use and adapt the original example for your own learning. For other reuse, see Legal information. About AI assistance.