GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

9 Filter UI Patterns (Panel Slide to Chip Remove)

Filter ui hides several small jobs. One of nine recomputes chip-row width once a chip disappears, one scales a badge by the active-filter count's change,

Auto-plays · click a tile to jump to its section · all nine in one zip

Every category, chip label, and price shown across the nine screens is fictional — there is no real product catalog, no real brand name, and no live API call anywhere in the set. Listener-wise, seven of the nine respond to something a person actually does: 01 (panel toggle) and 06 (tree expand, plus a resize listener) fire on click, 02 (chip remove) delegates a click to each chip's remove button, 04 (clear-all cascade) fires on the clear button's click, 07 (sticky apply-bar) listens for change on two checkboxes, 08 (mobile sheet drag) tracks real pointerdown/pointermove/pointerup/pointercancel events, and 09 (price-range fill) listens for input on both its min and max handles. Items 03 (result count morph) and 05 (badge pulse) run purely on a setTimeout loop cycling through a fixed value array, with no click, drag, or pointer listener attached — every item still autoplays once for the demo, but only those seven also answer a real interaction.

Anatomy of a filter ui — what nine screens compute

Nine screens split cleanly into three ways of turning a filter change into a number a browser can animate, and a filter ui design that skips this step usually just snaps between states instead of moving through them.

Why three screens recompute a dimension from a count or a gap between values

02, 06, and 09 each start from how many things there are, or how far apart two values sit, and turn that into a pixel dimension: 02 re-sums the widths and gaps of the chips still on screen into a new row width, 06 multiplies its visible child-item count by one item's height into a new list height, and 09 turns the distance between its min and max handle values into a fraction of the track's total length.

Why three screens step a position from a boolean state instead of a continuous input

01, 04, and 07 don't read a count or a distance — they read a plain true-or-false state and interpolate a position from it: 01's panel position depends only on whether it is open, 07's apply-bar position depends only on whether at least one filter is selected, and 04 recomputes each chip's disappear time as its index times a fixed interval once a single clear-all boolean fires.

Why three screens map a changing number directly onto a visual property

03, 05, and 08 all take a number that is already changing and map it onto something visible with almost no translation: 03 steps a displayed count from its old value to its new one, 05 scales a badge by the size of the count's change before easing back, and 08 maps a pointer's drag distance straight onto a sheet's height with a snap-point interpolation only on release.

Code per screen

01Panel slide toggle — a basic panel interpolating position from an open state

Once the panel is in its open state, its off-screen horizontal position interpolates in to its on-screen value, and the closed state retraces the same value in reverse.

Panel slide toggle — a basic panel interpolating position from an open state
Open in a new tab

01 · Panel slide toggle — a basic panel interpolating position from an open state — Measured area 41.968% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

transformclassList.toggletransition

What the numbers say

Across 23 tracked frames the panel changes on 10 of them, covering 41.968% cumulative area at a 163.3 average intensity — the highest average intensity of the nine, since one wide surface crossing the frame moves more pixels per step than several small ones. The panel's own track is min(64vw, 200px) wide, so the same transform works down to a narrow phone viewport without a separate breakpoint.

(function () {
  var stage = document.getElementById('stage');
  var trigger = document.getElementById('trigger');
  var panel = document.getElementById('panel');
  if (!stage || !trigger || !panel) return;

  function setOpen(open) {
    stage.classList.toggle('is-open', open);
    trigger.setAttribute('aria-expanded', String(open));
    panel.setAttribute('aria-hidden', String(!open));
  }

  trigger.addEventListener('click', function () {
    panel.classList.remove('is-demo');
    setOpen(!stage.classList.contains('is-open'));
  });
})();

02Chip remove collapse — a row recomputing container width from remaining chip count

Once a chip is removed, the widths and gaps of the remaining chips are re-summed into a new container width, and the whole row narrows to that value.

Chip remove collapse — a row recomputing container width from remaining chip count
Open in a new tab

02 · Chip remove collapse — a row recomputing container width from remaining chip count — Measured area 5.569% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

getBoundingClientRecttransitiongap

What the numbers say

The row changes on 11 of 23 frames, a 5.569% cumulative area at 149.5 average intensity — a small footprint because only the row's own width shrinks, not the surrounding page. recompute() runs the same re-summing after both a real removal and the autoplay loop's own add/remove cycle, so anyone browsing filter ui examples sees identical math whichever path triggered it.

  function removeChip(chip) {
    if (!chip) return null;
    var label = chip.dataset.label || '';
    chip.remove();
    recompute();
    return label;
  }

  function addChip(label) {
    var chip = buildChip(label);
    row.appendChild(chip);
    recompute();
  }

  row.addEventListener('click', function (e) {
    var btn = e.target.closest('.chip__x');
    if (!btn) return;
    removeChip(btn.closest('.chip'));
  });

