Blog//7 min read
Interactive floor plan with hotspots in React
Build an interactive floor plan in React: percent based hotspot coordinates, animated detail cards, responsive scaling and a link to your units table.
MSMarco Schulz, founder of Facade UI
Before: a PDF floor plan nobody zooms into. After: click a room, see the square footage. That is the whole pitch for an interactive floor plan, and it is one of the few animated elements on a real estate page that buyers actually use instead of just watching. The Hotspot image in Facade is the version I built after doing this for a new development client, and the approach works for any image that needs annotations: a floor plan, a site map, a kitchen render, a product photo. Here is how it is put together.
Percent based coordinates
The mistake I made the first time was positioning hotspots in pixels. It looked right on my screen and drifted on every other one, because the image scaled and the markers did not.
The fix is to store every hotspot position as a percentage of the image's width and height. A marker at x: 26, y: 34 sits 26 percent from the left and 34 percent from the top, no matter how wide the container is. The data shape is small:
export interface Hotspot {
id: string;
x: number; // percent from the left, 0 to 100
y: number; // percent from the top, 0 to 100
title: string;
text?: string;
meta?: Array<{ label: string; value: string }>;
}Rendering is one absolutely positioned wrapper per hotspot with left: ${x}% and top: ${y}%, and a negative margin of half the marker's size so the center of the marker, not its corner, sits on the coordinate. The container itself gets a fixed aspectRatio through a style prop, which is what keeps the percentages honest: if the container could change shape, 34 percent from the top would land on a different room.
Finding the coordinates takes a minute per room. Open the plan in any image editor, read the pixel position of the room's center, divide by the image's width and height, multiply by 100. For the default plan in Facade, the great room is at 26 by 34 and the primary bedroom at 65 by 70.
The hotspot markers
Each marker is a <button> so it is focusable and announces itself. The label reads "Great room, details", aria-expanded reflects whether its card is open, and aria-controls points at the card's id when it is. Inside the button sits a small brand colored circle with a plus icon. When the hotspot opens, the circle turns to the foreground color and the plus rotates 45 degrees into a close mark through a spring, which is a cheaper way of saying "this is now a close button" than swapping icons.
A pulsing ring around idle markers is a common addition on floor plans, and it is one @keyframes rule: a second circle behind the marker that scales up and fades out on a two second loop, paused for visitors who prefer reduced motion. I keep it optional. On a plan with eight rooms, eight pulsing rings compete with the plan itself; on a plan with three, a gentle pulse tells the visitor the image is clickable.
Only one hotspot is open at a time. Clicking the active marker closes it, clicking another switches, Escape closes, and a pointer down anywhere outside the component closes it too. That last rule is implemented with a window listener that checks contains() against the root element, and it only attaches while something is open.
Detail cards with motion
The card for an open hotspot is a role="dialog" with the room's title, a paragraph of text, a list of label and value pairs and a close button. It enters through AnimatePresence with a short fade, scale and rise, and leaves the same way in reverse.
Placement is the detail that takes the most thought. A card that always opens below and to the right of its marker will fall off the edge for any hotspot on the right or bottom of the image. The component reads the hotspot's coordinates and flips: markers past 60 percent from the left open their card to the left, markers above 45 percent from the top open below, everything else opens above. The coordinates are written into CSS custom properties, --hx and --hy, so the positioning math lives in Tailwind arbitrary values with calc() and never touches JavaScript layout measurement.
import { HotspotImage, type Hotspot } from "@/components/facade/hotspot-image";
const hotspots: Hotspot[] = [
{
id: "living",
x: 26,
y: 34,
title: "Great room",
text: "Vaulted 14 ft ceiling and wide plank white oak floors.",
meta: [
{ label: "Size", value: "22' x 16'" },
{ label: "Exposure", value: "South" },
],
},
{
id: "primary",
x: 65,
y: 70,
title: "Primary bedroom",
text: "Walk-in closet and ensuite with heated floors.",
meta: [
{ label: "Size", value: "16' x 14'" },
{ label: "Closet", value: "Walk-in" },
],
},
];
<HotspotImage
src="/plans/unit-4b.png"
alt="Floor plan of unit 4B"
hotspots={hotspots}
aspectRatio="16/10"
defaultActiveId="living"
/>Responsive scaling
Percent coordinates solve the markers. The card needs its own answer, because a 256 pixel wide popover anchored to a room has nowhere to go on a 360 pixel phone.
The component switches layout at the small breakpoint. On sm and up, the card is anchored to its hotspot with the flip logic above. Below that, the card ignores the anchor entirely and docks to the bottom of the image as a full width sheet with a small inset. Same element, same content, two sets of Tailwind classes, and the visitor on a phone gets a readable card instead of a clipped one.
The image itself is either an <img> with object-cover or any children you pass. The second option matters for floor plans: an inline SVG plan scales without blur, inherits currentColor so it follows your theme in dark mode, and can carry its own <title> for accessibility. Facade ships a FloorPlanIllustration as the default visual, which is what you see on the component page when no src is set.
Mapping rooms to a units table
On a new development site, the floor plan is rarely alone. There is a units table next to it or below it, and a buyer who clicks "Primary bedroom" on the plan for unit 4B expects the table to know which unit they are looking at.
The clean way to connect them is to make the hotspot data a derived view of the unit data. Keep one units array with the plan image, the rooms and their dimensions per unit type. Build the hotspots array from the selected unit's rooms, and pass the unit's plan as src. Selecting a different row in the table swaps both at once.
const unit = units.find((u) => u.id === selectedId)!;
const hotspots: Hotspot[] = unit.rooms.map((room) => ({
id: room.key,
x: room.x,
y: room.y,
title: room.name,
meta: [
{ label: "Size", value: `${room.widthFt}' x ${room.depthFt}'` },
{ label: "Area", value: `${room.sqft} sqft` },
],
}));
<HotspotImage key={unit.id} src={unit.planSrc} alt={`Floor plan of ${unit.label}`} hotspots={hotspots} aspectRatio={unit.planAspect} />;The key on the component resets the open hotspot when the unit changes, so a card for a room that no longer exists never lingers over the new plan.
The Hotspot image is a Pro component. It installs from the private registry once the license key is in components.json, which the installation docs cover in one step:
npx shadcn@latest add @facade/hotspot-imageOn a property developer site, it sits right after the building elevation and the availability table, so the flow is: pick a unit on the building, check its price in the table, explore its rooms on the plan.
FAQ
Can I use a photo instead of a floor plan?
Yes. The component does not know what the image is. A kitchen render with hotspots on the range, the island and the pantry works exactly the same way, and the meta pairs become appliance brands and dimensions.
How many hotspots are too many?
Six to eight on one image. Beyond that, markers start overlapping on phones and the plan reads as noise. If a unit has more rooms, group them (bedrooms, baths) or split the plan by floor.
Does it work with Next.js Image optimization?
The default renders a plain <img>. To use next/image, pass it as children with fill and object-cover inside the fixed aspect ratio container; the hotspots are positioned over whatever the children render.