Isometric Property Card
Real Estate · IsometricReal-estate listing card with a hand-built isometric illustration. One projection kernel draws four variants: apartment tower, private villa, exploded floor plans and a plotted community. Pure SVG + framer-motion, themeable, keyboard accessible.
Interactive Playground & Studio
Live ControlsAll Variants & Themes
Click to test interactive spring physics on every theme
Available units light up and the crown lifts on hover. Best for: Residential towers, new launches, project pages.
Windows warm up, the pool shimmers and the upper volume lifts. Best for: Villa and bungalow listings.
Floor plates explode apart and get labelled on hover. Best for: Layout explorers, unit-type pickers.
Available plots rise in a stagger; sold plots stay put. Best for: Masterplans, plotted developments.
Features & Physics
Code & Integration
Customized live with your active playground prop settings:
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
/* ────────────────────────────────────────────────────────────────────────────
IsometricPropertyCard
───────────────────────────────────────────────────────────────────────────
A real-estate listing card with a hand-built isometric illustration.
One projection kernel (pt) + one Box primitive draw all four variants.
┌──────────┬──────────────────────────────────────────────────────────────┐
│ variant │ hover / "hover" stateMode │
├──────────┼──────────────────────────────────────────────────────────────┤
│ tower │ available units light up, the crown lifts │
│ villa │ windows warm up, pool shimmers, the upper volume lifts │
│ floors │ floor plates explode apart and get labelled │
│ plots │ available plots rise in a stagger, sold plots stay put │
└──────────┴──────────────────────────────────────────────────────────────┘
Projection: screen x = (x - y) * cos30, screen y = (x + y) * sin30 - z.
The viewer sits on the +x / +y / +z side, so +y faces are the left wall
and +x faces are the right wall.
──────────────────────────────────────────────────────────────────────────── */
export type IsometricPropertyCardStateMode =
| "interactive"
| "standerd"
| "hover";
export type IsometricPropertyCardSize = "xs" | "sm" | "md" | "lg" | "xl";
export type IsometricPropertyCardVariant =
| "tower"
| "villa"
| "floors"
| "plots";
interface Palette {
name: string;
/** Accent used for lit windows, available plots, tag pill. */
accent: string;
/** Soft tint of the accent for the illustration backdrop. */
wash: string;
/** Building faces. */
face: Face;
/** Ground / plinth faces. */
ground: Face;
}
interface Face {
top: string;
left: string;
right: string;
line: string;
}
interface VariantCopy {
title: string;
subtitle: string;
tag: string;
highlights: string[];
}
export const ISOMETRIC_PROPERTY_CARD_VARIANTS: Record<
IsometricPropertyCardVariant,
Palette & VariantCopy
> = {
tower: {
name: "Apartment Tower",
accent: "#3b6cf6",
wash: "#eef2fe",
face: { top: "#f1f4fa", left: "#cdd6e8", right: "#a3b1cf", line: "#8795b4" },
ground: { top: "#e4e8f0", left: "#c9cfdc", right: "#b2bacb", line: "#a0a9bd" },
title: "Skyline Residences",
subtitle: "2 & 3 BHK apartments in the city centre",
tag: "Apartments",
highlights: ["2–3 BHK", "Sky lounge", "Ready 2027"],
},
villa: {
name: "Private Villa",
accent: "#e07a2f",
wash: "#fdf3e7",
face: { top: "#faf5ec", left: "#e8dcc4", right: "#cdb994", line: "#b09b76" },
ground: { top: "#dfe9cf", left: "#c4d3ae", right: "#a9bd91", line: "#97aa80" },
title: "Palm Court Villas",
subtitle: "4 BHK villas with a private pool and garden",
tag: "Villas",
highlights: ["4 BHK", "Private pool", "3,200 sq ft"],
},
floors: {
name: "Floor Plans",
accent: "#0f9d8a",
wash: "#e8f6f3",
face: { top: "#f0f8f6", left: "#c6e0da", right: "#98c4bb", line: "#7aaba1" },
ground: { top: "#e1efec", left: "#bcd6d0", right: "#9dbfb8", line: "#86aca4" },
title: "Studio 24 Layouts",
subtitle: "Explore every layout, floor by floor",
tag: "Floor plans",
highlights: ["4 floors", "12 layouts", "PDF plans"],
},
plots: {
name: "Plotted Community",
accent: "#2f8f4e",
wash: "#eaf5ec",
face: { top: "#f3f6ee", left: "#d7dfc8", right: "#b6c3a0", line: "#9aa886" },
ground: { top: "#d8e3cf", left: "#bccdb0", right: "#a2b595", line: "#8fa382" },
title: "Greenfield Enclave",
subtitle: "Gated plotted community with wide roads",
tag: "Plots",
highlights: ["120 plots", "Gated", "From 1,200 sq ft"],
},
};
const WIDTHS: Record<IsometricPropertyCardSize, number> = {
xs: 220,
sm: 260,
md: 320,
lg: 380,
xl: 440,
};
/* ── Projection kernel ────────────────────────────────────────────────────── */
const CX = 160;
const CY = 205;
const r = (n: number) => Math.round(n * 100) / 100;
const pt = (x: number, y: number, z: number): [number, number] => [
r(CX + (x - y) * 0.866),
r(CY + (x + y) * 0.5 - z),
];
const poly = (pts: [number, number][]) => pts.map((p) => p.join(",")).join(" ");
/** Axis-aligned box: draws the left (+y), right (+x) and top faces. */
function Box({
x,
y,
z,
w,
d,
h,
c,
}: {
x: number;
y: number;
z: number;
w: number;
d: number;
h: number;
c: Face;
}) {
const top = [pt(x, y, z + h), pt(x + w, y, z + h), pt(x + w, y + d, z + h), pt(x, y + d, z + h)];
const left = [pt(x, y + d, z), pt(x + w, y + d, z), pt(x + w, y + d, z + h), pt(x, y + d, z + h)];
const right = [pt(x + w, y, z), pt(x + w, y + d, z), pt(x + w, y + d, z + h), pt(x + w, y, z + h)];
return (
<g stroke={c.line} strokeWidth={0.6} strokeLinejoin="round">
<polygon fill={c.left} points={poly(left)} />
<polygon fill={c.right} points={poly(right)} />
<polygon fill={c.top} points={poly(top)} />
</g>
);
}
/** Flat quad lying on a horizontal plane (floors, water, rooms). */
function Flat({
x0,
x1,
y0,
y1,
z,
fill,
opacity = 1,
}: {
x0: number;
x1: number;
y0: number;
y1: number;
z: number;
fill: string;
opacity?: number;
}) {
return (
<polygon
fill={fill}
opacity={opacity}
points={poly([pt(x0, y0, z), pt(x1, y0, z), pt(x1, y1, z), pt(x0, y1, z)])}
/>
);
}
/** Window on a +y wall (left side of the screen). */
const wallLeftPts = (x0: number, x1: number, y: number, z0: number, z1: number) =>
poly([pt(x0, y, z0), pt(x1, y, z0), pt(x1, y, z1), pt(x0, y, z1)]);
/** Window on a +x wall (right side of the screen). */
const wallRightPts = (x: number, y0: number, y1: number, z0: number, z1: number) =>
poly([pt(x, y0, z0), pt(x, y1, z0), pt(x, y1, z1), pt(x, y0, z1)]);
interface SceneProps {
on: boolean;
p: Palette;
}
const spring = { type: "spring", stiffness: 220, damping: 22 } as const;
/* ── Variant 1 · Apartment tower ──────────────────────────────────────────── */
function TowerScene({ on, p }: SceneProps) {
const floors = 9;
const fh = 14;
const winBase = "#dfe6f3";
const lit = (i: number, j: number, f: number) => i >= 3 && (i * 3 + j + f) % 4 === 0;
const wins: React.ReactNode[] = [];
for (let i = 0; i < floors; i++) {
const z0 = 6 + i * fh + 4;
for (let j = 0; j < 3; j++) {
const o = -26 + 5.5 + j * 15.5;
[0, 1].forEach((f) => {
const points =
f === 0 ? wallLeftPts(o, o + 10, 26, z0, z0 + 7) : wallRightPts(26, o, o + 10, z0, z0 + 7);
const isLit = lit(i, j, f);
wins.push(
<motion.polygon
key={`${i}-${j}-${f}`}
animate={{ fill: isLit && on ? p.accent : winBase, opacity: isLit && on ? 1 : 0.9 }}
points={points}
transition={{ duration: 0.35, delay: isLit ? (floors - i) * 0.04 : 0 }}
/>,
);
});
}
}
return (
<g>
<Box c={p.ground} d={124} h={6} w={124} x={-62} y={-62} z={0} />
<Box c={p.face} d={40} h={18} w={28} x={-58} y={-20} z={6} />
<Box c={p.face} d={52} h={floors * fh} w={52} x={-26} y={-26} z={6} />
{wins}
<motion.g animate={{ y: on ? -7 : 0 }} transition={spring}>
<Box c={p.face} d={40} h={5} w={40} x={-20} y={-20} z={6 + floors * fh} />
<Box c={p.ground} d={14} h={8} w={14} x={-7} y={-7} z={11 + floors * fh} />
</motion.g>
<Box c={p.face} d={36} h={12} w={26} x={30} y={-18} z={6} />
</g>
);
}
/* ── Variant 2 · Villa with pool ──────────────────────────────────────────── */
function VillaScene({ on, p }: SceneProps) {
const glass = on ? "#ffd58a" : "#d9e4ec";
const tree = (x: number, y: number, s: number) => (
<g key={`${x}-${y}`}>
<Box c={{ top: "#8a6a47", left: "#7a5c3d", right: "#664b30", line: "#5a4229" }} d={3} h={9 * s} w={3} x={x} y={y} z={4} />
<Box
c={{ top: "#8fc07c", left: "#6fa65c", right: "#578a47", line: "#4d7a3f" }}
d={12 * s}
h={12 * s}
w={12 * s}
x={x - 4.5 * s}
y={y - 4.5 * s}
z={13 * s}
/>
</g>
);
return (
<g>
<Box c={p.ground} d={148} h={4} w={148} x={-74} y={-74} z={0} />
{tree(-58, -52, 1)}
{tree(-52, 10, 0.8)}
<Box c={p.face} d={60} h={40} w={70} x={-45} y={-35} z={4} />
<motion.g animate={{ y: on ? -8 : 0 }} transition={spring}>
<Box c={p.face} d={40} h={30} w={45} x={-30} y={-25} z={44} />
<Box c={p.ground} d={48} h={4} w={53} x={-34} y={-29} z={74} />
</motion.g>
{/* ground-floor glazing on both walls */}
<motion.polygon animate={{ fill: glass }} points={wallLeftPts(-38, -6, 25, 12, 30)} transition={{ duration: 0.4 }} />
<motion.polygon animate={{ fill: glass }} points={wallRightPts(25, -28, 16, 12, 30)} transition={{ duration: 0.4, delay: 0.08 }} />
{/* pool */}
<Flat fill="#bcd0c4" opacity={0.9} x0={28} x1={66} y0={-8} y1={30} z={4.2} />
<Flat fill="#6fc3e0" x0={31} x1={63} y0={-5} y1={27} z={4.4} />
<motion.g animate={{ opacity: on ? [0.2, 0.8, 0.2] : 0.2 }} transition={on ? { duration: 1.6, repeat: Infinity } : { duration: 0.3 }}>
<Flat fill="#ffffff" x0={36} x1={50} y0={2} y1={8} z={4.5} />
<Flat fill="#ffffff" x0={44} x1={58} y0={14} y1={20} z={4.5} />
</motion.g>
{tree(58, 44, 0.9)}
</g>
);
}
/* ── Variant 3 · Exploded floor plates ────────────────────────────────────── */
function FloorsScene({ on, p }: SceneProps) {
const plates = [0, 1, 2, 3];
const gap = on ? 34 : 13;
const rooms: [number, number, number, number][] = [
[-43, -7, -43, 3],
[-3, 43, -43, 3],
[-43, 3, 7, 43],
[7, 43, 7, 43],
];
const tints = ["#e3f1ee", "#d3e8e3", "#eaf5f2", "#c8e2dc"];
return (
<g>
{plates.map((i) => (
<motion.g key={i} animate={{ y: -i * gap }} transition={{ ...spring, delay: i * 0.03 }}>
<Box c={p.ground} d={90} h={4} w={90} x={-45} y={-45} z={0} />
{rooms.map(([x0, x1, y0, y1], k) => (
<Flat
key={k}
fill={on && i === 2 && k === 1 ? p.accent : tints[(k + i) % 4]}
opacity={on && i === 2 && k === 1 ? 0.85 : 1}
x0={x0}
x1={x1}
y0={y0}
y1={y1}
z={4.2}
/>
))}
<Box c={p.face} d={2} h={9} w={90} x={-45} y={-45} z={4} />
<Box c={p.face} d={88} h={9} w={2} x={-45} y={-43} z={4} />
<Box c={p.face} d={46} h={9} w={2} x={-5} y={-43} z={4} />
<Box c={p.face} d={2} h={9} w={46} x={-43} y={5} z={4} />
<motion.text
animate={{ opacity: on ? 1 : 0 }}
fill={p.accent}
fontSize={8}
fontWeight={700}
transition={{ duration: 0.3, delay: on ? 0.15 + i * 0.05 : 0 }}
x={pt(45, -45, 4)[0] + 8}
y={pt(45, -45, 4)[1] + 3}
>
{`Floor ${i + 1}`}
</motion.text>
</motion.g>
))}
</g>
);
}
/* ── Variant 4 · Plotted community ────────────────────────────────────────── */
const PLOT_STATUS = ["a", "s", "a", "r", "s", "a", "s", "a", "a", "r", "a", "s", "s", "a", "s", "a"];
function PlotsScene({ on, p }: SceneProps) {
const tile = 36;
const gapW = 4;
const origin = -78;
const cells: { i: number; row: number; col: number }[] = [];
for (let row = 0; row < 4; row++) for (let col = 0; col < 4; col++) cells.push({ i: row * 4 + col, row, col });
cells.sort((a, b) => a.row + a.col - (b.row + b.col));
const plotFace = (s: string): Face =>
s === "a"
? { top: "#bfe3c9", left: "#8fc7a0", right: "#6aa97e", line: "#5a9a6e" }
: s === "r"
? { top: "#fbe3b4", left: "#efc983", right: "#d6ab5f", line: "#c29a50" }
: { top: "#e4e4e0", left: "#cfcfca", right: "#b5b5af", line: "#a3a39d" };
return (
<g>
<Box c={{ top: "#cdd6c3", left: "#b3bea8", right: "#9aa88d", line: "#8a977d" }} d={172} h={2} w={172} x={-86} y={-86} z={0} />
{cells.map(({ i, row, col }) => {
const s = PLOT_STATUS[i];
const x = origin + col * (tile + gapW);
const y = origin + row * (tile + gapW);
return (
<motion.g
key={i}
animate={{ y: s === "a" && on ? -9 : 0 }}
transition={{ ...spring, delay: s === "a" && on ? i * 0.025 : 0 }}
>
<Box c={plotFace(s)} d={tile} h={3} w={tile} x={x} y={y} z={2} />
{s === "s" && <Box c={p.face} d={16} h={13} w={16} x={x + 10} y={y + 10} z={5} />}
{s === "r" && <Box c={{ top: "#fff3d6", left: "#f1d9a3", right: "#d9bd7f", line: "#c4a96c" }} d={12} h={6} w={12} x={x + 12} y={y + 12} z={5} />}
</motion.g>
);
})}
</g>
);
}
export interface IsometricPropertyCardProps {
variant?: IsometricPropertyCardVariant;
size?: IsometricPropertyCardSize;
stateMode?: IsometricPropertyCardStateMode;
/** Defaults to a sample name per variant. */
title?: string;
subtitle?: string;
/** Small pill above the title, e.g. "Apartments". */
tag?: string;
/** Up to three short facts shown as chips, e.g. ["3 BHK", "Pool"]. */
highlights?: string[];
onClick?: () => void;
className?: string;
}
export function IsometricPropertyCard({
variant = "tower",
size = "md",
stateMode = "interactive",
title,
subtitle,
tag,
highlights,
onClick,
className = "",
}: IsometricPropertyCardProps) {
const [hovered, setHovered] = useState(false);
const v = ISOMETRIC_PROPERTY_CARD_VARIANTS[variant] ?? ISOMETRIC_PROPERTY_CARD_VARIANTS.tower;
const on = stateMode === "hover" || (stateMode === "interactive" && hovered);
const width = WIDTHS[size] ?? WIDTHS.md;
const chips = (highlights ?? v.highlights).slice(0, 3);
const heading = title ?? v.title;
const Scene = { tower: TowerScene, villa: VillaScene, floors: FloorsScene, plots: PlotsScene }[variant] ?? TowerScene;
return (
<motion.div
animate={{ y: on && stateMode === "interactive" ? -4 : 0 }}
aria-label={onClick ? heading : undefined}
className={className}
role={onClick ? "button" : undefined}
style={{
width,
fontSize: width / 22,
borderRadius: "1.6em",
background: "#ffffff",
border: "1px solid rgba(15,23,42,0.08)",
boxShadow: on
? "0 22px 44px -14px rgba(15,23,42,0.28), 0 4px 10px rgba(15,23,42,0.06)"
: "0 8px 22px -12px rgba(15,23,42,0.18), 0 1px 3px rgba(15,23,42,0.05)",
overflow: "hidden",
cursor: onClick ? "pointer" : "default",
transition: "box-shadow 0.35s ease",
fontFamily: "inherit",
}}
tabIndex={onClick ? 0 : undefined}
transition={spring}
onBlur={() => setHovered(false)}
onClick={onClick}
onFocus={() => setHovered(true)}
onKeyDown={(e) => {
if (onClick && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
onClick();
}
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* illustration */}
<div style={{ position: "relative", background: `radial-gradient(120% 90% at 50% 100%, ${v.wash} 0%, #ffffff 80%)` }}>
<svg aria-label={`${v.name} illustration`} role="img" style={{ display: "block", width: "100%", height: "auto" }} viewBox="0 0 320 250">
<Scene on={on} p={v} />
</svg>
<span
style={{
position: "absolute",
top: "0.9em",
left: "0.9em",
padding: "0.25em 0.75em",
borderRadius: 999,
fontSize: "0.68em",
fontWeight: 700,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: v.accent,
background: "rgba(255,255,255,0.85)",
border: `1px solid ${v.accent}33`,
}}
>
{tag ?? v.tag}
</span>
</div>
{/* copy */}
<div style={{ padding: "1em 1.2em 1.25em" }}>
<div style={{ fontSize: "1.2em", fontWeight: 650, letterSpacing: "-0.015em", color: "#0f172a", lineHeight: 1.15 }}>
{heading}
</div>
<div style={{ marginTop: "0.35em", fontSize: "0.82em", color: "#64748b", lineHeight: 1.35 }}>
{subtitle ?? v.subtitle}
</div>
<div style={{ marginTop: "0.9em", display: "flex", flexWrap: "wrap", gap: "0.4em" }}>
{chips.map((c) => (
<span
key={c}
style={{
padding: "0.3em 0.7em",
borderRadius: 999,
fontSize: "0.72em",
fontWeight: 600,
color: "#334155",
background: "#f1f5f9",
}}
>
{c}
</span>
))}
</div>
</div>
</motion.div>
);
}
export default IsometricPropertyCard;
Peer Dependencies
Requires Framer Motion and Tailwind CSS for spring physics and layout tokens:
npm install framer-motion clsx lucide-react tailwind-mergeAPI & Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "tower" | "villa" | "floors" | "plots" | "tower" | Which isometric scene to draw. |
| title | string | per variant | Project name. Each variant ships a sample default. |
| subtitle | string | per variant | One-line description under the title. |
| tag | string | per variant | Pill over the illustration, e.g. Apartments. |
| highlights | string[] | per variant | Up to three fact chips (BHK, area, possession). |
| size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Card width (220 / 260 / 320 / 380 / 440px); type scales with it. |
| stateMode | "interactive" | "standerd" | "hover" | "interactive" | Force the idle or animated scene instead of reacting to the pointer. |
| onClick | () => void | undefined | Makes the card a button (Enter / Space supported). |