PropTech

Floorplan Viewer

An SVG floor plan with labeled rooms and areas that highlight on hover or focus, showing the room's size in the header.

Install

npx shadcn@latest add @paragon/floorplan-viewer

floorplan-viewer.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface FloorplanRoom {
  id: string;
  label: string;
  /** Rectangle in the 0..100 × 0..100 plan coordinate space. */
  x: number;
  y: number;
  w: number;
  h: number;
  /** Area in sqft. */
  area: number;
}

export interface FloorplanViewerProps extends React.ComponentProps<"div"> {
  rooms: FloorplanRoom[];
  /** Total interior area override; defaults to the sum of rooms. */
  totalArea?: number;
}

/**
 * An SVG floorplan: rooms drawn as labeled rectangles with area captions.
 * Hovering or focusing a room highlights it (fill + area readout) via a
 * color-only transition — no layout motion, no reliance on hover for essential
 * info (each room stays labeled). Deterministic — geometry is props.
 */
export function FloorplanViewer({
  rooms,
  totalArea,
  className,
  ...props
}: FloorplanViewerProps) {
  const [active, setActive] = React.useState<string | null>(null);
  const total =
    totalArea ?? rooms.reduce((s, r) => s + r.area, 0);
  const activeRoom = rooms.find((r) => r.id === active);

  return (
    <div
      data-slot="floorplan-viewer"
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-5 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-baseline justify-between">
        <p className="text-sm font-medium">Floor plan</p>
        <p className="text-xs text-muted-foreground tabular-nums">
          {activeRoom ? (
            <>
              <span className="font-medium text-foreground">
                {activeRoom.label}
              </span>{" "}
              · {new Intl.NumberFormat("en-US").format(activeRoom.area)} sqft
            </>
          ) : (
            <>
              {new Intl.NumberFormat("en-US").format(total)} sqft total
            </>
          )}
        </p>
      </div>

      <svg
        viewBox="-2 -2 104 104"
        className="mt-3 w-full"
        role="group"
        aria-label="Interactive floor plan"
      >
        {rooms.map((r) => {
          const isActive = r.id === active;
          const cx = r.x + r.w / 2;
          const cy = r.y + r.h / 2;
          return (
            <g
              key={r.id}
              tabIndex={0}
              role="button"
              aria-label={`${r.label}, ${r.area} square feet`}
              className="cursor-pointer outline-none [&:focus-visible>rect]:stroke-ring"
              onMouseEnter={() => setActive(r.id)}
              onMouseLeave={() => setActive((cur) => (cur === r.id ? null : cur))}
              onFocus={() => setActive(r.id)}
              onBlur={() => setActive((cur) => (cur === r.id ? null : cur))}
            >
              <rect
                x={r.x}
                y={r.y}
                width={r.w}
                height={r.h}
                rx={1.5}
                className={cn(
                  "transition-[fill,stroke] duration-150 ease-out",
                  isActive
                    ? "fill-primary/15 stroke-primary"
                    : "fill-secondary stroke-border",
                )}
                strokeWidth={0.6}
              />
              <text
                x={cx}
                y={cy - 1}
                textAnchor="middle"
                className={cn(
                  "select-none fill-foreground text-[3.4px] font-medium transition-colors duration-150",
                  !isActive && "fill-muted-foreground",
                )}
              >
                {r.label}
              </text>
              <text
                x={cx}
                y={cy + 4}
                textAnchor="middle"
                className="select-none fill-muted-foreground text-[2.8px] tabular-nums"
              >
                {r.area} sf
              </text>
            </g>
          );
        })}
      </svg>
    </div>
  );
}