03Result count morph — a number interpolating between its old and new value

When the result count changes after a filter update, the display steps from the old count to the new one in even increments instead of jumping.

Result count morph — a number interpolating between its old and new value
Open in a new tab

03 · Result count morph — a number interpolating between its old and new value — Measured area 1.142% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramesetPropertytextContent

What the numbers say

This screen touches 18 of 23 frames yet only 1.142% cumulative area — a text node redrawing digits moves far fewer pixels than a panel sliding, so the low area is the shape of a number counting up, not a weak effect. VALUES cycles through four fixed counts, and each step rounds the eased position between the last count and the next one for display.

  function animate(to, duration) {
    var start = null;
    function frame(ts) {
      if (start === null) start = ts;
      var t = Math.min(1, (ts - start) / duration);
      var value = Math.round(from + (to - from) * ease(t));
      el.textContent = value + ' items';
      el.style.setProperty('--pulse', (1 - t).toFixed(3));
      if (t < 1) {
        requestAnimationFrame(frame);
      } else {
        from = to;
        setTimeout(step, HOLD_MS);
      }
    }
    requestAnimationFrame(frame);
  }

  function step() {
    idx = (idx + 1) % VALUES.length;
    animate(VALUES[idx], STEP_MS);
  }

  setTimeout(step, HOLD_MS);

04Clear-all cascade — chips vanishing in order by index times interval

Once a clear-all state fires, each chip's disappear-start time is recomputed by multiplying its order by a fixed interval, so they vanish left to right.

Clear-all cascade — chips vanishing in order by index times interval
Open in a new tab

04 · Clear-all cascade — chips vanishing in order by index times interval — Measured area 4.054% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

animation-delayclassList.removeopacity

What the numbers say

12 of 23 frames move, a 4.054% cumulative area at 74.0 average intensity, the second-lowest intensity of the nine because each chip only fades and shrinks in place rather than traveling far. STAGGER_MS is 90ms in the shipped build; the variants section below covers what happens at 200ms.

(function () {
  var STAGGER_MS = 90;
  var chips = document.getElementById('chips');
  var clearBtn = document.getElementById('clearBtn');
  if (!chips || !clearBtn) return;

  clearBtn.addEventListener('click', function () {
    chips.classList.remove('is-demo');
    var items = chips.querySelectorAll('.chip');
    items.forEach(function (chip, i) {
      setTimeout(function () {
        chip.classList.remove('is-active');
      }, i * STAGGER_MS);
    });
  });
})();

05Active-count badge pulse — a badge scaling by its change amount then settling back

When the active-filter count changes, the badge scales up by an amount proportional to the size of that change, then eases back to its resting size.

Active-count badge pulse — a badge scaling by its change amount then settling back
Open in a new tab

05 · Active-count badge pulse — a badge scaling by its change amount then settling back — Measured area 0.34% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

