←
Isometric Property Card

Isometric Property Card

Real Estate · Isometric

Real-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 Controls
isometric-property-card.tsx
Apartments
Skyline Residences
2 & 3 BHK apartments in the city centre
2–3 BHKSky loungeReady 2027
Hover or click to interact
Customizer
PRESETS & VARIATIONS

All Variants & Themes

Click to test interactive spring physics on every theme

Apartment Towervariant="tower"
Apartments
Skyline Residences
2 & 3 BHK apartments in the city centre
2–3 BHKSky loungeReady 2027

Available units light up and the crown lifts on hover. Best for: Residential towers, new launches, project pages.

Private Villavariant="villa"
Villas
Palm Court Villas
4 BHK villas with a private pool and garden
4 BHKPrivate pool3,200 sq ft

Windows warm up, the pool shimmers and the upper volume lifts. Best for: Villa and bungalow listings.

Floor Plansvariant="floors"
Floor 1Floor 2Floor 3Floor 4Floor plans
Studio 24 Layouts
Explore every layout, floor by floor
4 floors12 layoutsPDF plans

Floor plates explode apart and get labelled on hover. Best for: Layout explorers, unit-type pickers.

Plotted Communityvariant="plots"
Plots
Greenfield Enclave
Gated plotted community with wide roads
120 plotsGatedFrom 1,200 sq ft

Available plots rise in a stagger; sold plots stay put. Best for: Masterplans, plotted developments.

Features & Physics

✓4 variants: tower, villa, floor plans, plots
✓Single projection kernel + Box primitive (pure SVG)
✓Hover / focus animates the scene (units, pool, floors, plots)
✓Keyboard focusable, reduced to flat chips on small sizes

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-merge

API & Props Reference

PropTypeDefaultDescription
variant"tower" | "villa" | "floors" | "plots""tower"Which isometric scene to draw.
titlestringper variantProject name. Each variant ships a sample default.
subtitlestringper variantOne-line description under the title.
tagstringper variantPill over the illustration, e.g. Apartments.
highlightsstring[]per variantUp 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() => voidundefinedMakes the card a button (Enter / Space supported).