Listings
Listings Tabs
A split title, category tabs and full listing cards — location, beds, baths, area, agent and price — with a button to all listings.
Layout and motion from the Findit template — see it in context"use client";
import * as React from "react";
import Link from "next/link";
import { FlipButton } from "@/components/ui/flip-button";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { ZoomCard } from "@/components/ui/zoom-card";
import { cn } from "@/lib/utils";
/*
* Listings Tabs — a split title, category tabs, the newest listings of the chosen category as full cards (chips,
* location, beds / baths / area, agent and price) and a button to all of them.
* Source: marcframe/findit → components/sections/LatestSection.tsx (+ design-system PropertyCard complete, TabButton).
*
* Layout kept from Framer: the lead sits beside the heading from 768px, aligned to its bottom (up to 400px); the tabs,
* then the cards 20px apart in one, two (768px) and three (1024px) columns; up to six cards show. A card: a 350px photo
* with the category (a link) and the type as chips in its top-left corner, then the location, the title, the figures,
* a line, and the agent beside the price.
* Motion kept: hovering a card grows its photo to 105% (zoom-card); the list swaps at once when a tab is chosen, as in
* Framer. Changed: the tabs are a segmented control (the selected fill slides to the chosen tab); the button rolls its
* label (flip-button). Pass `categories={[]}` for a plain list (Findit's agent and related listings).
* Dropped: Findit's dotted, uppercase label.
*/
export type TabListing = {
title: string;
href: string;
image: { src: string; alt: string };
/** Matches a category `value`; the chip links to `category.href`. */
category: { value: string; label: string; href?: string };
/** "For sale", "For rent"… */
type?: string;
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;
};
export type ListingsTabsProps = {
/** Small line above the heading. */
label?: string;
title?: string;
lead?: string;
/** One tab per category after the "all" tab; empty for no tabs. */
categories?: { value: string; label: string }[];
allLabel?: string;
listings?: TabListing[];
/** How many cards show for a tab. */
limit?: number;
action?: { label: string; href: string };
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 = { value: "houses", label: "Houses", href: "/properties?category=houses" };
const apartments = { value: "apartments", label: "Apartments", href: "/properties?category=apartments" };
const villas = { value: "villas", label: "Villas", href: "/properties?category=villas" };
const defaults = {
label: "Latest properties",
title: "New on the market",
lead: "Fresh listings from our agents this week — homes to buy and to rent, checked and photographed by us.",
categories: [houses, apartments, villas].map(({ value, label }) => ({ value, label })),
allLabel: "View all",
listings: [
{
title: "Garden Porch House",
href: "/properties/garden-porch-house",
image: { src: photo("photo-1570129477492-45c003edd2be"), alt: "White house with a wraparound porch and a green lawn" },
category: houses,
type: "For sale",
location: "Maple Grove",
beds: 4,
baths: 3,
area: "2,400 sqft",
agent: agents.nora,
price: "$845,000",
},
{
title: "Bright Loft on Park Row",
href: "/properties/bright-loft-park-row",
image: { src: photo("photo-1560448204-e02f11c3d0e2"), alt: "Bright living room with tall windows" },
category: apartments,
type: "For rent",
location: "Downtown",
beds: 2,
baths: 1,
area: "980 sqft",
agent: agents.sam,
price: "$2,650/mo",
},
{
title: "Terrace Villa",
href: "/properties/terrace-villa",
image: { src: photo("photo-1580587771525-78b9dba3b914"), alt: "Modern house with terraces above a swimming pool" },
category: villas,
type: "For sale",
location: "Coral Hills",
beds: 5,
baths: 4,
area: "4,100 sqft",
agent: agents.maya,
price: "$2,300,000",
},
{
title: "Glass House by the Lake",
href: "/properties/glass-house-lake",
image: { src: photo("photo-1600585154340-be6161a56a0c"), alt: "Modern house with glass walls and a lawn at dusk" },
category: houses,
type: "For sale",
location: "Lakeside",
beds: 3,
baths: 2,
area: "2,050 sqft",
agent: agents.sam,
price: "$1,120,000",
},
{
title: "Blue Sofa Flat",
href: "/properties/blue-sofa-flat",
image: { src: photo("photo-1493809842364-78817add7ffb"), alt: "Living room with a blue sofa and white shelves" },
category: apartments,
type: "For rent",
location: "Old Town",
beds: 1,
baths: 1,
area: "640 sqft",
agent: agents.nora,
price: "$1,800/mo",
},
{
title: "Palm Court Villa",
href: "/properties/palm-court-villa",
image: { src: photo("photo-1564013799919-ab600027ffc6"), alt: "White house with a pool and palm trees" },
category: villas,
type: "For rent",
location: "Bayview",
beds: 4,
baths: 4,
area: "3,300 sqft",
agent: agents.maya,
price: "$7,900/mo",
},
{
title: "Cedar Family Home",
href: "/properties/cedar-family-home",
image: { src: photo("photo-1568605114967-8130f3a36994"), alt: "Wooden house with lit windows at dusk" },
category: houses,
type: "For sale",
location: "Cedar Falls",
beds: 4,
baths: 2,
area: "2,700 sqft",
agent: agents.nora,
price: "$690,000",
},
] as TabListing[],
action: { label: "View all properties", href: "/properties" },
};
const ALL = "__all";
export function ListingsTabs({
label = defaults.label,
title = defaults.title,
lead = defaults.lead,
categories = defaults.categories,
allLabel = defaults.allLabel,
listings = defaults.listings,
limit = 6,
action = defaults.action,
className,
}: ListingsTabsProps) {
const [active, setActive] = React.useState(ALL);
const shown = listings.filter((listing) => active === ALL || listing.category.value === active).slice(0, limit);
return (
<section className={cn("py-(--section-y)", className)}>
<div className="mx-auto flex max-w-(--container) flex-col items-start gap-12 px-(--gutter)">
<div className="flex w-full 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>}
<h2 className="font-heading text-h2 font-semibold tracking-tight text-balance">{title}</h2>
</div>
{lead && <p className="max-w-100 text-pretty text-muted-foreground md:flex-1">{lead}</p>}
</div>
<div className="flex w-full flex-col gap-7.5">
{categories.length > 0 && (
<div className="-m-1 max-w-[calc(100%+0.5rem)] overflow-x-auto p-1">
<SegmentedControl
label={label || title}
value={active}
onValueChange={setActive}
options={[{ value: ALL, label: allLabel }, ...categories]}
/>
</div>
)}
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
{shown.map((listing) => (
<ListingCard key={listing.href} listing={listing} />
))}
</div>
</div>
{action && <FlipButton href={action.href}>{action.label}</FlipButton>}
</div>
</section>
);
}
function ListingCard({ listing }: { listing: TabListing }) {
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>
)}
{listing.type && (
<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<TabListing["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-tabs.json1. Add the Framebrick UI components it uses (they bring Motion).
npx shadcn@latest add https://framebrick.co/r/segmented-control.json https://framebrick.co/r/zoom-card.json https://framebrick.co/r/flip-button.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-tabs.tsx."use client"; import * as React from "react"; import Link from "next/link"; import { FlipButton } from "@/components/ui/flip-button"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { ZoomCard } from "@/components/ui/zoom-card"; import { cn } from "@/lib/utils"; /* * Listings Tabs — a split title, category tabs, the newest listings of the chosen category as full cards (chips, * location, beds / baths / area, agent and price) and a button to all of them. * Source: marcframe/findit → components/sections/LatestSection.tsx (+ design-system PropertyCard complete, TabButton). * * Layout kept from Framer: the lead sits beside the heading from 768px, aligned to its bottom (up to 400px); the tabs, * then the cards 20px apart in one, two (768px) and three (1024px) columns; up to six cards show. A card: a 350px photo * with the category (a link) and the type as chips in its top-left corner, then the location, the title, the figures, * a line, and the agent beside the price. * Motion kept: hovering a card grows its photo to 105% (zoom-card); the list swaps at once when a tab is chosen, as in * Framer. Changed: the tabs are a segmented control (the selected fill slides to the chosen tab); the button rolls its * label (flip-button). Pass `categories={[]}` for a plain list (Findit's agent and related listings). * Dropped: Findit's dotted, uppercase label. */ export type TabListing = { title: string; href: string; image: { src: string; alt: string }; /** Matches a category `value`; the chip links to `category.href`. */ category: { value: string; label: string; href?: string }; /** "For sale", "For rent"… */ type?: string; 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; }; export type ListingsTabsProps = { /** Small line above the heading. */ label?: string; title?: string; lead?: string; /** One tab per category after the "all" tab; empty for no tabs. */ categories?: { value: string; label: string }[]; allLabel?: string; listings?: TabListing[]; /** How many cards show for a tab. */ limit?: number; action?: { label: string; href: string }; 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 = { value: "houses", label: "Houses", href: "/properties?category=houses" }; const apartments = { value: "apartments", label: "Apartments", href: "/properties?category=apartments" }; const villas = { value: "villas", label: "Villas", href: "/properties?category=villas" }; const defaults = { label: "Latest properties", title: "New on the market", lead: "Fresh listings from our agents this week — homes to buy and to rent, checked and photographed by us.", categories: [houses, apartments, villas].map(({ value, label }) => ({ value, label })), allLabel: "View all", listings: [ { title: "Garden Porch House", href: "/properties/garden-porch-house", image: { src: photo("photo-1570129477492-45c003edd2be"), alt: "White house with a wraparound porch and a green lawn" }, category: houses, type: "For sale", location: "Maple Grove", beds: 4, baths: 3, area: "2,400 sqft", agent: agents.nora, price: "$845,000", }, { title: "Bright Loft on Park Row", href: "/properties/bright-loft-park-row", image: { src: photo("photo-1560448204-e02f11c3d0e2"), alt: "Bright living room with tall windows" }, category: apartments, type: "For rent", location: "Downtown", beds: 2, baths: 1, area: "980 sqft", agent: agents.sam, price: "$2,650/mo", }, { title: "Terrace Villa", href: "/properties/terrace-villa", image: { src: photo("photo-1580587771525-78b9dba3b914"), alt: "Modern house with terraces above a swimming pool" }, category: villas, type: "For sale", location: "Coral Hills", beds: 5, baths: 4, area: "4,100 sqft", agent: agents.maya, price: "$2,300,000", }, { title: "Glass House by the Lake", href: "/properties/glass-house-lake", image: { src: photo("photo-1600585154340-be6161a56a0c"), alt: "Modern house with glass walls and a lawn at dusk" }, category: houses, type: "For sale", location: "Lakeside", beds: 3, baths: 2, area: "2,050 sqft", agent: agents.sam, price: "$1,120,000", }, { title: "Blue Sofa Flat", href: "/properties/blue-sofa-flat", image: { src: photo("photo-1493809842364-78817add7ffb"), alt: "Living room with a blue sofa and white shelves" }, category: apartments, type: "For rent", location: "Old Town", beds: 1, baths: 1, area: "640 sqft", agent: agents.nora, price: "$1,800/mo", }, { title: "Palm Court Villa", href: "/properties/palm-court-villa", image: { src: photo("photo-1564013799919-ab600027ffc6"), alt: "White house with a pool and palm trees" }, category: villas, type: "For rent", location: "Bayview", beds: 4, baths: 4, area: "3,300 sqft", agent: agents.maya, price: "$7,900/mo", }, { title: "Cedar Family Home", href: "/properties/cedar-family-home", image: { src: photo("photo-1568605114967-8130f3a36994"), alt: "Wooden house with lit windows at dusk" }, category: houses, type: "For sale", location: "Cedar Falls", beds: 4, baths: 2, area: "2,700 sqft", agent: agents.nora, price: "$690,000", }, ] as TabListing[], action: { label: "View all properties", href: "/properties" }, }; const ALL = "__all"; export function ListingsTabs({ label = defaults.label, title = defaults.title, lead = defaults.lead, categories = defaults.categories, allLabel = defaults.allLabel, listings = defaults.listings, limit = 6, action = defaults.action, className, }: ListingsTabsProps) { const [active, setActive] = React.useState(ALL); const shown = listings.filter((listing) => active === ALL || listing.category.value === active).slice(0, limit); return ( <section className={cn("py-(--section-y)", className)}> <div className="mx-auto flex max-w-(--container) flex-col items-start gap-12 px-(--gutter)"> <div className="flex w-full 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>} <h2 className="font-heading text-h2 font-semibold tracking-tight text-balance">{title}</h2> </div> {lead && <p className="max-w-100 text-pretty text-muted-foreground md:flex-1">{lead}</p>} </div> <div className="flex w-full flex-col gap-7.5"> {categories.length > 0 && ( <div className="-m-1 max-w-[calc(100%+0.5rem)] overflow-x-auto p-1"> <SegmentedControl label={label || title} value={active} onValueChange={setActive} options={[{ value: ALL, label: allLabel }, ...categories]} /> </div> )} <div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3"> {shown.map((listing) => ( <ListingCard key={listing.href} listing={listing} /> ))} </div> </div> {action && <FlipButton href={action.href}>{action.label}</FlipButton>} </div> </section> ); } function ListingCard({ listing }: { listing: TabListing }) { 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> )} {listing.type && ( <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<TabListing["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 { ListingsTabs } from "@/components/listings-tabs";
<ListingsTabs
title="New on the market"
categories={[
{ value: "houses", label: "Houses" },
{ value: "apartments", label: "Apartments" },
]}
listings={[
{
title: "Garden Porch House",
href: "/properties/garden-porch-house",
image: { src: "/listings/porch.jpg", alt: "…" },
category: { value: "houses", label: "Houses", href: "/properties?category=houses" },
type: "For sale",
location: "Maple Grove",
beds: 4,
baths: 3,
area: "2,400 sqft",
agent: { name: "Nora Lane", image: "/agents/nora.jpg", href: "/agents/nora-lane" },
price: "$845,000",
},
]}
action={{ label: "View all properties", href: "/properties" }}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | Latest properties | Small muted line above the heading. |
| title / lead | string | — | Heading (h2) and the lead beside it from 768px (up to 400px). |
| categories | { value, label }[] | 3 categories | One tab each after the "all" tab. Pass [] for a plain list without tabs. |
| allLabel | string | View all | The first tab, showing every listing. |
| listings | TabListing[] | 7 listings | { title, href, image, category: { value, label, href? }, type?, location, beds, baths, area, agent?, price }. |
| limit | number | 6 | Cards shown for a tab. |
| action | { label, href } | — | Button under the cards; omit to hide it. |
| className | string | — | Classes for the <section>. |
- The tabs are a segmented control: the selected fill slides to the chosen tab, arrow keys move between tabs, and the list swaps at once (as in Framer).
- Cards: one column, two from 768px, three from 1024px. Hovering a card grows its photo to 105% (zoom-card); the photo link repeats the title link, so it is hidden from assistive tech and the keyboard.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this block in your own project.
Create a React + Tailwind CSS block called ListingsTabs (Next.js, client component). - <section className="py-(--section-y)">, container mx-auto max-w-(--container) px-(--gutter), column gap-12, items-start. - Split title: a small muted label and an h2 (font-heading text-h2 semibold); beside it from 768px (items-end) a muted lead up to 400px. - Tabs: a segmented control (radiogroup; rounded-lg bg-muted p-[3px]; the selected option on bg-background with a shadow that slides between options with a spring) with "View all" and one option per category; the grid below shows the first six listings of the chosen category. - Grid gap-5: 1 / 2 (768px) / 3 (1024px) columns. Card (rounded-xl bg-muted, overflow-hidden): a 350px photo that zooms to 1.05 on hover (400ms, cubic-bezier(0.12,0.23,0.5,1)) inside an aria-hidden link, chips at its top-left (category bg-primary, a link; type bg-background); then p-5: a muted location with a pin icon, the h3 title (font-heading text-lg) and a <dl> of Beds / Baths / Area with icons (muted terms, foreground values), as one link; a border-t row with the agent (40px round photo and name, a link) and the price (font-heading text-lg). - A button under the grid whose label rolls on hover. 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.