transformscale(transitionend

What the numbers say

This is the smallest footprint of the nine — 0.34% cumulative area at 88.0 average intensity across 14 of 23 frames — and that is structural, not a flaw: a single-digit badge simply has fewer pixels to move than a chip row or a panel. The scale-up-then-settle is driven entirely by a transitionend listener, so a browser tab throttled in the background still lands on the correct resting size instead of getting stuck mid-scale.

  function grow() {
    idx = (idx + 1) % VALUES.length;
    badge.textContent = String(VALUES[idx]);
    badge.style.transform = 'scale(1.4)';
  }

  badge.addEventListener('transitionend', function (e) {
    if (e.propertyName !== 'transform') return;
    if (badge.style.transform === 'scale(1.4)') {
      badge.style.transform = 'scale(1)';
    } else {
      setTimeout(grow, HOLD_MS);
    }
  });

06Category tree expand — a list recomputing height from child count

Once a category is expanded, the list's total height is recomputed by multiplying its child-item count by a single item's height, then grows to that value.

Category tree expand — a list recomputing height from child count
Open in a new tab

06 · Category tree expand — a list recomputing height from child count — Measured area 16.82% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

scrollHeightsetPropertyaria-expanded

What the numbers say

11 of 23 frames move, a 16.82% cumulative area at 140.5 average intensity, roughly four times item 02's footprint since a full list height is a larger surface than one row narrowing. measure() reads list.scrollHeight into a --tree-height custom property both when it opens and on a window resize, so a rotated tablet gets a freshly measured height instead of a stale cached one.

(function () {
  var toggle = document.getElementById('toggle');
  var list = document.getElementById('list');
  if (!toggle || !list) return;

  function measure() {
    list.style.setProperty('--tree-height', list.scrollHeight + 'px');
  }
  measure();

  toggle.addEventListener('click', function () {
    list.classList.remove('is-demo');
    measure();
    var open = list.classList.toggle('is-open');
    toggle.setAttribute('aria-expanded', String(open));
  });

  window.addEventListener('resize', measure);
})();

07Sticky apply-bar reveal — a bottom bar repositioned by a selection-state value

Once at least one filter is selected, the bottom apply bar's vertical position interpolates up from off-screen, and it hides again once nothing is selected.

Sticky apply-bar reveal — a bottom bar repositioned by a selection-state value
Open in a new tab

07 · Sticky apply-bar reveal — a bottom bar repositioned by a selection-state value — Measured area 19.0% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

transformtranslateY(classList.toggle

What the numbers say

12 of 23 frames move, a 19.0% cumulative area at 130.7 average intensity — close to item 01's panel, since both move one large surface the full length of its own track. sync() runs on every checkbox change event and only ever reads whether any input is checked, so adding a tenth filter option needs no change to the reveal logic itself.

(function () {
  var stage = document.getElementById('stage');
  var bar = document.getElementById('bar');
  var inputs = document.querySelectorAll('.option__input');
  if (!stage || !bar || !inputs.length) return;

  function sync() {
    bar.classList.remove('is-demo');
    var any = Array.prototype.some.call(inputs, function (input) {
      return input.checked;
    });
    stage.classList.toggle('has-selection', any);
  }

  inputs.forEach(function (input) {
    input.addEventListener('change', sync);
  });
})();

08Mobile sheet drag — a bottom sheet mapping drag distance to its height

While the sheet is being dragged, the pointer's travel distance maps directly onto the sheet's height, and on release it interpolates to the nearest snap-point value.

Mobile sheet drag — a bottom sheet mapping drag distance to its height
Open in a new tab

08 · Mobile sheet drag — a bottom sheet mapping drag distance to its height — Measured area 44.842% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointerdownclamp(transform

What the numbers say

08 moves on all 23 of 23 tracked frames — the only item that never sits still — with the largest cumulative area at 44.842%, yet the lowest average intensity at 30.3, because a 1:1 drag-to-height mapping changes a little on nearly every frame instead of jumping in a few large steps. This is the filter ui mobile pattern in the set: MAX_DRAG clamps the sheet to 130px of travel, so a fast flick can't drag the sheet past its designed range before release.


  function setDrag(px) {
    sheet.style.setProperty('--drag', Math.max(0, Math.min(MAX_DRAG, px)) + 'px');
  }

  handle.addEventListener('pointerdown', function (e) {
    dragging = true;
    startY = e.clientY;
    sheet.classList.remove('is-demo');
    sheet.classList.add('is-dragging');
    handle.setPointerCapture(e.pointerId);
  });

  handle.addEventListener('pointermove', function (e) {
    if (!dragging) return;
    setDrag(e.clientY - startY);
  });

  function release() {
    if (!dragging) return;
    dragging = false;
    sheet.classList.remove('is-dragging');
    setDrag(0);
  }

09Price-range fill — a slider computing its track width from two input values

When either the min or max handle's value changes, the gap between them is computed as a fraction of the track's total length, redrawing the filled segment's start and end.

Price-range fill — a slider computing its track width from two input values
Open in a new tab

09 · Price-range fill — a slider computing its track width from two input values — Measured area 1.531% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

input[type=range]calc(setProperty

What the numbers say

11 of 23 frames move, a 1.531% cumulative area at 160.4 average intensity, the second-highest intensity of the nine — the filled segment itself is thin, so each frame's change is concentrated into a few pixels rather than spread thin. The autoplay comment marks exactly where a real input listener hands off to the seeded demo sequence:

  minInput.addEventListener('input', update);
  maxInput.addEventListener('input', update);
  update();

  // Autoplay only: feeds a fixed sequence of seeded value pairs instead of firing real input events
  var SEQ = [[20, 70], [5, 40], [55, 95], [20, 70]];
  var idx = 0;
  (function step() {
    setTimeout(function () {
      idx = (idx + 1) % SEQ.length;
      minInput.value = String(SEQ[idx][0]);
      maxInput.value = String(SEQ[idx][1]);
      update();
      step();
    }, 550);
  })();

Where values reshape the screen — remaining width, active count, range gap

Three of the nine numbers above decide the whole shape of their screen rather than one visual detail on it, and reading them wrong breaks the layout, not just the motion.

Why remaining width has to be resummed, not just subtracted

02's container can't simply subtract one chip's width when a chip leaves, because the remaining chips' own gaps also change once one fewer chip sits between them — recompute() walks every surviving chip's offsetWidth plus the row's gap value fresh on each removal instead of tracking a running subtraction that would drift out of sync after a few edits.

Why active count drives a badge's scale amount, not just its text

05 doesn't just swap the badge's digit — the scale-up amount is proportional to how much the count changed, so a jump from 2 to 3 produces a smaller pulse than a jump from 2 to 9, which is a detail a filter ui component built with a fixed pulse size would lose entirely.

Why the range gap has to be a fraction of total track length

09's filled segment can't be positioned in raw pixels, because the track itself is a min(72vw, 220px) width that changes with viewport size — the gap between the min and max handle values is turned into a percentage of the track's current width, so the fill stays correct whether the track measures 220px on desktop or a narrower value on a small phone.

Where it breaks — rapid clear, mobile drag, low contrast

Where this group differs from a nine-screen sidebar drawer set built around a single open/closed edge is scope: that set moves one panel from one of four edges, while this set of nine spans a chip row, a tree, a slider, and a sheet, each recomputing a different kind of dimension. The same index-times-interval cascade in item 04 also shows up, restyled around a single active choice rather than several removable chips, in a nine-screen segmented control set that slides a highlight pill between options — the full nine-screen implementation behind this set, both vanilla and React, sits behind mbfmp3s6 in this post's zip.

What happens when a filter is cleared and reselected faster than the cascade finishes

04's setTimeout calls are keyed to each chip's own index at the moment clearBtn fires; if a filter is reselected mid-cascade, chips can still be mid-fade from the earlier click while the new chip layout is already computing its own removal order, so a production build should cancel pending timeouts on a second click rather than letting two cascades overlap.

What mobile drag needs beyond MAX_DRAG

08 clamps the sheet's height at 130px of drag, but a real device also needs touch-action: none on the handle so the browser doesn't try to scroll the page underneath a vertical drag, and pointercancel has to run the same release() cleanup as pointerup or an interrupted drag (an incoming call, a system gesture) leaves is-dragging stuck on the sheet.

What low contrast does to a filter ui at a glance

A chip's remove ×, a tree's expand caret, and a slider's handle are all small targets that depend on contrast against their own background more than the labels around them — a token pass that only checks body text contrast can pass a page where these controls' own edges sink into the surrounding chip or track color.

Three variants and accessibility

Variant What changes What it trades off
Chip-row collapse duration Item 02's $dur-base token from 300ms to 650ms At 300ms the row narrows almost as fast as the chip disappears, reading as an abrupt snap; at 650ms the remaining chips visibly slide together, but clearing several chips in a row starts to feel like the row is still catching up
Clear-cascade stagger interval Item 04's STAGGER_MS from 90ms to 200ms At 90ms the chips vanish close enough together that the left-to-right order barely registers; at 200ms the order reads clearly chip by chip, but a row with many chips takes noticeably longer to finish clearing
Tree-expand easing curve Item 06's $ease-spring token swapped for an elastic cubic-bezier(0.34, 1.56, 0.64, 1) curve The spring settles into its final height calmly with no overshoot; the elastic curve overshoots past the final height before settling back, which telegraphs that more child items are still loading underneath

This zip ships the 650ms, 200ms, and elastic-curve versions.

What reduced motion and screen readers both get from the same computed values

Every one of the nine checks prefers-reduced-motion and swaps its computed transition or animation for an instant state change while keeping the same end value, so a reduced-motion reader still lands on the correct open width, badge size, or fill position — just without the interpolation. aria-expanded on 01's trigger and 06's toggle, aria-hidden on 01's panel, and role="status" on 03's live count keep the same numbers that drive the animation also driving what a screen reader announces.

FAQ

Does any of these nine screens read a real product catalog or price list?

No — every category label, chip name, and price shown across the nine is a fixed fictional value written directly into the demo's markup or its VALUES/SEQ arrays; there is no fetch call, no real inventory, and no live pricing API anywhere in the set.

Which of the nine respond to a real click, drag, or checkbox instead of just autoplaying?

Seven do: 01, 02, 04, and 06 respond to a real click, 07 responds to change on real checkboxes, 08 responds to real pointerdown/pointermove/pointerup events, and 09 responds to real input events on its two handles; only 03 and 05 run purely on a fixed setTimeout loop with no click, drag, input, or pointer listener attached.

Is a filter ui design like this one accessible with a keyboard alone, with no mouse or touch?

Yes for the seven with real listeners — 01's trigger, 02's chip-remove buttons, 04's clear button, 06's toggle, and 07's checkboxes are all native focusable elements that fire their listeners on Enter/Space as well as a pointer, 09's two handles are native <input type="range"> elements that already answer arrow keys, and only 08's drag handle should additionally expose arrow-key stepping in production since a pointer-only drag has no keyboard equivalent as shipped.

Sources: MDN — CSS custom properties · MDN — Element.getBoundingClientRect · MDN — prefers-reduced-motion · MDN — PointerEvent

Enter the archive password

The password is inside this article. You will find it as you read.