Blog//7 min read
Service area map with ZIP code checker in React
Build a service area map in React with zones as data, a stylized SVG instead of Google Maps, a ZIP code checker and an animated yes or no result.
MSMarco Schulz, founder of Facade UI
The client request I hear most often on home services sites is some version of "can the map show all the areas we serve?" The first few times I answered it with an embedded Google Map and a list of suburbs. It looked like every other contractor site and it did not answer the visitor's real question, which is not "where are you" but "will you come to me." The Service area block in Facade is the version I settled on after building this section for HVAC companies, plumbers and cleaning services. Here is how it is put together.
Zones as data, not as pixels
The first decision is to treat service zones as data. Each zone is an object with an id, a display name, a shape, a label anchor, the cities inside it and the ZIP codes it covers. The map, the zone list and the ZIP checker all read from the same array, so adding a zone means adding one object, not editing three places.
import type { ServiceZone } from "@/components/facade/service-area";
const zones: ServiceZone[] = [
{
id: "north",
name: "North Austin",
path: "M150 40 L238 30 L262 70 L232 108 L170 120 L136 84 Z",
labelX: 198,
labelY: 76,
cities: ["Allandale", "Crestview", "Domain", "North Burnet"],
zips: ["78757", "78758", "78759", "78727"],
eta: "Same day",
},
{
id: "central",
name: "Central Austin",
path: "M156 172 L190 214 L248 200 L270 252 L214 296 L140 284 L118 228 Z",
labelX: 190,
labelY: 250,
cities: ["Zilker", "Travis Heights", "Bouldin Creek", "Circle C"],
zips: ["78704", "78745", "78748", "78749"],
eta: "Same day",
},
];The eta field is optional and it is the field that does the selling. "Same day" next to a zone name tells a visitor with a broken furnace more than any paragraph. For zones further out, "Next day" is honest and still useful.
A stylized SVG map instead of Google Maps
The shapes are SVG paths in a fixed 400 by 320 viewBox. That sounds like a step backward from a real map, so let me explain why I prefer it on a marketing page.
A Google Maps embed loads a third party script, a tile set and its own fonts. On a mobile connection that is a noticeable hit to Largest Contentful Paint, and Google's own Core Web Vitals guidance treats 2.5 seconds as the threshold for a good LCP. An inline SVG with six polygons costs nothing. It also inherits your theme: the zones use currentColor and the brand token, so the map looks like your site in light and dark mode instead of a gray rectangle with a red pin.
The other reason is honesty. Real service areas are not perfect circles around a shop. They follow highways, rivers and county lines, and a hand drawn polygon communicates "we know this town" better than a radius overlay. The decorative river line in the Facade block has a legend label you can rename or hide with riverLabel="".
Drawing the paths is a one time task. I open the real service area in a map tool, sketch the polygons on a 400 by 320 grid and copy the coordinates. Half an hour per client, and the result is a map that never changes under you when a map provider updates its styles.
Keyboard and screen reader behavior
Each zone is focusable. Arrow keys move between zones, Enter or Space selects one, and the zone list on the side expands to show its cities and ZIP codes. The SVG itself is a role="group" with a label, so a screen reader user hears "Service area map" and can tab into the list instead of being handed a pile of path elements.
The ZIP code checker
The checker is a small form under the map. The input accepts digits only and stops at five; the onChange handler strips everything else, so pasting "78704-1234" leaves "78704". On submit, the component validates against ^\d{5}$ and then runs a lookup:
const zone = zones.find((z) => z.zips.includes(zip)) ?? null;That is the whole algorithm. No geocoding, no API key, no rate limits. The trade off is that you have to maintain the ZIP list, which for a local business is a list of twenty to sixty codes they already know by heart.
The result messages are functions, so you can write them in the client's voice:
import { ServiceArea } from "@/components/facade/service-area";
<ServiceArea
eyebrow="Service area"
heading="Heating and cooling across greater Austin"
description="Two hour arrival windows in every zone. Enter your ZIP to confirm."
zones={zones}
hqLabel="Dispatch on Burnet Rd"
hqX={200}
hqY={160}
checkerLabel="Do we serve your address?"
checkerPlaceholder="5 digit ZIP"
successMessage={(zip, zone) => `Yes. ${zone.name} gets ${zone.eta?.toLowerCase() ?? "fast"} service.`}
failureMessage={(zip) => `${zip} is outside our area for now.`}
ctaLabel="Schedule a visit"
ctaHref="/book"
waitlistLabel="Tell us when you expand"
waitlistHref="/waitlist"
/>A successful check also selects the matching zone on the map, so the visitor sees their neighborhood light up at the same moment they read the answer.
The animated result state
Three states need motion here, and each one is small.
An invalid entry, say four digits, shakes the input. The block increments a counter on each failed submit and keys the shake animation off it, which means the shake replays on every attempt instead of only the first.
A match renders a success card with the zone name, the ETA and a CTA button. A miss renders a card with the failure message and a waitlist link, because a "no" with a next step keeps the lead instead of losing it. Both cards animate in through AnimatePresence with mode="wait", so switching from a yes to a no does not stack two cards for a frame.
All of it respects prefers-reduced-motion. The block reads useReducedMotion() and skips the shake and the entrance transforms for visitors who asked for less movement.
Linking zones to city pages
The service area is also an internal linking tool. Most home services sites have a page per city or suburb for local search. Add an href to your own data shape, or use the zone id to build the link, and render the city list as links in the panel next to the map. A visitor who finds their zone can jump straight to "AC repair in Round Rock," which is the page you want ranking for that query.
I keep the zone array in a single lib/service-area.ts file and import it into the map block, the footer's service list and the city pages' generateStaticParams. One file, three consumers, no drift.
Installing it
The Service area is a Pro block. It installs from the private registry once your license key is in components.json, which is a one time step covered in the installation docs.
npx shadcn@latest add @facade/service-areaThe file lands in components/facade/service-area.tsx and the only dependencies are motion and lucide-react. On an HVAC site I put it fourth, right after the process steps and before the reviews, because by then the visitor knows what the company does and wants to know whether it applies to them.
FAQ
Can I use real geographic shapes instead of hand drawn polygons?
Yes. Export county or ZIP boundaries from a GIS source as SVG paths, scale them into the 400 by 320 viewBox and paste the path strings into the zone objects. The component does not care where the path came from.
What about addresses, not ZIP codes?
ZIP codes are deliberate. An address lookup needs a geocoding API, which adds a key, a cost and a privacy notice. Five digits are enough to answer "do you come to me" and most people know their ZIP without looking it up.
How many zones work before the map gets crowded?
Four to eight. Beyond that, labels start overlapping in the fixed viewBox. If a client covers a whole metro region, group suburbs into larger zones and list the individual cities in the panel instead of drawing each one.