Blog//7 min read
Home valuation lead funnel in React: What's my home worth
Build a home valuation form in React: address first, five steps with direction aware transitions, an estimate range without an AVM API and lead delivery.
MSMarco Schulz, founder of Facade UI
Seller leads are the ones real estate agents actually pay for. A buyer lead fills out a form and disappears into a dozen other agents' inboxes. A seller who wants to know what their house is worth is a few months away from signing a listing agreement. That is why the "What's my home worth?" widget exists on almost every agent site, and why most of them are either a third party iframe or a single form that asks for an email before it gives anything back. The Home valuation funnel in Facade is my attempt at a version that respects the visitor and still captures the lead. Here is how I built it.
Why the address comes first
Order matters more than design in a lead funnel. The first field sets the tone, and "Email address" as the first field says "we want your data." "Property address" says "tell us about the house." Same form, different first question, very different completion.
There is a practical reason too. The address is the one piece of information the visitor has that an agent cannot get elsewhere, and it is the field that feels least like a commitment. People type their address into search boxes all day. So the funnel opens with a single address input, autoComplete="street-address" so phones fill it in one tap, and a short note under it: we use your address to pull nearby comparable sales, and it is never shown publicly.
Validation on this step is deliberately loose. The component accepts anything six characters or longer. A strict address parser at step one is a wall; the agent will verify the address on the phone anyway.
Step by step state
The funnel has five steps: Address, Type, Details, Condition, Contact. Then a loading phase and a result. That is three pieces of state, and I keep them separate:
step, a number from 0 to 4phase, one of"form","loading"or"result"data, theValuationDataobject that every step writes into
export interface ValuationData {
address: string;
propertyType: "single-family" | "condo" | "townhome" | "multi-family" | null;
beds: number;
baths: number;
sqft: number;
condition: "needs-work" | "average" | "updated" | "renovated" | null;
name: string;
email: string;
phone: string;
}Each step has its own validity check, computed from data, and the Continue button is disabled until the current step is valid. Property type and condition are option cards instead of selects: four large buttons with a label, a one line hint and an icon. A select hides the choices; cards show them, and on a phone they are far easier to tap than a native dropdown.
Beds, baths and square footage use steppers with minus and plus buttons and a live <output> element, so the value is announced to screen readers as it changes. Nobody wants to type "3" into a field labeled bedrooms.
The progress bar above the steps is derived from step and phase, not stored. During loading it counts the pending step as done so the bar fills to the end before the result appears, which is a small thing that makes the wait feel intentional.
Direction aware transitions
When the visitor clicks Continue, the current step should leave to the left and the next one should enter from the right. When they click Back, the opposite. If you animate every step the same way, Back feels broken.
The pattern is AnimatePresence with mode="wait" and a custom value that carries the direction. The variants read it:
const variants = {
enter: (dir: number) => ({ opacity: 0, x: dir * 40 }),
center: { opacity: 1, x: 0 },
exit: (dir: number) => ({ opacity: 0, x: dir * -40 }),
};Set dir to 1 before moving forward and -1 before moving back, pass it to both AnimatePresence and the motion.div through custom, and the exit animation of the leaving step uses the fresh direction instead of a stale one. mode="wait" keeps the two steps from overlapping inside a fixed height container.
For visitors with prefers-reduced-motion, the funnel reads useReducedMotion() and switches to a plain opacity crossfade. The direction logic stays in place; the translate just drops to zero.
An estimate range without an AVM API
Here is the part most people get wrong. A real automated valuation model (AVM) needs a data license, and the numbers it returns carry legal weight in some states. For a lead funnel, you do not need a real appraisal. You need a plausible range and a reason to talk to the agent.
The funnel ships with a demo estimator that works from four inputs you already collected: square footage, property type, condition and room count. It multiplies a base price per square foot for the market by a type factor, a condition factor and a small boost for extra bedrooms and bathrooms, then rounds to the nearest thousand and spreads it into a range of roughly 7 percent either side.
function demoEstimate(d: ValuationData, base: number): ValuationEstimate {
const t = d.propertyType ? TYPE_FACTOR[d.propertyType] : 1;
const c = CONDITIONS.find((x) => x.value === d.condition)?.factor ?? 1;
const roomBoost = 1 + Math.max(0, d.beds - 3) * 0.025 + Math.max(0, d.baths - 2) * 0.02;
const mid = Math.round((d.sqft * base * t * c * roomBoost) / 1000) * 1000;
return { low: Math.round((mid * 0.93) / 1000) * 1000, mid, high: Math.round((mid * 1.07) / 1000) * 1000 };
}Set basePricePerSqft to the client's market and the demo estimator gives sensible numbers. When the client has access to a real AVM or their MLS provides comparables, swap it out with the estimate prop, which accepts a function returning a ValuationEstimate or a promise of one. The loading phase waits for it.
The result screen shows the mid value large, the low to high range under it and a bar with the three markers. The disclaimer under the bar is a prop, and the default is honest: this is an automated estimate, not an appraisal, and your agent will confirm after a walkthrough. Do not remove it.
Sending the lead
The contact step collects name, email and phone, validates the email format and then triggers the estimate. The onLead callback fires with both the data and the estimate, which means your CRM entry can include the number the seller saw.
import { ValuationFunnel, type ValuationData, type ValuationEstimate } from "@/components/facade/valuation-funnel";
<ValuationFunnel
eyebrow="Free home estimate"
heading="What is your home worth today?"
marketLabel="Greater Denver"
basePricePerSqft={340}
onLead={async (data: ValuationData, estimate: ValuationEstimate) => {
await fetch("/api/leads/valuation", {
method: "POST",
body: JSON.stringify({ ...data, estimate }),
});
}}
bookLabel="Book a walkthrough"
bookHref="/book"
/>The result screen also offers a booking link, bookHref, which is where the funnel pays off: a seller who just saw a number they liked is the easiest appointment an agent will book all week.
The funnel is a Pro block. It installs from the private registry after the one time license setup in the installation docs:
npx shadcn@latest add @facade/valuation-funnelOn an agent site it goes below the listings and the testimonials, as the second to last block before the FAQ. By then the visitor has seen that the agent sells homes like theirs, and the question "what is mine worth" is already in their head.
FAQ
Is showing an estimate before collecting contact details a mistake?
The funnel asks for contact details before the estimate, on the last step. By then the visitor has invested four steps and the estimate is one click away, which is a much better moment to ask than step one.
Can I skip the demo estimator and only collect the lead?
Yes. Pass an estimate function that returns a wide range or a fixed placeholder and rewrite the result copy through the props, or resolve the estimate server side and email it. The step logic does not care where the numbers come from.
Does it store anything in the browser?
No. State lives in React for the current visit. If you want the funnel to survive a refresh, persist data to sessionStorage in the consuming component; the source is yours after installation.