An SVG floor plan with labeled rooms and areas that highlight on hover or focus, showing the room's size in the header.
npx shadcn@latest add @paragon/floorplan-viewer"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>
);
}