Browse the library
Components / Forms
Listing filters
FreeHome search filters with location search, type chips, beds and baths, dual price slider and result count.
components/facade/listing-filters.tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ListingFiltersValue {
query: string;
types: string[];
/** Minimum bedrooms, 0 means any. */
beds: number;
/** Minimum bathrooms, 0 means any. */
baths: number;
price: [number, number];
}
export interface ListingFiltersProps {
propertyTypes?: string[];
priceMin?: number;
priceMax?: number;
priceStep?: number;
defaultValue?: Partial<ListingFiltersValue>;
/** Number of results for the current filters. Pass a function to compute it from the value. */
resultCount?: number | ((value: ListingFiltersValue) => number);
searchPlaceholder?: string;
onChange?: (value: ListingFiltersValue) => void;
className?: string;
}
const compactUsd = (n: number) => {
// Number() drops trailing zeros without eating the digits of whole millions ($10M stays $10M).
if (n >= 1_000_000) return `$${Number((n / 1_000_000).toFixed(2))}M`;
if (n >= 1000) return `$${Math.round(n / 1000)}K`;
return `$${n}`;
};
const bedOptions = [0, 1, 2, 3, 4];
const bathOptions = [0, 1, 2, 3];
function SegmentGroup({
label,
options,
value,
onChange,
layoutId,
}: {
label: string;
options: number[];
value: number;
onChange: (v: number) => void;
layoutId: string;
}) {
const reduce = useReducedMotion();
const labelId = `${layoutId}-label`;
const groupRef = React.useRef<HTMLDivElement>(null);
// Roving tabindex: one tab stop per group, arrow keys move the selection.
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const idx = options.indexOf(value);
let next: number | undefined;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = options[(idx + 1) % options.length];
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = options[(idx - 1 + options.length) % options.length];
else if (e.key === "Home") next = options[0];
else if (e.key === "End") next = options[options.length - 1];
if (next === undefined) return;
e.preventDefault();
onChange(next);
groupRef.current?.querySelectorAll<HTMLButtonElement>("[role=radio]")[options.indexOf(next)]?.focus();
};
return (
<div className="space-y-2">
<p id={labelId} className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
</p>
<div
ref={groupRef}
role="radiogroup"
aria-labelledby={labelId}
onKeyDown={onKeyDown}
className="flex rounded-xl border border-border bg-background p-1"
>
{options.map((o) => {
const active = value === o;
return (
<button
key={o}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onClick={() => onChange(o)}
className={cn(
"relative flex-1 rounded-lg px-2 py-1.5 text-sm font-medium tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{active && (
<motion.span
layoutId={layoutId}
className="absolute inset-0 rounded-lg bg-primary"
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 450, damping: 36 }}
/>
)}
<span className="relative">{o === 0 ? "Any" : `${o}+`}</span>
</button>
);
})}
</div>
</div>
);
}
function RangeSlider({
min,
max,
step,
value,
onChange,
id,
}: {
min: number;
max: number;
step: number;
value: [number, number];
onChange: (v: [number, number]) => void;
id: string;
}) {
const trackRef = React.useRef<HTMLDivElement>(null);
const [dragging, setDragging] = React.useState<0 | 1 | null>(null);
const pct = (v: number) => ((v - min) / (max - min)) * 100;
const snap = (v: number) => Math.min(max, Math.max(min, Math.round((v - min) / step) * step + min));
const setThumb = (thumb: 0 | 1, raw: number) => {
const v = snap(raw);
if (thumb === 0) onChange([Math.min(v, value[1] - step), value[1]]);
else onChange([value[0], Math.max(v, value[0] + step)]);
};
const valueFromClient = (clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect) return min;
return min + ((clientX - rect.left) / rect.width) * (max - min);
};
const onTrackDown = (e: React.PointerEvent<HTMLDivElement>) => {
const v = valueFromClient(e.clientX);
const thumb: 0 | 1 = Math.abs(v - value[0]) <= Math.abs(v - value[1]) ? 0 : 1;
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(thumb);
setThumb(thumb, v);
const btn = trackRef.current?.querySelectorAll<HTMLElement>("[role=slider]")[thumb];
btn?.focus({ preventScroll: true });
};
const onKey = (thumb: 0 | 1) => (e: React.KeyboardEvent<HTMLDivElement>) => {
const big = step * 10;
const current = value[thumb];
let next: number | null = null;
if (e.key === "ArrowLeft" || e.key === "ArrowDown") next = current - step;
else if (e.key === "ArrowRight" || e.key === "ArrowUp") next = current + step;
else if (e.key === "PageDown") next = current - big;
else if (e.key === "PageUp") next = current + big;
else if (e.key === "Home") next = thumb === 0 ? min : value[0] + step;
else if (e.key === "End") next = thumb === 0 ? value[1] - step : max;
if (next !== null) {
e.preventDefault();
setThumb(thumb, next);
}
};
const fmt = (v: number) => (v >= max ? `${compactUsd(v)}+` : compactUsd(v));
return (
<div
ref={trackRef}
className="relative h-6 touch-none select-none"
onPointerDown={onTrackDown}
onPointerMove={(e) => {
if (dragging !== null) setThumb(dragging, valueFromClient(e.clientX));
}}
onPointerUp={() => setDragging(null)}
onPointerCancel={() => setDragging(null)}
>
<div className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted" />
<div
className="absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-brand"
style={{ left: `${pct(value[0])}%`, right: `${100 - pct(value[1])}%` }}
/>
{([0, 1] as const).map((thumb) => (
<div
key={thumb}
role="slider"
tabIndex={0}
id={thumb === 0 ? `${id}-min` : `${id}-max`}
aria-label={thumb === 0 ? "Minimum price" : "Maximum price"}
aria-valuemin={thumb === 0 ? min : value[0] + step}
aria-valuemax={thumb === 0 ? value[1] - step : max}
aria-valuenow={value[thumb]}
aria-valuetext={fmt(value[thumb])}
onKeyDown={onKey(thumb)}
className={cn(
"absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full border-2 border-brand bg-background shadow-md transition-[transform,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/30",
dragging === thumb ? "scale-110 cursor-grabbing" : "hover:scale-110",
)}
style={{ left: `${pct(value[thumb])}%`, zIndex: dragging === thumb ? 2 : 1 }}
/>
))}
</div>
);
}
export function ListingFilters({
propertyTypes = ["House", "Condo", "Townhome", "Multi-family", "Land"],
priceMin = 0,
priceMax = 2_000_000,
priceStep = 25_000,
defaultValue,
resultCount,
searchPlaceholder = "City, neighborhood, ZIP or address",
onChange,
className,
}: ListingFiltersProps) {
const uid = React.useId();
const reduce = useReducedMotion();
const empty: ListingFiltersValue = { query: "", types: [], beds: 0, baths: 0, price: [priceMin, priceMax] };
const [value, setValue] = React.useState<ListingFiltersValue>(() => ({
query: "",
types: [],
beds: 0,
baths: 0,
price: [priceMin, priceMax],
...defaultValue,
}));
const onChangeRef = React.useRef(onChange);
React.useEffect(() => {
onChangeRef.current = onChange;
});
const update = (patch: Partial<ListingFiltersValue>) => {
// Compute outside the updater so onChange is not called twice under StrictMode.
const next = { ...value, ...patch };
setValue(next);
onChangeRef.current?.(next);
};
const activeCount =
(value.query ? 1 : 0) +
value.types.length +
(value.beds ? 1 : 0) +
(value.baths ? 1 : 0) +
(value.price[0] !== priceMin || value.price[1] !== priceMax ? 1 : 0);
const count = typeof resultCount === "function" ? resultCount(value) : resultCount;
const priceLabel = `${compactUsd(value.price[0])} to ${value.price[1] >= priceMax ? `${compactUsd(priceMax)}+` : compactUsd(value.price[1])}`;
return (
<div className={cn("w-full rounded-2xl border border-border bg-card p-4 text-card-foreground sm:p-5", className)}>
<div className="relative">
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<input
type="search"
aria-label="Search location"
placeholder={searchPlaceholder}
value={value.query}
onChange={(e) => update({ query: e.target.value })}
className="h-11 w-full rounded-xl border border-input bg-background pl-10 pr-10 text-sm text-foreground transition-colors placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 [&::-webkit-search-cancel-button]:hidden"
/>
<AnimatePresence>
{value.query && (
<motion.button
type="button"
aria-label="Clear search"
onClick={() => update({ query: "" })}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
className="absolute right-2.5 top-1/2 grid size-6 -translate-y-1/2 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-3.5" />
</motion.button>
)}
</AnimatePresence>
</div>
<fieldset className="mt-5">
<legend className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">Home type</legend>
<div className="flex flex-wrap gap-2">
{propertyTypes.map((t) => {
const on = value.types.includes(t);
return (
<motion.button
key={t}
type="button"
aria-pressed={on}
onClick={() => update({ types: on ? value.types.filter((x) => x !== t) : [...value.types, t] })}
layout={!reduce}
transition={{ type: "spring", stiffness: 500, damping: 38 }}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
on
? "border-brand/40 bg-brand/10 text-foreground"
: "border-border bg-background text-muted-foreground hover:border-foreground/20 hover:text-foreground",
)}
>
<AnimatePresence initial={false}>
{on && (
<motion.span
initial={{ width: 0, opacity: 0 }}
animate={{ width: "auto", opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.2, ease: [0.22, 1, 0.36, 1] }}
className="inline-flex overflow-hidden"
>
<Check className="size-3.5 text-brand" strokeWidth={3} aria-hidden="true" />
</motion.span>
)}
</AnimatePresence>
{t}
</motion.button>
);
})}
</div>
</fieldset>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<SegmentGroup label="Beds" options={bedOptions} value={value.beds} onChange={(beds) => update({ beds })} layoutId={`${uid}-beds`} />
<SegmentGroup label="Baths" options={bathOptions} value={value.baths} onChange={(baths) => update({ baths })} layoutId={`${uid}-baths`} />
</div>
<div className="mt-5 space-y-3">
<div className="flex items-baseline justify-between">
<p id={`${uid}-price`} className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Price
</p>
<p className="font-mono text-sm tabular-nums text-foreground">{priceLabel}</p>
</div>
<div role="group" aria-labelledby={`${uid}-price`} className="px-2.5">
<RangeSlider
id={uid}
min={priceMin}
max={priceMax}
step={priceStep}
value={value.price}
onChange={(price) => update({ price })}
/>
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-3 border-t border-border pt-4">
<button
type="button"
onClick={() => {
setValue(empty);
onChangeRef.current?.(empty);
}}
disabled={activeCount === 0}
className="rounded-md text-sm font-medium text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
>
Clear all{activeCount > 0 && <span className="tabular-nums"> ({activeCount})</span>}
</button>
{count !== undefined && (
<p className="inline-flex h-10 items-center rounded-xl bg-primary px-4 text-sm font-medium text-primary-foreground" aria-live="polite">
<span className="relative inline-flex overflow-hidden tabular-nums">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={count}
initial={reduce ? false : { y: "100%", opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduce ? undefined : { y: "-100%", opacity: 0 }}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
>
{count.toLocaleString("en-US")}
</motion.span>
</AnimatePresence>
</span>
{count === 1 ? "home" : "homes"}
</p>
)}
</div>
</div>
);
}
export default ListingFilters;app/page.tsx
"use client";
import { ListingFilters, type ListingFiltersValue } from "@/components/facade/listing-filters";
// Deterministic mock count so the demo feels live without a backend.
function estimateResults(v: ListingFiltersValue) {
let n = 1284;
if (v.query.trim()) n *= 0.18;
if (v.types.length) n *= Math.min(1, 0.22 * v.types.length + 0.15);
n *= [1, 0.92, 0.74, 0.46, 0.21][v.beds] ?? 1;
n *= [1, 0.95, 0.62, 0.28][v.baths] ?? 1;
const span = (v.price[1] - v.price[0]) / 2_000_000;
n *= Math.max(0.04, span);
return Math.max(0, Math.round(n));
}
export default function Demo() {
return (
<div className="w-full max-w-[560px]">
<ListingFilters
defaultValue={{ query: "Austin, TX", types: ["House", "Townhome"], beds: 3, price: [450_000, 1_250_000] }}
resultCount={estimateResults}
/>
</div>
);
}Installation
$
npx shadcn@latest add https://facadeui.com/r/listing-filters.jsonUsage
import { ListingFilters } from "@/components/facade/listing-filters";
export default function Page() {
return <ListingFilters />;
}Props
ListingFiltersProps is exported, so you can extend or wrap it. Every prop has a default; <ListingFilters /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| propertyTypes | string[] | |
| priceMin | number | |
| priceMax | number | |
| priceStep | number | |
| defaultValue | Partial<ListingFiltersValue> | |
| resultCount | number | ((value: ListingFiltersValue) => number) | Number of results for the current filters. Pass a function to compute it from the value. |
| searchPlaceholder | string | |
| onChange | (value: ListingFiltersValue) => void | |
| className | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade listing filters to this page and adapt the copy to our business.