Components · Data
Filter bar
Toggle chips, a sort select and a live result count; Clear turns every filter off.
The UI Index has no filter on its 2,062 sites yet.
12 deploys
12 deploys
Both previews render the same markup; the HTML tab below is that markup without a framework.
Install
- React
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ filter-bar.json - Astro
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ astro/ filter-bar.json
Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS and the script.
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { Select } from '../select/select';
import { CHECK_PATH, filterBar as c, type FilterOption } from './filter-bar.variants';
export type { FilterOption };
export interface FilterBarProps {
id: string;
/** Names the chip group, e.g. "Filter by status". */
label: string;
filters: FilterOption[];
sort: { value: string; label: string }[];
sortValue?: string;
/** The result line, e.g. "24 deploys". */
count: string;
onChange?: (detail: { active: string[]; sort?: string }) => void;
className?: string;
}
/** Toggle chips, a sort select and the result count; Clear turns every filter off. */
export function FilterBar({ id, label, filters, sort, sortValue, count, onChange, className }: FilterBarProps) {
const [active, setActive] = useState(filters.filter((f) => f.pressed).map((f) => f.value));
const [order, setOrder] = useState(sortValue);
const update = (next: string[], s = order) => {
setActive(next);
onChange?.({ active: next, sort: s });
};
return (
<div data-filter-bar="" className={cn(c.root, className)}>
<div className={c.inner}>
<ul aria-label={label} className={c.chips}>
{filters.map((f) => {
const on = active.includes(f.value);
return (
<li key={f.value}>
<button type="button" data-filter={f.value} aria-pressed={on ? 'true' : 'false'} className={c.chip} onClick={(e) => { e.preventDefault(); update(on ? active.filter((v) => v !== f.value) : [...active, f.value]); }}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={c.check}><path d={CHECK_PATH} /></svg>
{f.label}
</button>
</li>
);
})}
</ul>
<div className={c.side}>
<p role="status" className={c.count}>{count}</p>
<div className={c.tools}>
<label htmlFor={`${id}-sort`} className="sr-only">Sort by</label>
<div className={c.sort}><Select id={`${id}-sort`} defaultValue={sortValue} options={sort} onChange={(e) => { setOrder(e.target.value); onChange?.({ active, sort: e.target.value }); }} /></div>
<button type="button" data-filter-clear="" disabled={active.length === 0} className={c.clear} onClick={(e) => { e.preventDefault(); update([]); }}>Clear</button>
</div>
</div>
</div>
</div>
);
}/** A filter bar: toggle chips (aria-pressed) for the categories, a sort select and the result count (role=status,
* so a change is announced), with Clear once anything is on. One row from a wide container, wrapping on phones.
* It changes no results itself: listen to `filter-change` ({ active: string[], sort }) and filter your list. */
export const filterBar = {
root: '@container w-full',
inner: 'flex flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:justify-between',
chips: 'm-0 flex list-none flex-wrap items-center gap-2 p-0',
chip:
'inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full border border-input bg-background px-3 text-[13px] font-medium transition-[background-color,border-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-[color-mix(in_oklab,var(--primary)_var(--hover-amount),var(--background))] aria-pressed:active:bg-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] [&_svg]:size-3.5',
check: 'hidden [[aria-pressed=true]_&]:block',
// The count leads; the sort and Clear stay together on one line.
side: 'flex flex-wrap items-center justify-between gap-3 @2xl:justify-end',
tools: 'flex items-center gap-3',
count: 'text-sm text-muted-foreground tabular-nums',
sort: 'w-44',
clear: '-mx-1.5 inline-flex h-8 cursor-pointer items-center rounded-md px-1.5 text-sm font-medium underline decoration-foreground/30 underline-offset-4 transition-colors hover:decoration-foreground active:bg-pressed active:decoration-2 disabled:pointer-events-none disabled:no-underline disabled:opacity-50',
};
export type FilterOption = { value: string; label: string; pressed?: boolean };
export const CHECK_PATH = 'M3.5 8.5l3 3 6-7';---
import { cn } from '@/lib/utils';
import Select from '../select/select.astro';
import { CHECK_PATH, filterBar as c, type FilterOption } from './filter-bar.variants';
interface Props {
id: string;
/** Names the chip group, e.g. "Filter by status". */
label: string;
filters: FilterOption[];
sort: { value: string; label: string }[];
sortValue?: string;
/** The result line, e.g. "24 deploys". Kept current by your code. */
count: string;
class?: string;
}
const { id, label, filters, sort, sortValue, count, class: className } = Astro.props;
const anyOn = filters.some((f) => f.pressed);
---
<div data-filter-bar class={cn(c.root, className)}>
<div class={c.inner}>
<ul aria-label={label} class={c.chips}>
{
filters.map((f) => (
<li>
<button type="button" data-filter={f.value} aria-pressed={f.pressed ? 'true' : 'false'} class={c.chip}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class={c.check}><path d={CHECK_PATH} /></svg>
{f.label}
</button>
</li>
))
}
</ul>
<div class={c.side}>
<p role="status" class={c.count}>{count}</p>
<div class={c.tools}>
<label for={`${id}-sort`} class="sr-only">Sort by</label>
<div class={c.sort}><Select id={`${id}-sort`} value={sortValue} options={sort} /></div>
<button type="button" data-filter-clear disabled={!anyOn} class={c.clear}>Clear</button>
</div>
</div>
</div>
</div>
<script>
import './filter-bar.client';
</script>// Filter bar behaviour for Astro and plain HTML (React keeps state): chips toggle aria-pressed, Clear turns them all
// off, and every change dispatches a bubbling `filter-change` event ({ active, sort }) from [data-filter-bar].
declare global {
interface Window {
__caseroomFilter?: boolean;
}
}
const emit = (bar: HTMLElement) => {
const active = [...bar.querySelectorAll<HTMLElement>('[data-filter][aria-pressed=true]')].map((b) => b.dataset.filter!);
const sort = bar.querySelector<HTMLSelectElement>('select')?.value;
const clear = bar.querySelector<HTMLButtonElement>('[data-filter-clear]');
if (clear) clear.disabled = active.length === 0;
bar.dispatchEvent(new CustomEvent('filter-change', { bubbles: true, detail: { active, sort } }));
};
if (typeof window !== 'undefined' && !window.__caseroomFilter) {
window.__caseroomFilter = true;
document.addEventListener('click', (e) => {
if (e.defaultPrevented) return;
const t = e.target as Element;
const chip = t.closest<HTMLButtonElement>('[data-filter-bar] [data-filter]');
const clear = t.closest<HTMLButtonElement>('[data-filter-bar] [data-filter-clear]');
const bar = (chip ?? clear)?.closest<HTMLElement>('[data-filter-bar]');
if (!bar) return;
if (chip) chip.setAttribute('aria-pressed', String(chip.getAttribute('aria-pressed') !== 'true'));
if (clear) for (const b of bar.querySelectorAll('[data-filter]')) b.setAttribute('aria-pressed', 'false');
emit(bar);
});
document.addEventListener('change', (e) => {
const bar = (e.target as Element).closest<HTMLElement>('[data-filter-bar]');
if (bar && (e.target as Element).matches('select')) emit(bar);
});
}
export {};/** A filter bar: toggle chips (aria-pressed) for the categories, a sort select and the result count (role=status,
* so a change is announced), with Clear once anything is on. One row from a wide container, wrapping on phones.
* It changes no results itself: listen to `filter-change` ({ active: string[], sort }) and filter your list. */
export const filterBar = {
root: '@container w-full',
inner: 'flex flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:justify-between',
chips: 'm-0 flex list-none flex-wrap items-center gap-2 p-0',
chip:
'inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full border border-input bg-background px-3 text-[13px] font-medium transition-[background-color,border-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-[color-mix(in_oklab,var(--primary)_var(--hover-amount),var(--background))] aria-pressed:active:bg-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] [&_svg]:size-3.5',
check: 'hidden [[aria-pressed=true]_&]:block',
// The count leads; the sort and Clear stay together on one line.
side: 'flex flex-wrap items-center justify-between gap-3 @2xl:justify-end',
tools: 'flex items-center gap-3',
count: 'text-sm text-muted-foreground tabular-nums',
sort: 'w-44',
clear: '-mx-1.5 inline-flex h-8 cursor-pointer items-center rounded-md px-1.5 text-sm font-medium underline decoration-foreground/30 underline-offset-4 transition-colors hover:decoration-foreground active:bg-pressed active:decoration-2 disabled:pointer-events-none disabled:no-underline disabled:opacity-50',
};
export type FilterOption = { value: string; label: string; pressed?: boolean };
export const CHECK_PATH = 'M3.5 8.5l3 3 6-7';<div data-filter-bar class="@container w-full max-w-3xl">
<div class="flex flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:justify-between">
<ul aria-label="Filter by status" class="m-0 flex list-none flex-wrap items-center gap-2 p-0">
<li>
<button type="button" data-filter="live" aria-pressed="true" class="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full border border-input bg-background px-3 text-[13px] font-medium transition-[background-color,border-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-[color-mix(in_oklab,var(--primary)_var(--hover-amount),var(--background))] aria-pressed:active:bg-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] [&_svg]:size-3.5">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="hidden [[aria-pressed=true]_&]:block"><path d="M3.5 8.5l3 3 6-7"></path></svg>
Live
</button>
</li>
<li>
<button type="button" data-filter="preview" aria-pressed="false" class="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full border border-input bg-background px-3 text-[13px] font-medium transition-[background-color,border-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-[color-mix(in_oklab,var(--primary)_var(--hover-amount),var(--background))] aria-pressed:active:bg-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] [&_svg]:size-3.5">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="hidden [[aria-pressed=true]_&]:block"><path d="M3.5 8.5l3 3 6-7"></path></svg>
Preview
</button>
</li>
<li>
<button type="button" data-filter="failed" aria-pressed="false" class="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full border border-input bg-background px-3 text-[13px] font-medium transition-[background-color,border-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-[color-mix(in_oklab,var(--primary)_var(--hover-amount),var(--background))] aria-pressed:active:bg-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] [&_svg]:size-3.5">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="hidden [[aria-pressed=true]_&]:block"><path d="M3.5 8.5l3 3 6-7"></path></svg>
Failed
</button>
</li>
</ul>
<div class="flex flex-wrap items-center justify-between gap-3 @2xl:justify-end">
<p role="status" class="text-sm text-muted-foreground tabular-nums">12 deploys</p>
<div class="flex items-center gap-3">
<label for="ex-filter-sort" class="sr-only">Sort by</label>
<div class="w-44">
<div class="relative w-full">
<select class="w-full min-w-0 rounded-md border border-input bg-background px-3 text-sm text-foreground transition-colors placeholder:text-muted-foreground enabled:hover:border-foreground/85 enabled:active:bg-muted aria-invalid:enabled:hover:border-destructive aria-invalid:enabled:hover:bg-[color-mix(in_oklab,var(--destructive)_6%,var(--background))] aria-invalid:enabled:active:bg-[color-mix(in_oklab,var(--destructive)_10%,var(--background))] focus-visible:border-ring focus-visible:outline-2 focus-visible:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:outline-destructive h-9 cursor-pointer appearance-none py-1 pr-9 has-[option[value='']:checked]:text-muted-foreground [&_option]:text-foreground" id="ex-filter-sort">
<option value="new" selected>Newest first</option>
<option value="old">Oldest first</option>
<option value="slow">Slowest build</option>
</select>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground"><path d="M4 6l4 4 4-4"></path></svg>
</div>
</div>
<button type="button" data-filter-clear class="-mx-1.5 inline-flex h-8 cursor-pointer items-center rounded-md px-1.5 text-sm font-medium underline decoration-foreground/30 underline-offset-4 transition-colors hover:decoration-foreground active:bg-pressed active:decoration-2 disabled:pointer-events-none disabled:no-underline disabled:opacity-50">
Clear
</button>
</div>
</div>
</div>
</div><script type="module" src="/caseroom/r/html/filter-bar.js"></script>