UI Library
Filter panel
A faceted-filter side panel that replaces a row of toolbar dropdowns with one button. Selection is lifted to the caller and applied live, so the list updates as boxes are ticked; active choices stay visible as removable chips while the panel is closed.
Overlayfilterfacetspanelcheckboxradix
Preview
Courses
- Working Safely at Height
- Permit to Work — Issuing Authority
- Dropped Objects Prevention
- Journey Management Basics
apps/site/blocks/components/filter-panel.tsx
'use client';
/**
* A floating faceted-filter side panel — standalone.
*
* This is the icompetent-admin panel (`src/components/admin/filter-panel.tsx`)
* rewritten to drop into any React + Tailwind project. The version in that repo
* is built on shadcn's `sheet.tsx` and needs a patch to it (an `overlayClassName`
* prop) that no other project will have; this one talks to Radix directly, so
* there is nothing to patch.
*
* ── Dependencies ────────────────────────────────────────────────────────────
* npm i @radix-ui/react-dialog @radix-ui/react-checkbox \
* @radix-ui/react-collapsible lucide-react
*
* Tailwind is assumed. Nothing else is: no shadcn, no `cn()` helper, no
* `tailwindcss-animate` / `tw-animate-css` — the slide-in keyframes are defined
* at the bottom of this file and injected with the panel.
*
* Corner radii are written as explicit pixels (`rounded-[12px]`) rather than
* `rounded-xl` and friends on purpose. A shadcn project remaps that scale from
* its own `--radius`, so a host with `--radius: 1rem` renders `rounded-sm` at
* 12px and turns the 16px checkbox into a circle. Explicit values look the same
* in every project; change them if you would rather inherit the host's scale.
*
* ── Why it is built this way ────────────────────────────────────────────────
* A toolbar grows one dropdown per facet until the page opens on its controls
* instead of its records. This trades all of them for one button, and gives
* each facet room for a label, a count and a checkbox list.
*
* Selection is **lifted to the caller** and applied live: the list behind the
* panel updates as boxes are ticked, so the footer button only has to close it.
* That is deliberate — a panel that batches changes until "Apply" makes you
* guess what each choice will do.
*
* ── Usage ───────────────────────────────────────────────────────────────────
* const { selected, toggle, clear } = useFacetSelection();
*
* const facets: FilterFacet[] = [
* { id: 'status', name: 'Status', options: [
* { value: facetValue('status', 'live'), label: 'Live' },
* { value: facetValue('status', 'draft'), label: 'Draft' },
* ]},
* ];
*
* const shown = items.filter((item) =>
* matchesSelection(selected, valuesOf(item)),
* );
*
* <FiltersButton count={selected.size} onClick={() => setOpen(true)} />
* <ActiveFilterChips facets={facets} selected={selected}
* onToggle={toggle} onClear={clear} />
* <FilterPanel open={open} onOpenChange={setOpen} facets={facets}
* selected={selected} onToggle={toggle} onClear={clear}
* resultCount={shown.length} resultNoun="course" />
*/
import * as React from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import * as Checkbox from '@radix-ui/react-checkbox';
import * as Collapsible from '@radix-ui/react-collapsible';
import { Check, ChevronDown, Search, SlidersHorizontal, X } from 'lucide-react';
// ── Types ───────────────────────────────────────────────────────────────────
export type FilterOption = {
/** Unique across ALL facets — see `facetValue()`. */
value: string;
label: string;
};
export type FilterFacet = {
id: string;
name: string;
options: FilterOption[];
};
// ── Selection state + matching ──────────────────────────────────────────────
/**
* Namespace a raw value to its facet.
*
* Two facets can hold the same string — a category called "Free" and a price
* facet's `free` — and without a namespace they become one checkbox driving
* both. Skip this only when your values are already globally unique (ids, say).
*/
export function facetValue(facetId: string, value: string): string {
return `${facetId}:${value}`;
}
/** Split on the FIRST colon only: the raw value may well contain one. */
export function splitFacetValue(selected: string): { facetId: string; value: string } {
const at = selected.indexOf(':');
return at === -1
? { facetId: selected, value: '' }
: { facetId: selected.slice(0, at), value: selected.slice(at + 1) };
}
/** The selected set, plus the two operations the panel needs. */
export function useFacetSelection(initial?: Iterable<string>) {
const [selected, setSelected] = React.useState<Set<string>>(() => new Set(initial ?? []));
const toggle = React.useCallback((value: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (!next.delete(value)) next.add(value);
return next;
});
}, []);
const clear = React.useCallback(() => setSelected(new Set()), []);
return { selected, setSelected, toggle, clear };
}
/**
* **AND across facets, OR within one.**
*
* This is the only rule that makes ticking a second box in a group *widen* the
* result instead of emptying it, which is what people expect a checkbox list to
* do. Pass every value the item holds — one per facet, or many where a facet is
* multi-valued (tags).
*/
export function matchesSelection(selected: Set<string>, itemValues: Iterable<string>): boolean {
if (selected.size === 0) return true;
const wantedByFacet = new Map<string, Set<string>>();
for (const value of selected) {
const { facetId } = splitFacetValue(value);
if (!wantedByFacet.has(facetId)) wantedByFacet.set(facetId, new Set());
wantedByFacet.get(facetId)!.add(value);
}
const held = itemValues instanceof Set ? itemValues : new Set(itemValues);
for (const wanted of wantedByFacet.values()) {
let hit = false;
for (const value of wanted) {
if (held.has(value)) {
hit = true;
break;
}
}
if (!hit) return false;
}
return true;
}
// ── Toolbar button ──────────────────────────────────────────────────────────
/** The one control that replaces a row of dropdowns, badged with the count. */
export function FiltersButton({
count,
onClick,
label = 'Filters',
className = '',
}: {
count: number;
onClick: () => void;
label?: string;
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={count > 0 ? `${label} (${count} active)` : label}
className={
'inline-flex h-10 items-center rounded-[12px] border bg-white px-4 text-sm font-bold ' +
'text-slate-700 transition hover:bg-slate-50 ' +
(count > 0 ? 'border-slate-900 ' : 'border-slate-200 ') +
className
}
>
<SlidersHorizontal className="mr-2 h-4 w-4" aria-hidden />
{label}
{count > 0 && (
<span className="ml-2 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-slate-900 px-1.5 text-[11px] font-bold text-white">
{count}
</span>
)}
</button>
);
}
// ── Active filter chips ─────────────────────────────────────────────────────
/**
* What is currently selected, as removable chips.
*
* Not optional decoration: a closed panel otherwise hides the reason the list
* is short, which is the one real cost of moving filters off the toolbar.
*/
export function ActiveFilterChips({
facets,
selected,
onToggle,
onClear,
className = '',
}: {
facets: FilterFacet[];
selected: Set<string>;
onToggle: (value: string) => void;
onClear: () => void;
className?: string;
}) {
const chips = facets.flatMap((facet) =>
facet.options
.filter((option) => selected.has(option.value))
.map((option) => ({ ...option, facet: facet.name })),
);
if (chips.length === 0) return null;
return (
<div className={'mb-4 flex flex-wrap items-center gap-2 ' + className}>
{chips.map((chip) => (
<span
key={chip.value}
className="inline-flex items-center gap-1 rounded-full bg-slate-100 py-1 pl-2.5 pr-1 text-xs font-medium text-slate-700"
>
<span className="text-slate-500">{chip.facet}:</span> {chip.label}
<button
type="button"
aria-label={`Remove filter ${chip.label}`}
onClick={() => onToggle(chip.value)}
className="rounded-full p-0.5 hover:bg-slate-300/60"
>
<X className="h-3 w-3" aria-hidden />
</button>
</span>
))}
<button
type="button"
onClick={onClear}
className="h-7 rounded-full px-2 text-xs font-bold text-slate-500 hover:text-slate-700"
>
Clear all
</button>
</div>
);
}
// ── The panel ───────────────────────────────────────────────────────────────
export function FilterPanel({
open,
onOpenChange,
facets,
selected,
onToggle,
onClear,
resultCount,
resultNoun = 'result',
title = 'Filters',
description = 'Narrow the list. Choices apply as you tick them.',
searchPlaceholder = 'Find a filter…',
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
facets: FilterFacet[];
selected: Set<string>;
onToggle: (value: string) => void;
onClear: () => void;
resultCount: number;
/** Singular; pluralised with a trailing "s" in the footer button. */
resultNoun?: string;
title?: string;
description?: string;
searchPlaceholder?: string;
}) {
const [query, setQuery] = React.useState('');
// A stale search term would hide facets the next time the panel opens.
React.useEffect(() => {
if (!open) setQuery('');
}, [open]);
const q = query.trim().toLowerCase();
const visible = React.useMemo(() => {
if (!q) return facets;
return facets
.map((facet) => ({
...facet,
options: facet.options.filter((option) => option.label.toLowerCase().includes(q)),
}))
.filter((facet) => facet.options.length > 0);
}, [facets, q]);
const selectedCount = facets.reduce(
(total, facet) => total + facet.options.filter((o) => selected.has(o.value)).length,
0,
);
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<style>{PANEL_KEYFRAMES}</style>
<Dialog.Overlay
className="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-[2px]"
style={{ animation: 'filterPanelFade 200ms ease-out' }}
/>
{/*
* The geometry, which is the whole point of the look:
*
* `top/bottom` TOGETHER are what give the panel its height — so height
* stays `auto`. Set a height as well and you get a full-viewport box
* starting 16px down, hanging off the bottom of the screen.
*
* The width is the same trick: 100% would run past the right margin, so
* it is the viewport less BOTH margins, then capped on larger screens.
*
* `overflow-hidden` is load-bearing next to the rounded corners — the
* header, search row and footer carry full-width borders that would
* otherwise cut straight across them.
*/}
<Dialog.Content
className={
'fixed inset-y-4 right-4 z-50 flex h-auto w-[calc(100%-2rem)] flex-col ' +
'overflow-hidden rounded-[16px] border border-slate-200 bg-white shadow-2xl sm:max-w-md'
}
style={{ animation: 'filterPanelIn 250ms cubic-bezier(0.32, 0.72, 0, 1)' }}
>
<div className="border-b border-slate-200 px-6 py-5">
<Dialog.Title className="text-xl font-extrabold tracking-tight text-slate-900">
{title}
</Dialog.Title>
<Dialog.Description className="mt-1 text-sm text-slate-500">
{description}
</Dialog.Description>
<Dialog.Close
className="absolute right-4 top-4 rounded-[6px] p-1 text-slate-400 opacity-70 transition hover:bg-slate-100 hover:opacity-100"
aria-label="Close filters"
>
<X className="h-4 w-4" aria-hidden />
</Dialog.Close>
</div>
<div className="border-b border-slate-200 px-6 py-4">
<div className="relative">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
aria-hidden
/>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
className="h-10 w-full rounded-[12px] border border-slate-200 pl-9 pr-3 text-sm outline-none focus:border-slate-400"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-2">
{visible.length === 0 ? (
<p className="py-8 text-center text-sm text-slate-500">
No filters match “{query}”.
</p>
) : (
visible.map((facet) => (
<FacetSection
key={facet.id}
facet={facet}
selected={selected}
onToggle={onToggle}
// While searching, every remaining group is a match —
// collapsing them would hide the very thing searched for.
forceOpen={q.length > 0}
/>
))
)}
</div>
<div className="flex items-center justify-between gap-3 border-t border-slate-200 px-6 py-4">
<button
type="button"
onClick={onClear}
disabled={selectedCount === 0}
className="h-10 rounded-[12px] px-3 text-sm font-bold text-slate-500 hover:text-slate-700 disabled:opacity-40 disabled:hover:text-slate-500"
>
Clear all
</button>
<button
type="button"
onClick={() => onOpenChange(false)}
className="h-10 rounded-[12px] bg-slate-900 px-4 text-sm font-bold text-white shadow-sm transition hover:bg-slate-800"
>
Show {resultCount} {resultNoun}
{resultCount === 1 ? '' : 's'}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
// ── One facet ───────────────────────────────────────────────────────────────
function FacetSection({
facet,
selected,
onToggle,
forceOpen,
}: {
facet: FilterFacet;
selected: Set<string>;
onToggle: (value: string) => void;
forceOpen: boolean;
}) {
const count = facet.options.filter((o) => selected.has(o.value)).length;
// A dozen groups open at once is a scroll, not a panel: start closed, except
// where a choice is already active and its absence would be a surprise.
const [open, setOpen] = React.useState(count > 0);
return (
<Collapsible.Root
open={forceOpen || open}
onOpenChange={setOpen}
className="border-b border-slate-100 py-1 last:border-0"
>
<Collapsible.Trigger
disabled={forceOpen}
className="flex w-full items-center justify-between gap-2 rounded-[8px] px-1 py-3 text-left hover:bg-slate-50 disabled:hover:bg-transparent"
>
<span className="flex items-center gap-2 text-sm font-bold text-slate-800">
{facet.name}
{count > 0 && (
<span className="inline-flex h-5 items-center rounded-full bg-slate-100 px-2 text-[11px] font-bold text-slate-600">
{count}
</span>
)}
</span>
{!forceOpen && (
<ChevronDown
className={
'h-4 w-4 shrink-0 text-slate-400 transition-transform ' + (open ? 'rotate-180' : '')
}
aria-hidden
/>
)}
</Collapsible.Trigger>
<Collapsible.Content className="pb-2">
<div className="grid gap-0.5">
{facet.options.map((option) => {
const id = `filter-${facet.id}-${option.value}`;
return (
<label
key={option.value}
htmlFor={id}
className="flex cursor-pointer items-center gap-3 rounded-[8px] px-1 py-2 text-sm text-slate-600 hover:bg-slate-50"
>
<Checkbox.Root
id={id}
checked={selected.has(option.value)}
onCheckedChange={() => onToggle(option.value)}
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-[4px] border border-slate-300 data-[state=checked]:border-slate-900 data-[state=checked]:bg-slate-900 data-[state=checked]:text-white"
>
<Checkbox.Indicator>
<Check className="h-3.5 w-3.5" aria-hidden />
</Checkbox.Indicator>
</Checkbox.Root>
{option.label}
</label>
);
})}
</div>
</Collapsible.Content>
</Collapsible.Root>
);
}
/**
* Slide-in and fade, as plain CSS.
*
* Inlined so the file needs no animation plugin. If your project already has
* `tailwindcss-animate` or `tw-animate-css`, delete this and the two `style`
* attributes above, and use the utility classes instead.
*/
const PANEL_KEYFRAMES = `
@keyframes filterPanelIn {
from { opacity: 0; transform: translateX(16px) scale(0.98); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
@keyframes filterPanelFade {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
@keyframes filterPanelIn { from { opacity: 1; } to { opacity: 1; } }
@keyframes filterPanelFade { from { opacity: 1; } to { opacity: 1; } }
}
`;
// ── Demo ────────────────────────────────────────────────────────────────────
const DEMO_FACETS: FilterFacet[] = [
{
id: 'status',
name: 'Status',
options: [
{ value: facetValue('status', 'live'), label: 'Live' },
{ value: facetValue('status', 'draft'), label: 'Draft' },
{ value: facetValue('status', 'archived'), label: 'Archived' },
],
},
{
id: 'level',
name: 'Level',
options: [
{ value: facetValue('level', 'intro'), label: 'Introductory' },
{ value: facetValue('level', 'intermediate'), label: 'Intermediate' },
{ value: facetValue('level', 'advanced'), label: 'Advanced' },
],
},
{
id: 'format',
name: 'Format',
options: [
{ value: facetValue('format', 'self-paced'), label: 'Self-paced' },
{ value: facetValue('format', 'cohort'), label: 'Cohort' },
{ value: facetValue('format', 'in-person'), label: 'In person' },
],
},
];
const DEMO_ITEMS: { id: string; title: string; values: string[] }[] = [
{
id: 'a',
title: 'Working Safely at Height',
values: [facetValue('status', 'live'), facetValue('level', 'intro'), facetValue('format', 'self-paced')],
},
{
id: 'b',
title: 'Permit to Work — Issuing Authority',
values: [facetValue('status', 'live'), facetValue('level', 'advanced'), facetValue('format', 'cohort')],
},
{
id: 'c',
title: 'Dropped Objects Prevention',
values: [facetValue('status', 'draft'), facetValue('level', 'intermediate'), facetValue('format', 'in-person')],
},
{
id: 'd',
title: 'Journey Management Basics',
values: [facetValue('status', 'archived'), facetValue('level', 'intro'), facetValue('format', 'self-paced')],
},
];
/**
* A self-contained harness for the three exports above.
*
* It exists because `FilterPanel` is controlled: the open flag and the selected
* set belong to the caller, so the component cannot render on its own. This is
* what the /ui preview shows, and it doubles as the copy-paste wiring the
* header comment describes.
*/
export function FilterPanelDemo() {
const [open, setOpen] = React.useState(false);
const { selected, toggle, clear } = useFacetSelection();
const shown = DEMO_ITEMS.filter((item) => matchesSelection(selected, item.values));
return (
<div className="mx-auto w-full max-w-2xl">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-base font-bold text-slate-900">Courses</h3>
<FiltersButton count={selected.size} onClick={() => setOpen(true)} />
</div>
<ActiveFilterChips
facets={DEMO_FACETS}
selected={selected}
onToggle={toggle}
onClear={clear}
/>
<ul className="divide-y divide-slate-200 rounded-[12px] border border-slate-200 bg-white">
{shown.map((item) => (
<li key={item.id} className="px-4 py-3 text-sm font-medium text-slate-700">
{item.title}
</li>
))}
{shown.length === 0 && (
<li className="px-4 py-8 text-center text-sm text-slate-500">
Nothing matches those filters.
</li>
)}
</ul>
<FilterPanel
open={open}
onOpenChange={setOpen}
facets={DEMO_FACETS}
selected={selected}
onToggle={toggle}
onClear={clear}
resultCount={shown.length}
resultNoun="course"
/>
</div>
);
}
Dependencies
@radix-ui/react-dialog— npm package@radix-ui/react-checkbox— npm package@radix-ui/react-collapsible— npm packagelucide-react— npm package
Colours come from CSS variables (--background, --primary, --border) using the shadcn/ui token names, so the block adopts your palette rather than importing ours.