← Forms

Filter Chips

A row of rounded filter chips. Tap one and it fills with a soft tint and a springy bounce while a tick slides in from the left and draws itself, nudging the label over to make room. Tap it again and the tick slides back out.

Tap the chips to filter.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty element with data-filter-chips, an aria-label, and the chip names in data-chips.
  3. The chips wrap onto new lines to fit the width.

Options

Add any of these attributes to the same element.

data-chipsThe chip names, comma-separated. Or leave it out and put your own <button>s inside.
data-selectedChips that start on, comma-separated.
data-singleOnly one chip can be on at a time.
data-nameAdds a hidden input with this name holding the chosen chips, comma-separated, so they post with a form.
data-colorColour of chosen chips. Default #6a0dad.
data-autoplayIt taps chips on and off by itself while in view, and stops as soon as you use one.

Each chip is a toggle button with aria-pressed, so screen readers hear whether it’s on. Listen for the change event: e.detail.selected is the list of chosen chips.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Filter Chips: a row of rounded filter chips. Tap one and it fills with
    // a soft tint while a tick slides in from the left and draws itself,
    // nudging the label over. Tap again and the tick slides back out.
    // Usage: <div data-filter-chips data-chips="Vegan,Gluten-free,Under 30 min" aria-label="Filters"></div>
    //   Or put your own <button>s inside instead of data-chips.
    // Optional: data-selected (chips that start on, comma-separated),
    // data-single (only one chip on at a time), data-name (adds a hidden input
    // with this name holding the chosen chips, comma-separated, so they post
    // with a form), data-color (default #6a0dad), data-autoplay (it toggles
    // chips by itself while in view; it stops as soon as you use one).
    // Each chip is a toggle button (aria-pressed). Listen for the "change"
    // event: e.detail.selected is the list of chosen chips.
    var DEFAULTS = { color: '#6a0dad' };

    var TICK = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.2 8.4l3 3 6.6-7" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" pathLength="1"/></svg>';

    var css =
      '.fch{display:flex;flex-wrap:wrap;justify-content:center;gap:.5em}' +
      '.fch-chip{position:relative;display:inline-flex;align-items:center;height:2.3em;margin:0;padding:0 .95em;border:1.5px solid #dcdce1;border-radius:2em;background:#fff;color:#3f3f46;' +
      'font:inherit;font-weight:600;white-space:nowrap;cursor:pointer;-webkit-tap-highlight-color:transparent;touch-action:manipulation;' +
      'transition:background-color .3s,border-color .3s,color .3s,padding .32s cubic-bezier(.3,.7,.3,1)}' +
      '.fch-chip:hover{border-color:#b9b9c3}' +
      '.fch-chip:focus-visible{outline:2px solid var(--fch-color);outline-offset:2px}' +
      '.fch-tick{display:inline-grid;place-items:center;width:0;height:1em;margin-right:0;overflow:hidden;opacity:0;transform:translateX(-.5em);' +
      'transition:width .32s cubic-bezier(.3,.7,.3,1),margin .32s cubic-bezier(.3,.7,.3,1),opacity .2s,transform .32s cubic-bezier(.3,.7,.3,1)}' +
      '.fch-tick svg{flex:none;width:1em;height:1em}' +
      '.fch-tick path{stroke-dasharray:1 1;stroke-dashoffset:1;transition:stroke-dashoffset .3s ease-out}' +
      '.fch-on{background:color-mix(in srgb,var(--fch-color) 11%,#fff);border-color:var(--fch-color);color:var(--fch-color);padding-left:.75em}' +
      '.fch-on .fch-tick{width:1em;margin-right:.4em;opacity:1;transform:none}' +
      '.fch-on .fch-tick path{stroke-dashoffset:0;transition-delay:.14s}' +
      '@media (prefers-reduced-motion:reduce){.fch-chip,.fch-tick,.fch-tick path{transition:none}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }
    function list(v) { return (v || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean); }

    function setup(el) {
      var d = el.dataset;
      var mock = !!el.closest('[aria-hidden="true"]');
      var single = on(d.single);
      el.classList.add('fch');
      el.style.setProperty('--fch-color', d.color || DEFAULTS.color);
      var start = list(d.selected);

      // Chips from data-chips, or the element's own buttons.
      var names = list(d.chips);
      if (!names.length) el.querySelectorAll('button, [data-chip]').forEach(function (b) { names.push(b.textContent.trim()); });
      el.textContent = '';
      if (!mock) { el.setAttribute('role', 'group'); }
      var chips = names.map(function (n) {
        var c = document.createElement(mock ? 'span' : 'button');
        c.className = 'fch-chip';
        c.innerHTML = '<span class="fch-tick">' + TICK + '</span><span class="fch-label"></span>';
        c.querySelector('.fch-label').textContent = n;
        if (!mock) c.type = 'button';
        el.appendChild(c);
        return { el: c, name: n, on: false };
      });
      var hidden = null;
      if (!mock && d.name) {
        hidden = document.createElement('input');
        hidden.type = 'hidden';
        hidden.name = d.name;
        el.appendChild(hidden);
      }

      function selected() { return chips.filter(function (c) { return c.on; }).map(function (c) { return c.name; }); }
      function set(chip, v, quiet) {
        if (chip.on === v) return;
        chip.on = v;
        chip.el.classList.toggle('fch-on', v);
        if (!mock) chip.el.setAttribute('aria-pressed', v ? 'true' : 'false');
        if (!quiet && !reduced()) {
          anime.animate(chip.el, { scale: v ? [0.92, 1] : [0.96, 1], duration: v ? 520 : 360, ease: v ? 'outElastic(1, .55)' : 'out(3)' });
        }
      }
      function toggle(chip) {
        var v = !chip.on;
        if (single && v) chips.forEach(function (c) { if (c !== chip) set(c, false); });
        set(chip, v);
        if (hidden) hidden.value = selected().join(',');
        if (!mock) el.dispatchEvent(new CustomEvent('change', { detail: { selected: selected() }, bubbles: true }));
      }
      chips.forEach(function (c) {
        set(c, start.indexOf(c.name) > -1, true);
        if (!mock) {
          c.el.setAttribute('aria-pressed', c.on ? 'true' : 'false');
          c.el.addEventListener('click', function () { stop(); toggle(c); });
        }
      });
      if (hidden) hidden.value = selected().join(',');

      // Autoplay: a pretend finger taps chips on and off.
      var timer = 0, stopped = false;
      var order = [1, 3, 0, 1, 4, 3, 2, 0, 4, 2], oi = 0;
      function stop() { stopped = true; clearTimeout(timer); }
      function next() {
        if (stopped || !chips.length) return;
        toggle(chips[order[oi++ % order.length] % chips.length]);
        timer = setTimeout(next, 900 + Math.random() * 400);
      }
      if (on(d.autoplay) && !reduced()) {
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (!stopped && entries[0].isIntersecting) timer = setTimeout(next, 600);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-filter-chips]').forEach(function (el) {
        if (el._fch) return;
        el._fch = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-filter-chips data-chips="Vegetarian,Vegan,Gluten-free,Under 30 min" data-name="filters" aria-label="Recipe filters"></div>