From component variants to a bookable practitioner map

It started as a single shimmering loading card. It ended up deployed: 4,000 practitioners in Postgres, geospatial search, timezone-aware booking. This is the piece-by-piece story of how each component went from Figma variant to working software.

22 August 2026 · 9 min read

This is where it started: the classic skeleton shimmer, rebuilt as a practitioner profile card. One component, one loading state, one reveal. It would have been a perfectly respectable place to stop.

But a loading state implies something worth loading. A card implies a place it lives. A place implies a map, a map implies search, and search implies data that can answer honestly. So I kept pulling the thread, and by the end of this note that little card has become a live directory of 4,000 practitioners you can search, filter, and book across two countries. This is the piece-by-piece story of each component, designed as variants first and then built into something you can click.

The method: variants first, then make every variant real

Before any code, I enumerate a component's states the way you would in Figma: one component, one state property, every value designed. For the practitioner card that meant four variants sharing the same layer structure:

  • Skeleton: grey blocks matching the loaded card's exact geometry, with a shimmer sweep.
  • Compact: portrait, name, credentials, rating, and one line of availability.
  • Expanded: everything in compact, plus specialties, the treatment menu, and a booking action.
  • Active: any of the above with a teal ring when its map pin is selected.

The discipline pays off the moment you build. Because the variants share geometry, each one became a branch of a single React component instead of four lookalikes that drift apart. And because the skeleton mirrors the compact card block for block, the swap from loading to loaded reads as a reveal rather than a layout jump. The design file and the component tree end up telling the same story.

The skeleton shimmer

In Figma this is the dullest frame in the file: grey rounded rectangles. In the build it's the first thing anyone sees, so the timing does all the work. Each block clips an absolutely positioned gradient that sweeps across it forever:

<motion.div
  className="absolute inset-0 bg-gradient-to-r
             from-transparent via-white/10 to-transparent"
  initial={{ x: "-100%" }}
  animate={{ x: "100%" }}
  transition={{ duration: 1.4, ease: "easeInOut",
               repeat: Infinity, repeatDelay: 0.35 }}
/>

Three numbers matter: a 1.4s sweep, a 0.35s rest between sweeps, and a small per-block delay so stacked lines don't move in lockstep. Too fast reads as anxious, too slow reads as frozen. The whole thing collapses to static grey under prefers-reduced-motion, because a loading state should never be the most stressful part of a page.

The practitioner card

The compact card carries exactly what you scan when comparing people: who you are, what you do, how you're rated, and when I can see you. Availability lives on the compact card on purpose. It's the single biggest decision factor, so it never hides behind a tap. Distance is right beside it, computed from the actual query.

Tap the card and it expands in place. That's a Motion layout animation: the card holds its position in the rail while it grows downward to reveal specialty tags, virtual and direct-billing badges, the clinic address, and the treatment menu with real prices in the practitioner's own currency. The expand exists because you only care about someone's menu after they've made your shortlist. Two information altitudes, one component.

What it became

Searching…

Find care in Vancouver

That's not a mockup. Search any city in North America and it queries a live Postgres database of roughly 4,000 practitioners across 125 metros. Tap a face on the map and their card expands. Book a treatment and you'll move through a three-step flow to a confirmation screen, with every appointment time computed in the practitioner's own timezone. The expand button takes the whole thing full page. The rest of this note takes it apart, one component at a time.

The map and its pins

The pins are the practitioners' faces, not teardrops. A ring of portraits over a dark city reads instantly as people near you, and matching the pin photo to the card photo is what fuses the two halves of the screen into one interface. Selection flows both directions: tap a pin and its card expands and scrolls to centre while the map eases over; tap a card and its pin scales up with a teal ring.

The basemap is MapLibre over OpenStreetMap tiles, desaturated and dimmed in the style layer so streets stay legible without shouting over a dark UI. My first attempt used an off-the-shelf dark style that rendered as a handsome black void. A dark theme still has to show you something. The second lesson came free with the first: MapLibre quietly sets position relative on its container, which collapsed my absolutely positioned box to zero height. If your WebGL map is invisible, measure the container before you blame the GPU.

Search that goes anywhere

The search input geocodes as you type, debounced at 300ms and scoped to North America. Pick a city and the map eases there while the database answers with the practitioners who actually work in that place. Search Toronto, get Toronto's physios and RMTs. Search somewhere remote and you get the honest answer instead: the nearest practitioners and exactly how far away they are, with the header switching from near to nearest to. An honest empty state beats a fake full one every time.

The filter chips

Twelve disciplines, one row of chips. Each tap re-queries Postgres with the discipline folded into the geospatial search, so filtering never fakes it by hiding pins client-side. The skeleton rail returns for the beat the query takes, which keeps the loading language consistent everywhere in the component.

The booking flow

Book now opens a bottom sheet with three named steps: treatment, time, payment. The rules that shaped it:

  • One decision per step. Treatment first because it sets the price you'll see at payment. Time second. Card details last, when commitment is highest.
  • Progress is always visible. A three-segment labelled bar means the sheet never feels bottomless.
  • The primary action is unmissable. Continue is deliberately the largest button on every step and stays disabled until you've chosen, which makes the required action self-explanatory.
  • Times are the clinic's times. Every slot is computed in the practitioner's timezone and labelled with it, because in-person care happens in clinic time no matter where you're sitting.
  • End with reassurance, not a receipt. The confirmation repeats who, what, when, and price, then says your practitioner will be in touch, which is the actual next thing that happens.

The payment step is a simulation and says so in plain text beside a lock icon. Nothing leaves your browser. Faking certainty is the one thing a prototype should never do.

The database underneath

The first version hardcoded twelve people and quietly re-positioned them wherever you searched. Cute, and it collapses the moment anyone looks closely. So I provisioned Postgres (Neon, one command through the Vercel Marketplace) and wrote a deterministic seed: roughly 4,000 synthetic practitioners across 125 Canadian and American metros, population-weighted so Toronto gets dozens and Cheyenne gets a handful. Every row carries a discipline, specialty tags, languages, price band, currency, rating, weekly opening hours, and a PostGIS point.

The map calls one endpoint: the nearest N practitioners to a coordinate, filtered by discipline, ordered by ST_Distance with a GIST index underneath. If nothing sits within 30 km the query widens instead of returning an empty map. The interface never lies about distance and never pretends coverage it doesn't have.

Time, the detail that separates demos from products

Availability isn't stored as strings like tomorrow at 9:30. Each practitioner has weekly opening hours plus an IANA timezone, and next available is computed live against their local clock. Today in Halifax ends four hours before today in Vancouver. Saskatchewan skips daylight saving and so does Phoenix, and storing America/Regina instead of a UTC offset makes all of that permanently someone else's problem. Prices follow the same honesty rule: Canadian practitioners bill in CAD, American ones in USD, and the interface says which.

Why build the whole thing

Because an idea you can click argues better than an idea you can describe. Every screen above started as a rectangle with a state property in Figma, and every rectangle now does its job in production: the shimmer soothes, the card prioritises, the map orients, the search answers truthfully, the booking flow commits, and the database keeps everyone honest. Designing the states and building the software turn out to be the same act of deciding what should happen, all the way down.

That's the kind of work I love: taking a product idea from the first grey rectangle to something deployed, queryable, and true in every timezone. If you're building something that deserves that treatment, I'd love to hear about it.

Have an idea that needs a working prototype rather than another deck?

Get in touch