Koala logo Design

Filter popovers

When a list page has multiple filter dimensions (audience types, status, date range, specific people, etc.) and you want to keep the page header compact, use a single Filter button that opens a popover with the filter controls inside.

Live example

A Filter button — centred at the top of the activity timeline card — that opens an Alpine popover with audience checkboxes, a specific-people search, and a Reset action. Every change auto-applies — the popover state IS the server state, there is no draft / dirty step. The Apply button (and the divider above it) is mobile-only; desktop dismisses via outside-click / ESC. Specific people override audience: when one or more specific people are picked, only their activity is shown and the audience checkboxes are ignored (dimmed). The active filter count reflects this — it shows just the number of specific people picked when any are selected, otherwise the number of ticked audiences.

Show activity from

Specific people

Reference snippet

Copy-pasteable markup template for a filter popover wrapped around a GET form. Replace the audience checkboxes and the specific-people slot with whatever filter dimensions the page needs.

<form method="get" x-target.push="my-results">
    <!-- Centred trigger at the top of the timeline card -->
    <div class="flex justify-center pt-4 pb-2">
        <div class="relative" x-data="{ open: @(popoverInitiallyOpen ? "true" : "false") }">
            <button type="button"
                    x-on:click="open = !open"
                    koala-btn="Neutral"
                    koala-btn-variant="Outlined"
                    class="inline-flex items-center gap-2">
                <koala-icon name="Filter" size="Small" />
                <span>@filterButtonLabel</span>
                <koala-icon name="ChevronDown" size="Small" class="text-gray-400" />
            </button>

            <div x-show="open"
                 x-on:click.outside="open = false"
                 x-on:keydown.escape.window="open = false"
                 x-transition:enter="transition ease-out duration-100"
                 x-transition:enter-start="opacity-0 scale-95"
                 x-transition:enter-end="opacity-100 scale-100"
                 x-transition:leave="transition ease-in duration-75"
                 x-transition:leave-start="opacity-100 scale-100"
                 x-transition:leave-end="opacity-0 scale-95"
                 x-cloak
                 class="absolute z-20 mt-2 top-full left-1/2 -translate-x-1/2 w-[20rem] sm:w-[22rem] p-4
                        bg-white rounded-lg border border-gray-100
                        dark:bg-gray-700 dark:border-gray-600">

                <!-- Hidden flag — only carries `popoverOpen=true` while the popover is open.
                     Auto-submits triggered from inside the popover (checkbox change, autocomplete
                     add/remove, Reset link) preserve the open state across the AJAX swap. -->
                <input type="hidden" name="popoverOpen" x-bind:value="open ? 'true' : ''" />

                <!-- X close button — mobile only. Desktop dismisses via outside-click / ESC. -->
                <button type="button"
                        x-on:click="open = false"
                        class="absolute top-3 right-3 z-10 text-gray-400 hover:text-gray-600
                               dark:text-gray-400 dark:hover:text-gray-200 sm:hidden">
                    <koala-icon name="X" size="Small" />
                </button>

                <h3 class="font-semibold text-gray-900 dark:text-white mb-3 pr-8">Show activity from</h3>

                <!-- Audience checkboxes are dimmed when specific people are picked
                     (specific people override audience — see semantic below). -->
                <ul class="space-y-2 @(Model.UserIds.Count > 0 ? "opacity-50 pointer-events-none" : "")"
                    aria-disabled="@(Model.UserIds.Count > 0 ? "true" : "false")">
                    <li class="flex items-center">
                        <!-- Hidden false before each checkbox so an unchecked box still posts.
                             Auto-apply on change: $el.closest('form').requestSubmit() -->
                        <input type="hidden" name="IncludeClients" value="false" />
                        <input id="IncludeClients" name="IncludeClients" type="checkbox" value="true"
                               @(Model.IncludeClients ? "checked" : "")
                               x-on:change="$el.closest('form').requestSubmit()"
                               class="w-4 h-4 bg-white border-gray-300 rounded ..." />
                        <label for="IncludeClients" class="ml-2 ...">Clients</label>
                    </li>
                    <!-- Repeat for Partners and Team -->
                </ul>

                <div class="border-t border-gray-200 dark:border-gray-600 my-3"></div>

                <h3 class="font-semibold text-gray-900 dark:text-white mb-3">Specific people</h3>
                <partial name="_PersonAutocomplete" />

                <!-- Reset is the only action — every change has already been applied -->
                <div class="flex justify-end gap-3 pt-2">
                    <a href="@(MyPage.Route())?popoverOpen=true"
                       x-target.push="my-results"
                       koala-btn="Neutral" koala-btn-variant="Outlined">
                        Reset
                    </a>
                </div>

                <!-- Apply is mobile-only — desktop dismisses via outside-click / ESC.
                     The divider above it is mobile-only too. -->
                <div class="border-t border-gray-200 dark:border-gray-600 my-3 sm:hidden"></div>
                <button type="button"
                        x-on:click="open = false"
                        koala-btn="Primary"
                        class="w-full sm:hidden">
                    Apply
                </button>
            </div>
        </div>
    </div>
