Listings
Listings Filter
A page title, then a filter card that stays in view beside the listings from 1024px and folds away below; chips narrow the grid.
Layout and motion from the Findit template — see it in context"use client";
import * as React from "react";
import Link from "next/link";
import { FilterPanel } from "@/components/ui/filter-panel";
import { ZoomCard } from "@/components/ui/zoom-card";
import { cn } from "@/lib/utils";
/*
* Listings Filter — a page title, then a filter card beside the listings: chips by type, category and location
* narrow the grid of full listing cards.
* Source: marcframe/findit → components/sections/PropertiesSection.tsx (+ design-system PropertyFilter, PropertyCard
* complete).
*
* Layout kept from Framer: the lead sits beside the heading from 768px, aligned to its bottom (up to 400px). From
* 1024px the filter card (342px) stays in view while the list scrolls beside it (sticky, 120px from the top) and the
* cards sit in two columns; below 1024px the card spans the width above the list (two columns from 768px, one below)
* and its filters fold away behind a round button between two lines.
* Motion kept (filter-panel): opening the filters grows them by height (spring bounce 0, 0.4s) and fades them in; chip
* colors ease over 0.4s; hovering a card grows its photo to 105% (zoom-card).
* Changed: Findit makes every filter a page of its own; here the chips filter the list in place (one choice per group,
* "All" clears it) — for filter pages, give filter-panel links instead. The card title follows the choices.
* Dropped: Findit's dotted, uppercase label.
*/
export type FilterListing = {
title: string;
href: string;
image: { src: string; alt: string };
/** "For sale", "For rent"… — filtered by the type group. */
type: string;
/** Filtered by the category group; the chip links to `href`. */
category: { label: string; href?: string };
/** Filtered by the location group. */
location: string;
beds: number;
baths: number;
/** Floor area with its unit, e.g. "1,850 sqft". */
area: string;
agent?: { name: string; image: string; href?: string };
/** Formatted, e.g. "$1,250,000" or "$2,400/mo". */
price: string;
};
type Field = "type" | "category" | "location";
export type ListingsFilterProps = {
/** Heading level: h1 when the block opens the page (a listings page). */
as?: "h1" | "h2";
/** Small line above the heading. */
label?: string;
title?: string;
lead?: string;
/** Start of the filter card's title; the current choices follow it ("Explore: Houses"). */
filterTitle?: string;
filterText?: string;
/** Group labels; each group lists the values found in the listings. */
groupLabels?: Record<Field, string>;
allLabel?: string;
emptyText?: string;
/** Read out by screen readers when the list changes. */
countText?: (count: number) => string;
listings?: FilterListing[];
/** Distance from the top of the window while the card is stuck, in px. */
stickyTop?: number;
className?: string;
};
const photo = (id: string, w = 900) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=${w}&q=80`;
const face = (id: string) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=96&h=96&q=80`;
const agents = {
nora: { name: "Nora Lane", image: face("photo-1573496359142-b8d87734a5a2"), href: "/agents/nora-lane" },
sam: { name: "Sam Porter", image: face("photo-1560250097-0b93528c311a"), href: "/agents/sam-porter" },
maya: { name: "Maya Collins", image: face("photo-1534528741775-53994a69daeb"), href: "/agents/maya-collins" },
};
const houses = { label: "Houses", href: "/properties?category=houses" };
const apartments = { label: "Apartments", href: "/properties?category=apartments" };
const villas = { label: "Villas", href: "/properties?category=villas" };
const defaults = {
label: "Properties",
title: "Find a place that fits",
lead: "Every home here was visited by one of our agents. Narrow the list by type, category or area.",
filterTitle: "Explore:",
filterText: "Choose what you're after — the list follows as you pick.",
groupLabels: { type: "Type", category: "Category", location: "Location" },
allLabel: "All",
emptyText: "No listings match these filters yet. Try another area or category.",
listings: [
{
title: "White Pool House",
href: "/properties/white-pool-house",
image: { src: photo("photo-1512917774080-9991f1c4c750"), alt: "White house with a swimming pool" },
type: "For sale",
category: villas,
location: "Coral Hills",
beds: 5,
baths: 4,
area: "3,900 sqft",
agent: agents.maya,
price: "$1,950,000",
},
{
title: "Plant-Filled Apartment",
href: "/properties/plant-filled-apartment",
image: { src: photo("photo-1502672260266-1c1ef2d93688"), alt: "Living room with plants and a grey sofa" },
type: "For rent",
category: apartments,
location: "Old Town",
beds: 2,
baths: 1,
area: "860 sqft",
agent: agents.sam,
price: "$2,200/mo",
},
{
title: "Minimal White House",
href: "/properties/minimal-white-house",
image: { src: photo("photo-1523217582562-09d0def993a6"), alt: "White minimalist house with large windows" },
type: "For sale",
category: houses,
location: "Lakeside",
beds: 3,
baths: 2,
area: "1,980 sqft",
agent: agents.nora,
price: "$780,000",
},
{
title: "Open Plan Loft",
href: "/properties/open-plan-loft",
image: { src: photo("photo-1522708323590-d24dbb6b0267"), alt: "Open living room with a red armchair" },
type: "For rent",
category: apartments,
location: "Downtown",
beds: 1,
baths: 1,
area: "720 sqft",
agent: agents.maya,
price: "$2,950/mo",
},
{
title: "Porch House on Elm",
href: "/properties/porch-house-elm",
image: { src: photo("photo-1570129477492-45c003edd2be"), alt: "White house with a wraparound porch and a green lawn" },
type: "For sale",
category: houses,
location: "Maple Grove",
beds: 4,
baths: 3,
area: "2,400 sqft",
agent: agents.nora,
price: "$845,000",
},
{
title: "Palm Villa Retreat",
href: "/properties/palm-villa-retreat",
image: { src: photo("photo-1564013799919-ab600027ffc6"), alt: "White house with a pool and palm trees" },
type: "For rent",
category: villas,
location: "Coral Hills",
beds: 4,
baths: 4,
area: "3,300 sqft",
agent: agents.sam,
price: "$7,900/mo",
},
] as FilterListing[],
};
const valueOf = (listing: FilterListing, field: Field) => (field === "category" ? listing.category.label : listing[field]);
export function ListingsFilter({
as: Heading = "h1",
label = defaults.label,
title = defaults.title,
lead = defaults.lead,
filterTitle = defaults.filterTitle,
filterText = defaults.filterText,
groupLabels = defaults.groupLabels,
allLabel = defaults.allLabel,
emptyText = defaults.emptyText,
countText = (count) => (count === 1 ? "1 listing" : `${count} listings`),
listings = defaults.listings,
stickyTop = 120,
className,
}: ListingsFilterProps) {
const [chosen, setChosen] = React.useState<Partial<Record<Field, string>>>({});
const fields: Field[] = ["type", "category", "location"];
const shown = listings.filter((listing) => fields.every((field) => !chosen[field] || valueOf(listing, field) === chosen[field]));
const current = fields.map((field) => chosen[field]).filter(Boolean);
const groups = fields.map((field) => {
const values = [...new Set(listings.map((listing) => valueOf(listing, field)))];
const pick = (value?: string) => setChosen((previous) => ({ ...previous, [field]: value }));
return {
label: groupLabels[field],
items: [
{ label: allLabel, active: !chosen[field], onClick: () => pick(undefined) },
...values.map((value) => ({ label: value, active: chosen[field] === value, onClick: () => pick(value) })),
],
};
});
return (
<section className={cn("py-(--section-y)", className)}>
<div className="mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter)">
<div className="flex flex-col gap-5 md:flex-row md:items-end md:gap-12 lg:gap-24">
<div className="flex flex-col gap-2.5 md:flex-1">
{label && <p className="text-sm font-medium text-muted-foreground">{label}</p>}
<Heading className="font-heading text-h1 font-semibold tracking-tight text-balance">{title}</Heading>
</div>
{lead && <p className="max-w-100 text-pretty text-muted-foreground md:flex-1">{lead}</p>}
</div>
<div className="flex flex-col gap-12 lg:flex-row lg:items-start">
<FilterPanel
title={`${filterTitle} ${current.length ? current.join(" · ") : allLabel}`}
description={filterText}
groups={groups}
stickyTop={stickyTop}
className="lg:w-85.5 lg:shrink-0"
/>
<div className="min-w-0 flex-1">
<p role="status" className="sr-only">
{countText(shown.length)}
</p>
{shown.length > 0 ? (
<div className="grid gap-5 md:grid-cols-2">
{shown.map((listing) => (
<ListingCard key={listing.href} listing={listing} />
))}
</div>
) : (
<p className="rounded-xl bg-muted p-7.5 text-foreground/65">{emptyText}</p>
)}
</div>
</div>
</div>
</section>
);
}
function ListingCard({ listing }: { listing: FilterListing }) {
return (
<article className="flex flex-col overflow-hidden rounded-xl bg-muted">
<div className="relative h-87.5">
<Link href={listing.href} tabIndex={-1} aria-hidden className="absolute inset-0 block">
<ZoomCard className="size-full rounded-xl">
<img src={listing.image.src} alt={listing.image.alt} className="object-cover" />
</ZoomCard>
</Link>
<div className="absolute top-5 left-5 flex items-center gap-1.5">
{listing.category.href ? (
<Link
href={listing.category.href}
className="rounded-full bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{listing.category.label}
</Link>
) : (
<span className="rounded-full bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground">
{listing.category.label}
</span>
)}
<span className="rounded-full bg-background px-3 py-1.5 text-xs font-medium text-foreground">{listing.type}</span>
</div>
</div>
<div className="flex flex-1 flex-col gap-5 p-5">
<div className="flex flex-col gap-2.5">
<p className="flex items-center gap-1.5 text-sm text-foreground/65">
<PinIcon />
<span className="truncate">{listing.location}</span>
</p>
<Link href={listing.href} className="flex flex-col gap-4 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
<h3 className="font-heading text-lg font-semibold tracking-tight">{listing.title}</h3>
<dl className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-foreground/65">
<Figure icon={<BedIcon />} term="Beds" value={listing.beds} />
<Figure icon={<BathIcon />} term="Baths" value={listing.baths} />
<Figure icon={<AreaIcon />} term="Area" value={listing.area} />
</dl>
</Link>
</div>
<div className="mt-auto flex items-center justify-between gap-2.5 border-t pt-5">
{listing.agent ? <Agent agent={listing.agent} /> : <span />}
<p className="font-heading text-lg font-semibold tracking-tight whitespace-nowrap">{listing.price}</p>
</div>
</div>
</article>
);
}
function Agent({ agent }: { agent: NonNullable<FilterListing["agent"]> }) {
const content = (
<>
<img src={agent.image} alt="" className="size-10 shrink-0 rounded-full bg-background object-cover" />
<span className="truncate">{agent.name}</span>
</>
);
const classes = "flex min-w-0 items-center gap-2.5 text-sm";
return agent.href ? (
<Link href={agent.href} className={cn(classes, "outline-none hover:underline focus-visible:ring-[3px] focus-visible:ring-ring/50")}>
{content}
</Link>
) : (
<span className={classes}>{content}</span>
);
}
function Figure({ icon, term, value }: { icon: React.ReactNode; term: string; value: React.ReactNode }) {
return (
<div className="flex items-center gap-1">
{icon}
<dt>{term}:</dt>
<dd className="text-foreground">{value}</dd>
</div>
);
}
const iconProps = {
"aria-hidden": true,
viewBox: "0 0 20 20",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round",
strokeLinejoin: "round",
className: "size-4.5 shrink-0",
} as const;
function PinIcon() {
return (
<svg {...iconProps}>
<path d="M10 17.5s5.5-4.6 5.5-9.25a5.5 5.5 0 0 0-11 0C4.5 12.9 10 17.5 10 17.5z" />
<circle cx="10" cy="8.25" r="2" />
</svg>
);
}
function BedIcon() {
return (
<svg {...iconProps}>
<path d="M2.5 15.5v-10M2.5 12.5h15v3M17.5 12.5v-2.5a2.5 2.5 0 0 0-2.5-2.5H9v5M5.75 10a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5z" />
</svg>
);
}
function BathIcon() {
return (
<svg {...iconProps}>
<path d="M2.5 10h15v2a4 4 0 0 1-4 4h-7a4 4 0 0 1-4-4v-2zM5 10V4.75A1.75 1.75 0 0 1 8.25 3.9M6 16l-1 1.5M14 16l1 1.5" />
</svg>
);
}
function AreaIcon() {
return (
<svg {...iconProps}>
<path d="M3 7V3h4M13 3h4v4M17 13v4h-4M7 17H3v-4" />
</svg>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/blocks/listings-filter.json1. Add the Framebrick UI components it uses (they bring Motion).
npx shadcn@latest add https://framebrick.co/r/filter-panel.json https://framebrick.co/r/zoom-card.json2. Add the block tokens to
app/globals.cssonce (theming).npx shadcn@latest add https://framebrick.co/r/blocks/tokens.json3. Copy the block into
components/listings-filter.tsx."use client"; import * as React from "react"; import Link from "next/link"; import { FilterPanel } from "@/components/ui/filter-panel"; import { ZoomCard } from "@/components/ui/zoom-card"; import { cn } from "@/lib/utils"; /* * Listings Filter — a page title, then a filter card beside the listings: chips by type, category and location * narrow the grid of full listing cards. * Source: marcframe/findit → components/sections/PropertiesSection.tsx (+ design-system PropertyFilter, PropertyCard * complete). * * Layout kept from Framer: the lead sits beside the heading from 768px, aligned to its bottom (up to 400px). From * 1024px the filter card (342px) stays in view while the list scrolls beside it (sticky, 120px from the top) and the * cards sit in two columns; below 1024px the card spans the width above the list (two columns from 768px, one below) * and its filters fold away behind a round button between two lines. * Motion kept (filter-panel): opening the filters grows them by height (spring bounce 0, 0.4s) and fades them in; chip * colors ease over 0.4s; hovering a card grows its photo to 105% (zoom-card). * Changed: Findit makes every filter a page of its own; here the chips filter the list in place (one choice per group, * "All" clears it) — for filter pages, give filter-panel links instead. The card title follows the choices. * Dropped: Findit's dotted, uppercase label. */ export type FilterListing = { title: string; href: string; image: { src: string; alt: string }; /** "For sale", "For rent"… — filtered by the type group. */ type: string; /** Filtered by the category group; the chip links to `href`. */ category: { label: string; href?: string }; /** Filtered by the location group. */ location: string; beds: number; baths: number; /** Floor area with its unit, e.g. "1,850 sqft". */ area: string; agent?: { name: string; image: string; href?: string }; /** Formatted, e.g. "$1,250,000" or "$2,400/mo". */ price: string; }; type Field = "type" | "category" | "location"; export type ListingsFilterProps = { /** Heading level: h1 when the block opens the page (a listings page). */ as?: "h1" | "h2"; /** Small line above the heading. */ label?: string; title?: string; lead?: string; /** Start of the filter card's title; the current choices follow it ("Explore: Houses"). */ filterTitle?: string; filterText?: string; /** Group labels; each group lists the values found in the listings. */ groupLabels?: Record<Field, string>; allLabel?: string; emptyText?: string; /** Read out by screen readers when the list changes. */ countText?: (count: number) => string; listings?: FilterListing[]; /** Distance from the top of the window while the card is stuck, in px. */ stickyTop?: number; className?: string; }; const photo = (id: string, w = 900) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=${w}&q=80`; const face = (id: string) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=96&h=96&q=80`; const agents = { nora: { name: "Nora Lane", image: face("photo-1573496359142-b8d87734a5a2"), href: "/agents/nora-lane" }, sam: { name: "Sam Porter", image: face("photo-1560250097-0b93528c311a"), href: "/agents/sam-porter" }, maya: { name: "Maya Collins", image: face("photo-1534528741775-53994a69daeb"), href: "/agents/maya-collins" }, }; const houses = { label: "Houses", href: "/properties?category=houses" }; const apartments = { label: "Apartments", href: "/properties?category=apartments" }; const villas = { label: "Villas", href: "/properties?category=villas" }; const defaults = { label: "Properties", title: "Find a place that fits", lead: "Every home here was visited by one of our agents. Narrow the list by type, category or area.", filterTitle: "Explore:", filterText: "Choose what you're after — the list follows as you pick.", groupLabels: { type: "Type", category: "Category", location: "Location" }, allLabel: "All", emptyText: "No listings match these filters yet. Try another area or category.", listings: [ { title: "White Pool House", href: "/properties/white-pool-house", image: { src: photo("photo-1512917774080-9991f1c4c750"), alt: "White house with a swimming pool" }, type: "For sale", category: villas, location: "Coral Hills", beds: 5, baths: 4, area: "3,900 sqft", agent: agents.maya, price: "$1,950,000", }, { title: "Plant-Filled Apartment", href: "/properties/plant-filled-apartment", image: { src: photo("photo-1502672260266-1c1ef2d93688"), alt: "Living room with plants and a grey sofa" }, type: "For rent", category: apartments, location: "Old Town", beds: 2, baths: 1, area: "860 sqft", agent: agents.sam, price: "$2,200/mo", }, { title: "Minimal White House", href: "/properties/minimal-white-house", image: { src: photo("photo-1523217582562-09d0def993a6"), alt: "White minimalist house with large windows" }, type: "For sale", category: houses, location: "Lakeside", beds: 3, baths: 2, area: "1,980 sqft", agent: agents.nora, price: "$780,000", }, { title: "Open Plan Loft", href: "/properties/open-plan-loft", image: { src: photo("photo-1522708323590-d24dbb6b0267"), alt: "Open living room with a red armchair" }, type: "For rent", category: apartments, location: "Downtown", beds: 1, baths: 1, area: "720 sqft", agent: agents.maya, price: "$2,950/mo", }, { title: "Porch House on Elm", href: "/properties/porch-house-elm", image: { src: photo("photo-1570129477492-45c003edd2be"), alt: "White house with a wraparound porch and a green lawn" }, type: "For sale", category: houses, location: "Maple Grove", beds: 4, baths: 3, area: "2,400 sqft", agent: agents.nora, price: "$845,000", }, { title: "Palm Villa Retreat", href: "/properties/palm-villa-retreat", image: { src: photo("photo-1564013799919-ab600027ffc6"), alt: "White house with a pool and palm trees" }, type: "For rent", category: villas, location: "Coral Hills", beds: 4, baths: 4, area: "3,300 sqft", agent: agents.sam, price: "$7,900/mo", }, ] as FilterListing[], }; const valueOf = (listing: FilterListing, field: Field) => (field === "category" ? listing.category.label : listing[field]); export function ListingsFilter({ as: Heading = "h1", label = defaults.label, title = defaults.title, lead = defaults.lead, filterTitle = defaults.filterTitle, filterText = defaults.filterText, groupLabels = defaults.groupLabels, allLabel = defaults.allLabel, emptyText = defaults.emptyText, countText = (count) => (count === 1 ? "1 listing" : `${count} listings`), listings = defaults.listings, stickyTop = 120, className, }: ListingsFilterProps) { const [chosen, setChosen] = React.useState<Partial<Record<Field, string>>>({}); const fields: Field[] = ["type", "category", "location"]; const shown = listings.filter((listing) => fields.every((field) => !chosen[field] || valueOf(listing, field) === chosen[field])); const current = fields.map((field) => chosen[field]).filter(Boolean); const groups = fields.map((field) => { const values = [...new Set(listings.map((listing) => valueOf(listing, field)))]; const pick = (value?: string) => setChosen((previous) => ({ ...previous, [field]: value })); return { label: groupLabels[field], items: [ { label: allLabel, active: !chosen[field], onClick: () => pick(undefined) }, ...values.map((value) => ({ label: value, active: chosen[field] === value, onClick: () => pick(value) })), ], }; }); return ( <section className={cn("py-(--section-y)", className)}> <div className="mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter)"> <div className="flex flex-col gap-5 md:flex-row md:items-end md:gap-12 lg:gap-24"> <div className="flex flex-col gap-2.5 md:flex-1"> {label && <p className="text-sm font-medium text-muted-foreground">{label}</p>} <Heading className="font-heading text-h1 font-semibold tracking-tight text-balance">{title}</Heading> </div> {lead && <p className="max-w-100 text-pretty text-muted-foreground md:flex-1">{lead}</p>} </div> <div className="flex flex-col gap-12 lg:flex-row lg:items-start"> <FilterPanel title={`${filterTitle} ${current.length ? current.join(" · ") : allLabel}`} description={filterText} groups={groups} stickyTop={stickyTop} className="lg:w-85.5 lg:shrink-0" /> <div className="min-w-0 flex-1"> <p role="status" className="sr-only"> {countText(shown.length)} </p> {shown.length > 0 ? ( <div className="grid gap-5 md:grid-cols-2"> {shown.map((listing) => ( <ListingCard key={listing.href} listing={listing} /> ))} </div> ) : ( <p className="rounded-xl bg-muted p-7.5 text-foreground/65">{emptyText}</p> )} </div> </div> </div> </section> ); } function ListingCard({ listing }: { listing: FilterListing }) { return ( <article className="flex flex-col overflow-hidden rounded-xl bg-muted"> <div className="relative h-87.5"> <Link href={listing.href} tabIndex={-1} aria-hidden className="absolute inset-0 block"> <ZoomCard className="size-full rounded-xl"> <img src={listing.image.src} alt={listing.image.alt} className="object-cover" /> </ZoomCard> </Link> <div className="absolute top-5 left-5 flex items-center gap-1.5"> {listing.category.href ? ( <Link href={listing.category.href} className="rounded-full bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > {listing.category.label} </Link> ) : ( <span className="rounded-full bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground"> {listing.category.label} </span> )} <span className="rounded-full bg-background px-3 py-1.5 text-xs font-medium text-foreground">{listing.type}</span> </div> </div> <div className="flex flex-1 flex-col gap-5 p-5"> <div className="flex flex-col gap-2.5"> <p className="flex items-center gap-1.5 text-sm text-foreground/65"> <PinIcon /> <span className="truncate">{listing.location}</span> </p> <Link href={listing.href} className="flex flex-col gap-4 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"> <h3 className="font-heading text-lg font-semibold tracking-tight">{listing.title}</h3> <dl className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-foreground/65"> <Figure icon={<BedIcon />} term="Beds" value={listing.beds} /> <Figure icon={<BathIcon />} term="Baths" value={listing.baths} /> <Figure icon={<AreaIcon />} term="Area" value={listing.area} /> </dl> </Link> </div> <div className="mt-auto flex items-center justify-between gap-2.5 border-t pt-5"> {listing.agent ? <Agent agent={listing.agent} /> : <span />} <p className="font-heading text-lg font-semibold tracking-tight whitespace-nowrap">{listing.price}</p> </div> </div> </article> ); } function Agent({ agent }: { agent: NonNullable<FilterListing["agent"]> }) { const content = ( <> <img src={agent.image} alt="" className="size-10 shrink-0 rounded-full bg-background object-cover" /> <span className="truncate">{agent.name}</span> </> ); const classes = "flex min-w-0 items-center gap-2.5 text-sm"; return agent.href ? ( <Link href={agent.href} className={cn(classes, "outline-none hover:underline focus-visible:ring-[3px] focus-visible:ring-ring/50")}> {content} </Link> ) : ( <span className={classes}>{content}</span> ); } function Figure({ icon, term, value }: { icon: React.ReactNode; term: string; value: React.ReactNode }) { return ( <div className="flex items-center gap-1"> {icon} <dt>{term}:</dt> <dd className="text-foreground">{value}</dd> </div> ); } const iconProps = { "aria-hidden": true, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", className: "size-4.5 shrink-0", } as const; function PinIcon() { return ( <svg {...iconProps}> <path d="M10 17.5s5.5-4.6 5.5-9.25a5.5 5.5 0 0 0-11 0C4.5 12.9 10 17.5 10 17.5z" /> <circle cx="10" cy="8.25" r="2" /> </svg> ); } function BedIcon() { return ( <svg {...iconProps}> <path d="M2.5 15.5v-10M2.5 12.5h15v3M17.5 12.5v-2.5a2.5 2.5 0 0 0-2.5-2.5H9v5M5.75 10a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5z" /> </svg> ); } function BathIcon() { return ( <svg {...iconProps}> <path d="M2.5 10h15v2a4 4 0 0 1-4 4h-7a4 4 0 0 1-4-4v-2zM5 10V4.75A1.75 1.75 0 0 1 8.25 3.9M6 16l-1 1.5M14 16l1 1.5" /> </svg> ); } function AreaIcon() { return ( <svg {...iconProps}> <path d="M3 7V3h4M13 3h4v4M17 13v4h-4M7 17H3v-4" /> </svg> ); }
Usage
import { ListingsFilter } from "@/components/listings-filter";
<ListingsFilter
title="Find a place that fits"
listings={[
{
title: "White Pool House",
href: "/properties/white-pool-house",
image: { src: "/listings/pool-house.jpg", alt: "…" },
type: "For sale",
category: { label: "Villas", href: "/properties?category=villas" },
location: "Coral Hills",
beds: 5,
baths: 4,
area: "3,900 sqft",
price: "$1,950,000",
},
]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| as | "h1" | "h2" | "h1" | Heading level — h1 when the block opens a listings page. |
| label / title / lead | string | — | Muted line, heading (text-h1) and the lead beside it from 768px. |
| filterTitle / filterText | string | Explore: | The filter card's title — the current choices follow it — and its text. |
| groupLabels | { type, category, location } | Type, Category, Location | Group titles; each group lists the values found in the listings, after an "All" chip. |
| listings | FilterListing[] | 6 listings | { title, href, image, type, category: { label, href? }, location, beds, baths, area, agent?, price }. |
| emptyText | string | — | Shown when no listing matches. |
| countText | (count) => string | "6 listings" | Read out by screen readers when the list changes. |
| stickyTop | number | 120 | Distance from the top of the window while the card is stuck — the height of your sticky header plus a gap. |
| className | string | — | Classes for the <section>. |
- From 1024px the 342px filter card sticks while the two-column list scrolls beside it; below 1024px it spans the width above the list and its filters fold away behind a round button (filter-panel: height spring bounce 0, 0.4s, then a fade).
- One choice per group; "All" clears it. Findit makes every filter a page of its own — for that, render filter-panel with links (href and active) instead.
- Hovering a card grows its photo to 105% (zoom-card).
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this block in your own project.
Create a React + Tailwind CSS block called ListingsFilter (Next.js, client component). - <section className="py-(--section-y)">, container mx-auto max-w-(--container) px-(--gutter), column gap-12. - Split title: a small muted label and an h1 (font-heading text-h1 semibold); beside it from 768px (items-end) a muted lead up to 400px. - Below, a column that becomes a row from 1024px (items-start, gap-12): a filter card (rounded-lg border bg-card p-5; lg: 342px wide, sticky 120px from the top) with a title "Explore: <choices>", a muted text and three chip groups (Type, Category, Location; an uppercase muted h3 each, then rounded-full chips, the chosen one bg-primary). Below 1024px the groups fold away behind a round primary button between two lines and open by height with a spring (bounce 0, 0.4s) and a fade. - Choosing a chip filters the list in place (one choice per group; "All" clears it); a visually hidden role="status" says the count. - The list: 1 / 2 (768px) columns of full listing cards (rounded-xl bg-muted; a 350px photo zooming to 1.05 on hover, category and type chips; location with a pin, h3 title, Beds / Baths / Area, a border-t row with the agent and the price). An empty message on bg-muted when none match. - Only shadcn variables and block tokens (secondary text on bg-muted is text-foreground/65: muted-foreground falls under 4.5:1 there); plain <img> with alt text.