</form>

Conventions

  • Filter trigger sits centred at the top of the timeline card. Wrap the popover in <div class="flex justify-center pt-4 pb-2"> inside the card, immediately before the timeline content. The trigger sits centred just below the card's top edge (or tab nav) with the timeline directly below it. Don't park it at the right edge of a header row, and don't leave it floating above the card with empty page-background space around it.
  • Trigger button is koala-btn="Neutral" koala-btn-variant="Outlined" with a leading <koala-icon name="..."> (typically Filter) and a trailing ChevronDown.
  • Active filter count appears in parentheses on the button label, e.g. Filter (3). When any specific entity is picked the count is just the number of picked entities (audience is overridden — see below). Otherwise the count is the number of ticked audiences. Default state shows Filter (3) (all 3 audiences). Always shown — never hidden — so users get a quick read of how broad the current filter is.
  • Specific entities override audience. When one or more specific entities are picked, only those entities' rows are shown — the audience checkboxes are ignored, not OR'd in. This matches the user's mental model ("I picked specific people, show me ONLY their stuff"). The audience checkboxes are dimmed (opacity-50 pointer-events-none + aria-disabled="true") while specific entities are selected to communicate this. Apply the same predicate to the page query AND to any total-count query that drives header text — they must agree.
  • Default state: all audience checkboxes ticked. Reset is an instant restore to defaults — it navigates to the bare URL with ?popoverOpen=true, re-rendering with all audiences ticked and no specific entities.
  • Popover uses Alpine x-data="{ open: @(popoverInitiallyOpen ? "true" : "false") }" with x-on:click.outside="open = false" and x-on:keydown.escape.window. Anchored centred below the trigger via absolute top-full left-1/2 -translate-x-1/2 (paired with the centred wrapper above). An X close button sits absolutely at top-3 right-3 and just sets open = false — it's mobile-only (sm:hidden), because desktop dismisses via outside-click / ESC.
  • Filter popovers auto-apply on every change. The popover state is always the server state — there is no draft / dirty state. Each audience checkbox calls $el.closest('form').requestSubmit() on change; the autocomplete add/remove flow already submits via Alpine-AJAX. Use Reset to restore defaults; outside-click / Escape (desktop) or X / Apply (mobile) to dismiss the panel.
  • Apply button is mobile-only — desktop dismisses via outside-click / ESC / X (mobile). On the full-screen mobile popover the user needs an explicit dismiss when the X isn't enough or for muscle memory; on desktop the popover dismisses via outside-click / ESC, so Apply is just clutter. Add sm:hidden to the Apply button. The button's x-on:click="open = false" is all it needs to do — every change has already been auto-applied.
  • Horizontal divider above the Apply button is mobile-only too. Add sm:hidden to the divider so desktop doesn't show a stray border at the bottom of the popover.
  • Keeping the popover open across auto-submits. Every submit originating from inside the popover replaces the results container, which destroys the popover's Alpine state. Render a hidden <input type="hidden" name="popoverOpen" x-bind:value="open ? 'true' : ''" /> inside the popover so every submit while the popover is open carries popoverOpen=true forward, and seed x-data="{ open: ... }" from the query string on render. X / outside-click / ESC flip open to false BEFORE any submit, so a bare close doesn't carry the flag.
  • Mobile: same popover (no full-screen drawer fallback) unless positioning becomes awkward at narrow widths.
  • Wrap the popover in a <form method="get"> with x-target.push="results-container" so each change re-renders the list via Alpine-AJAX.
  • Bind audience flags as separate booleans (e.g. IncludeClients, IncludePartners, IncludeTeam), not as a single [Flags] enum. Multi-value ?Audience=... query strings can collapse to the first value depending on the binder. Each bool defaults to true so a fresh page load shows everything; emit a hidden value="false" before each checkbox so unchecked boxes still post.
  • The hidden popoverOpen input handles every submit originating from inside the popover (audience checkbox change, specific-people add/remove, Reset link). It only carries true while the panel is open, so closing the panel before any further interaction won't accidentally re-open it on the next render.