<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Facade UI Blog]]></title><description><![CDATA[Notes from building Facade UI: animated React and shadcn blocks for local business, real estate and agency websites. Practical walkthroughs with code you can co]]></description><link>https://facadeui.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ae71eced0f2084d078e1b92/4c1fa736-dc19-4f1a-929a-be129e4df380.png</url><title>Facade UI Blog</title><link>https://facadeui.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:12:39 GMT</lastBuildDate><atom:link href="https://facadeui.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Before and after slider in React, accessible and draggable]]></title><description><![CDATA[Roofers, painters, landscapers and remodelers all have the same proof: a photo from before the job and a photo from after. Put the two side by side and the…]]></description><link>https://facadeui.hashnode.dev/before-and-after-slider-in-react-accessible-and-draggable</link><guid isPermaLink="true">https://facadeui.hashnode.dev/before-and-after-slider-in-react-accessible-and-draggable</guid><dc:creator><![CDATA[Marco Schulz]]></dc:creator><pubDate>Thu, 05 Nov 2026 14:00:02 GMT</pubDate><content:encoded><![CDATA[<p>Roofers, painters, landscapers and remodelers all have the same proof: a photo from before the job and a photo from after. Put the two side by side and the eye wanders. Put them in one frame with a handle the visitor drags and the eye follows the handle. That is why the comparison slider earns a spot on every home services site I build, and why I spent more time on the <a href="/components/before-after-slider">Before / after slider</a> in Facade than its size suggests. Most of that time went into things you cannot see in a demo: touch, keyboard and screen readers.</p>
<h2>Pointer events, not mouse events</h2>
<p>The first version of every comparison slider on the internet listens to <code>mousedown</code>, <code>mousemove</code> and <code>mouseup</code>. Then someone opens it on a phone and nothing happens, so <code>touchstart</code> and <code>touchmove</code> get bolted on, and now there are two code paths that drift apart.</p>
<p>Pointer events replace both. <code>onPointerDown</code>, <code>onPointerMove</code> and <code>onPointerUp</code> fire for mouse, touch and pen with the same event shape, and <code>clientX</code> means the same thing in all three. The one extra call that makes dragging reliable is <code>setPointerCapture</code>: once the visitor presses down, the element keeps receiving move events even when the finger or cursor leaves its box. Without it, a fast drag that exits the frame freezes the handle halfway.</p>
<pre><code class="language-tsx">const onPointerDown = (e: React.PointerEvent&lt;HTMLDivElement&gt;) =&gt; {
  e.currentTarget.setPointerCapture(e.pointerId);
  setDragging(true);
  fromClientX(e.clientX);
};
const onPointerMove = (e: React.PointerEvent&lt;HTMLDivElement&gt;) =&gt; {
  if (!dragging) return;
  fromClientX(e.clientX);
};
const onPointerUp = (e: React.PointerEvent&lt;HTMLDivElement&gt;) =&gt; {
  if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
  setDragging(false);
};
</code></pre>
<p><code>fromClientX</code> reads the container's bounding rect and converts the pointer position into a percentage between 0 and 100. That percentage is the only piece of state. Everything else, the clip, the handle position, the ARIA attributes, derives from it.</p>
<p>Listen on the whole frame, not just the handle. Visitors tap anywhere on the photo and expect the handle to jump there. A 24 pixel handle as the only hit target is a usability bug on touch screens.</p>
<h2>The clip-path reveal</h2>
<p>The after photo fills the frame. The before photo sits on top of it in an absolutely positioned layer, and <code>clip-path: inset(0 X% 0 0)</code> cuts that layer off on the right at the handle position. Dragging changes X and the before photo reveals or hides the after photo underneath.</p>
<p>Clip-path is the right tool because the two images never move or resize. A common alternative is to change the width of the top layer, which stretches the image unless you also counter-scale it, and that path leads to subpixel jitter during a drag. With clip-path both images stay pixel aligned and the browser only repaints the clipped region.</p>
<p>In the Facade component the value runs through a <code>useSpring</code> from <code>motion/react</code> and <code>useTransform</code> turns the spring into the clip string. The trick is in one effect: while dragging, the spring jumps straight to the value so the handle follows the finger one to one; when the visitor releases or taps somewhere, the spring eases to the new position. Reduced motion users get the jump in both cases.</p>
<pre><code class="language-tsx">const spring = useSpring(value, { stiffness: 400, damping: 38, mass: 0.6 });
const clip = useTransform(spring, (v) =&gt; `inset(0 ${100 - v}% 0 0)`);

React.useEffect(() =&gt; {
  if (dragging || reduce) spring.jump(value);
  else spring.set(value);
}, [value, dragging, reduce, spring]);
</code></pre>
<h2>Keyboard support with arrow keys</h2>
<p>A slider that only works with a pointer is a slider half the audience cannot use. Keyboard support is a single handler on the focusable handle, and it follows the conventions people already know from range inputs:</p>
<ul>
<li>Left and Down move the handle toward "before", Right and Up toward "after", by <code>step</code> percent. The default step is 2.</li>
<li>Shift multiplies the step by 5, so a held shift arrow sweeps the frame in a few presses.</li>
<li>Page Up and Page Down move by 10.</li>
<li>Home jumps to 0 and End to 100, the two states a visitor most wants to compare.</li>
</ul>
<p>Call <code>preventDefault</code> on the keys you handle so the page does not scroll when someone presses Down with the handle focused. Do not handle keys you do not use; Tab must keep working.</p>
<h2>The ARIA slider role</h2>
<p>Screen reader support is where I see the most comparison sliders fail, and it is not much work. The handle is the one element in the tab order, and it carries:</p>
<ul>
<li><code>role="slider"</code> with <code>aria-valuemin={0}</code>, <code>aria-valuemax={100}</code> and <code>aria-valuenow</code> set to the rounded value.</li>
<li><code>aria-valuetext</code> that reads as a sentence, like "60% before, 40% after", so the announcement means something. A bare "60" does not.</li>
<li><code>aria-label</code> from the <code>label</code> prop, defaulting to "Before and after comparison", so a page with three sliders announces which one has focus.</li>
</ul>
<p>The two images are not decorative. When you pass <code>beforeSrc</code> and <code>afterSrc</code>, each layer renders an <code>&lt;img&gt;</code> with the <code>beforeAlt</code> and <code>afterAlt</code> text, because "Roof before replacement, missing shingles on the south side" is information a sighted visitor gets from the photo. The illustrated fallback that renders without URLs uses <code>role="img"</code> with the same labels.</p>
<h2>Touch handling on mobile</h2>
<p>Three things break comparison sliders on phones, and all three are fixable with CSS and one attribute.</p>
<p>First, scrolling. A horizontal drag on a touch screen is also a scroll gesture, and the browser will happily scroll the page while you try to move the handle. <code>touch-action: none</code> on the frame tells the browser that this element owns its touches. Only set it on the slider, never on the page.</p>
<p>Second, selection. A long press selects the image or the labels. <code>user-select: none</code> on the frame prevents it.</p>
<p>Third, the handle size. The visual handle can be slim, but the hit area is the entire frame thanks to the pointer handlers above, so a thumb never has to land on a two pixel line. The handle itself still gets a 44 pixel circle so it reads as something to grab.</p>
<h2>Install and use</h2>
<p>The slider is a Pro item and installs from the private registry once your license key is in <code>components.json</code> (one time setup in the <a href="/docs/installation#pro">installation docs</a>):</p>
<pre><code class="language-bash">npx shadcn@latest add @facadeui/before-after-slider
</code></pre>
<pre><code class="language-tsx">import { BeforeAfterSlider } from "@/components/facade/before-after-slider";

export function RoofProject() {
  return (
    &lt;BeforeAfterSlider
      beforeSrc="/projects/elm-street/before.jpg"
      afterSrc="/projects/elm-street/after.jpg"
      beforeAlt="Roof before replacement, curling shingles and moss on the north slope"
      afterAlt="Roof after replacement, new architectural shingles and copper flashing"
      beforeLabel="Before"
      afterLabel="After"
      defaultValue={50}
      step={2}
      label="Elm Street roof replacement"
      onValueChange={(v) =&gt; console.log(v)}
      className="max-w-3xl"
    /&gt;
  );
}
</code></pre>
<p>Shoot the two photos from the same spot with the same lens, or the reveal shows a misaligned house instead of a transformed roof. A tripod mark on the driveway is enough. On the <a href="/for/roofer">roofer page</a> the slider sits directly under the hero, before the stats, because a roofer's work is the proof and the proof should come before the claims.</p>
<h2>FAQ</h2>
<h3>Can I use it for staging photos in real estate?</h3>
<p>Yes. Empty room before, staged room after, same camera position. It works for renovations, landscaping, dental smile makeovers and anything else with a credible before state. It does not work for stock photos, and visitors can tell.</p>
<h3>Does it work with next/image?</h3>
<p>The <code>beforeSrc</code> and <code>afterSrc</code> props take plain URLs and render standard images. If you need <code>next/image</code> for sizing and formats, the source is yours after installation; swap the two <code>&lt;img&gt;</code> elements and keep the alt text in place.</p>
<h3>Why a 16 by 10 frame?</h3>
<p>The frame uses <code>aspect-ratio</code> so both photos share one box regardless of their native size, and 16 by 10 fits most exterior and interior shots without cropping the roofline. Override it with a Tailwind <code>aspect-*</code> class through <code>className</code>.</p>
]]></content:encoded></item><item><title><![CDATA[Opening hours component in React with time zones]]></title><description><![CDATA[The first "Open now" badge I shipped for a client lied. The salon was in Zurich, the site was deployed on servers in the US, and I was testing from my…]]></description><link>https://facadeui.hashnode.dev/opening-hours-component-in-react-with-time-zones</link><guid isPermaLink="true">https://facadeui.hashnode.dev/opening-hours-component-in-react-with-time-zones</guid><category><![CDATA[React]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Marco Schulz]]></dc:creator><pubDate>Tue, 03 Nov 2026 16:36:24 GMT</pubDate><content:encoded><![CDATA[<p>The first "Open now" badge I shipped for a client lied. The salon was in Zurich, the site was deployed on servers in the US, and I was testing from my laptop at home. It showed "Open" at 11 pm because somewhere in the chain a date was created in the wrong time zone. That bug is the reason the <a href="https://facadeui.com/components/opening-hours">Opening hours</a> component and the <a href="https://facadeui.com/components/open-now-badge">Open now badge</a> in Facade compute everything in the business's own IANA time zone and never trust the raw clock. Here is how they work.</p>
<h2>Why new Date() on the client is wrong</h2>
<p><code>new Date()</code> gives you the visitor's local time. A visitor in Los Angeles looking at a barber in New York gets a Date whose <code>getHours()</code> is three hours behind the shop. Compare that to the shop's hours and the badge is wrong for every out of town visitor, which on a site that ranks for the city name is most of them.</p>
<p>The server is no better. On Vercel or any cloud host the server clock is UTC or whatever region the function runs in. If you render "Open now" on the server, you render it in the server's zone, and the first paint in the browser disagrees with the hydrated one.</p>
<p>The rule that fixes both: the business has one time zone, it lives in your data, and every comparison happens in that zone. The visitor's zone and the server's zone are irrelevant.</p>
<h2>Intl and the business time zone</h2>
<p>You do not need a date library for this. <code>Intl.DateTimeFormat</code> accepts a <code>timeZone</code> option and <code>formatToParts</code> gives you the weekday, hour and minute in that zone as separate strings. Turn them into a weekday index and minutes since midnight and you have everything a comparison needs:</p>
<pre><code class="language-ts">export function getZonedNow(date: Date, timeZone: string): { day: number; minutes: number } {
  const parts = new Intl.DateTimeFormat("en-US", {
    timeZone,
    weekday: "short",
    hour: "numeric",
    minute: "numeric",
    hourCycle: "h23",
  }).formatToParts(date);
  const get = (type: Intl.DateTimeFormatPartTypes) =&gt; parts.find((p) =&gt; p.type === type)?.value ?? "0";
  const day = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(get("weekday"));
  const minutes = (Number(get("hour")) % 24) * 60 + Number(get("minute"));
  return { day, minutes };
}
</code></pre>
<p><code>hourCycle: "h23"</code> asks for 0 to 23 hours, and the <code>% 24</code> guards against engines that still return "24" for midnight. The <code>Date</code> you pass in is the real current instant; only its presentation changes. This is the whole trick: one instant, interpreted in the shop's zone.</p>
<p>Both Facade components use exactly this function. The badge even exports it along with <code>getOpenStatus</code>, so you can compute the status for a schema.org <code>openingHoursSpecification</code> or a "Closes in 20 minutes" line elsewhere on the page.</p>
<h2>Split shifts and closed days</h2>
<p>Hours data is where most components give up. A salon opens 9 to 1 and 2 to 7. A restaurant closes Mondays. A clinic is open Saturday morning only. The data shape has to express all of that without special cases in the UI.</p>
<p>Facade uses a weekly map where each day holds an array of ranges. Missing, <code>null</code> or an empty array means closed. More than one range means a split shift:</p>
<pre><code class="language-ts">import type { WeeklyHours } from "@/components/facade/opening-hours";

export const hours: WeeklyHours = {
  mon: null,
  tue: [{ open: "09:00", close: "13:00" }, { open: "14:00", close: "19:00" }],
  wed: [{ open: "09:00", close: "13:00" }, { open: "14:00", close: "19:00" }],
  thu: [{ open: "09:00", close: "20:00" }],
  fri: [{ open: "09:00", close: "19:00" }],
  sat: [{ open: "08:00", close: "16:00" }],
  sun: null,
};
</code></pre>
<p>Times are 24 hour strings. They get parsed into minutes since midnight once, then every comparison is integer math. The list renders "Closed" for a null day and joins the ranges of a split day with a comma. A range that closes after midnight, like a bar open until 02:00, is handled by treating a close time earlier than the open time as belonging to the next day.</p>
<h2>Highlighting today</h2>
<p>Visitors scan the list for one row: today. Highlighting it is the single most useful thing the component does, and it has to use the business zone too. At 11 pm in New York it is already Tuesday in Zurich, so "today" for the Zurich salon is Tuesday.</p>
<p>The component gets today's index from the same <code>getZonedNow</code> call and renders a soft pill behind that row. The pill is a <code>motion.div</code> with a <code>layoutId</code>, so if the page stays open past midnight it slides to the next row instead of jumping. The row also gets <code>aria-current="date"</code> so screen readers know which day the highlight means.</p>
<p>Before the component mounts there is no "today". The component keeps <code>now</code> as <code>null</code> on the first render, renders the plain list with a "Loading" status, and fills in today and the status in an effect. That is what makes the server HTML and the first client render identical, which is the hydration mismatch I ran into on that salon site.</p>
<p>The <code>weekStartsOn</code> prop switches between a Monday first and a Sunday first list. Europe reads Monday first, US customers often expect Sunday first.</p>
<h2>Live open status without a server</h2>
<p>The open status is derived, not fetched. Every 30 seconds an interval updates <code>now</code>, the component recomputes "is there a range on today that contains the current minute", and the badge flips when the shop opens or closes while the page is open. No API call, no server, no cron.</p>
<p>The standalone badge adds a third state. With <code>closingSoonMinutes</code> set, it turns amber and reads "Closing soon" during the last stretch before close. The default is 60 minutes. For a restaurant that stops seating at 9 pm but closes at 10, pass the seating time as the close and keep the default.</p>
<p>Both components are free and MIT licensed:</p>
<pre><code class="language-bash">npx shadcn@latest add https://facadeui.com/r/opening-hours.json https://facadeui.com/r/open-now-badge.json
</code></pre>
<p>And together on a page, sharing one hours object:</p>
<pre><code class="language-tsx">import { OpeningHours } from "@/components/facade/opening-hours";
import { OpenNowBadge } from "@/components/facade/open-now-badge";
import { hours } from "@/data/hours";

export function HoursSection() {
  return (
    &lt;section className="grid gap-6 md:grid-cols-[1fr_320px]"&gt;
      &lt;div&gt;
        &lt;OpenNowBadge hours={hours} timeZone="Europe/Zurich" closingSoonMinutes={45} size="md" /&gt;
        &lt;h2 className="mt-4 text-3xl font-semibold"&gt;Visit the salon&lt;/h2&gt;
      &lt;/div&gt;
      &lt;OpeningHours
        hours={hours}
        timeZone="Europe/Zurich"
        weekStartsOn="mon"
        title="Hours"
        note="Closed on public holidays."
      /&gt;
    &lt;/section&gt;
  );
}
</code></pre>
<p>The badge belongs in the hero next to the phone number and in the sticky mobile bar. The full list belongs in the contact section and the footer. I use this exact pairing on the <a href="https://facadeui.com/for/salon">salon page</a>, where "Are you open Sunday?" is the question that decides whether someone calls.</p>
<h2>FAQ</h2>
<h3>What about holidays?</h3>
<p>Holidays are exceptions, not a schedule, so they do not belong in <code>WeeklyHours</code>. Keep a small list of dates with their override hours in your data, pick today's override in the business zone, and pass the result as <code>hours</code>. The <code>note</code> prop is the place to tell visitors that holiday hours differ.</p>
<h3>Does it work for businesses in several locations?</h3>
<p>Yes. Each location has its own hours object and its own <code>timeZone</code> string. Render one <code>OpeningHours</code> per location, or one badge per location card. Nothing is shared globally.</p>
<h3>Why not render the status on the server for SEO?</h3>
<p>Search engines do not index "Open now" as a ranking signal, and a server rendered status would be wrong by the time the page is crawled anyway. Put the schedule itself in <code>openingHoursSpecification</code> JSON-LD, which is what Google reads, and let the live status be a client concern.</p>
]]></content:encoded></item><item><title><![CDATA[Real estate website template for Next.js: blocks, not a theme]]></title><description><![CDATA[When an agent or a small brokerage asks me for a website, the first thing they send is a link to a theme they found on a marketplace. It looks fine in the…]]></description><link>https://facadeui.hashnode.dev/real-estate-website-template-for-next-js-blocks-not-a-theme</link><guid isPermaLink="true">https://facadeui.hashnode.dev/real-estate-website-template-for-next-js-blocks-not-a-theme</guid><category><![CDATA[Next.js]]></category><category><![CDATA[React]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Tailwind CSS]]></category><dc:creator><![CDATA[Marco Schulz]]></dc:creator><pubDate>Tue, 03 Nov 2026 16:34:54 GMT</pubDate><content:encoded><![CDATA[<p>When an agent or a small brokerage asks me for a website, the first thing they send is a link to a theme they found on a marketplace. It looks fine in the demo. Six months after launch it looks like every other brokerage in the state, the listing grid needs a plugin update, and nobody remembers how the contact form sends email. This article is the plan I use instead: a Next.js site assembled from blocks you own, in the order a buyer or seller actually reads a page.</p>
<h2>Why themes age badly</h2>
<p>A theme is one decision made for you, forever. Layout, typography, animation library, form handler and color system arrive as a single bundle. The day you want to change one of them you discover they are all tied together.</p>
<p>Three problems show up on the themed real estate sites I get asked to fix:</p>
<ul>
<li>The listing data is shaped for the theme's demo, not for the MLS feed or the spreadsheet the agent actually has. Mapping one to the other becomes a permanent chore.</li>
<li>The animations are decorative. Things fade in, but nothing on the page responds to the visitor. A buyer who wants to filter by price gets a page reload.</li>
<li>Lead capture is a generic contact form. Sellers who want to know what their home is worth are the most valuable visitors on the site, and the form treats them like someone asking about parking.</li>
</ul>
<p>Blocks solve this because each block is a single TypeScript file in your repo. You install the ones you need, pass your data as props, and edit the source when a client wants something specific. There is no theme to update.</p>
<h2>The 10 block page plan</h2>
<p>This is the block order for an agent site. It is the same order I use on the <a href="https://facadeui.com/for/real-estate-agent">real estate agent page</a>, and each block links to its component docs.</p>
<ol>
<li><a href="https://facadeui.com/components/hero-real-estate">Hero, real estate</a>: a full width hero with a Buy, Rent, Sell search panel and animated stats. The search mode changes the labels, so "Location" becomes "Your address" when a visitor switches to Sell and the button turns into "Get valuation".</li>
<li>Agent card: the agent's name, rating, license and languages, with call, email and tour actions. On an agent site this belongs above the fold on mobile.</li>
<li><a href="https://facadeui.com/components/property-listing-grid">Property listing grid</a>: filterable and sortable listings with saved homes and a Load more button. The grid re-layouts with a spring when a filter changes instead of blinking.</li>
<li>Stats band: four animated numbers. Homes sold, average days on market, years in business, neighborhoods covered. Use real numbers from the agent or leave the block out.</li>
<li>Testimonials wall: reviews with names and neighborhoods.</li>
<li><a href="https://facadeui.com/components/valuation-funnel">Home valuation funnel</a>: the seller lead magnet. Five steps, address first, estimate range at the end.</li>
<li><a href="https://facadeui.com/components/mortgage-calculator">Mortgage calculator</a>: the buyer tool. I covered the formula and the install in a separate article.</li>
<li>FAQ section with FAQPage JSON-LD.</li>
<li>Contact section with a form, hours and a map card.</li>
<li>Big footer with NAP details, hours and the brokerage license line.</li>
</ol>
<p>Ten blocks is the whole site. A neighborhood page reuses blocks 3, 4 and 8 with different data.</p>
<h2>The data shape for listings</h2>
<p>Before you touch the hero, decide what a listing looks like in code. The listing grid takes a <code>PropertyListing[]</code> array, and every other block that shows property data can be fed from the same records.</p>
<pre><code class="language-ts">import type { PropertyListing } from "@/components/facade/property-listing-grid";

export const listings: PropertyListing[] = [
  {
    id: "mls-4108",
    type: "house",
    title: "Modern farmhouse with pool",
    address: "4108 Avenue G",
    city: "Hyde Park, Austin",
    price: 1_285_000,
    beds: 4,
    baths: 3.5,
    sqft: 3120,
    daysOnMarket: 2,
    status: "New",
    href: "/listings/mls-4108",
    imageSrc: "/photos/mls-4108/cover.jpg",
  },
];
</code></pre>
<p>Two decisions in that shape matter later. <code>type</code> is a union of <code>"house" | "condo" | "townhome"</code> because the filter chips in the grid are built from it. <code>status</code> is optional and limited to <code>"New" | "Open house" | "Price drop" | "Pending"</code>, because a badge vocabulary that grows without limit ends up with twelve colors nobody can read.</p>
<p>Where does the array come from? For a solo agent with twenty listings, a JSON file in the repo, updated through a CMS or a spreadsheet export, costs nothing. For a brokerage with an MLS feed, a server component fetches the feed, maps it to <code>PropertyListing</code> once, and passes the array down. The grid does not care which.</p>
<p>Rendering the grid is one component:</p>
<pre><code class="language-tsx">import { PropertyListingGrid } from "@/components/facade/property-listing-grid";
import { listings } from "@/data/listings";

export function Listings() {
  return (
    &lt;PropertyListingGrid
      eyebrow="Featured homes"
      heading="Homes on the market this week"
      listings={listings}
      pageSize={6}
      defaultSort="newest"
      onToggleSave={(id, saved) =&gt; console.log(id, saved)}
    /&gt;
  );
}
</code></pre>
<p>Saved homes live in component state. To survive a reload, write to <code>localStorage</code> in <code>onToggleSave</code>; restoring the set on load is a small edit to the installed source.</p>
<h2>The lead capture flow</h2>
<p>A real estate site has two visitors with two different questions. Buyers ask "Can I afford this?" and sellers ask "What is my home worth?" Each gets its own block, and the two blocks hand leads to the same place.</p>
<p>The valuation funnel asks for the address first. That ordering is deliberate: an address is low commitment and feels like the start of a tool, not the start of a form. Property type, size and condition follow, and the contact details come last, after the visitor has invested a minute. The built-in estimator multiplies square footage by a <code>basePricePerSqft</code> you set for the market, adjusts for condition, and returns a <code>low</code>, <code>mid</code> and <code>high</code> figure. It is clearly labeled as an estimate, not an appraisal, and the <code>estimate</code> prop lets you swap in a real valuation API later without touching the UI.</p>
<pre><code class="language-tsx">import { ValuationFunnel } from "@/components/facade/valuation-funnel";

&lt;ValuationFunnel
  marketLabel="Austin, TX"
  basePricePerSqft={312}
  onLead={(data, estimate) =&gt; fetch("/api/leads", { method: "POST", body: JSON.stringify({ data, estimate }) })}
  bookLabel="Book a walkthrough"
  bookHref="/book"
/&gt;
</code></pre>
<p><code>onLead</code> is where the site earns money. I post to a route handler that writes the lead to the agent's CRM and sends a notification. The mortgage calculator's <code>onChange</code> callback is the buyer side of the same idea: when a buyer settles on a payment, the pre-approval button next to the donut already knows the numbers.</p>
<h2>Install and deploy on Vercel</h2>
<p>All four featured blocks are Pro items. Set up the private registry once with your license key (the one time setup is in the <a href="https://facadeui.com/docs/installation#pro">installation docs</a>), then install the page in a single command:</p>
<pre><code class="language-bash">npx shadcn@latest add @facadeui/hero-real-estate @facadeui/property-listing-grid @facadeui/valuation-funnel @facadeui/mortgage-calculator
</code></pre>
<p>The free blocks on the list, agent card, stats band, FAQ section and the footer, install from the public registry URL, for example <code>npx shadcn@latest add https://facadeui.com/r/agent-card.json</code>. Every file lands in <code>components/facade/</code>, depends only on <code>motion</code> and <code>lucide-react</code>, and uses the semantic Tailwind tokens your shadcn setup already defines. Change <code>--brand</code> in <code>globals.css</code> and the whole site re-themes.</p>
<p>Deploying is <code>vercel</code> from the project root or a Git push. Because listings can render on the server, the listing pages are static HTML with the interactive grid hydrating on top. That is what keeps Largest Contentful Paint inside Google's 2.5 second "good" threshold on a phone, which a theme loading several jQuery plugins rarely manages.</p>
<h2>FAQ</h2>
<h3>Is this a template I can download as a zip?</h3>
<p>No. It is an ordered set of blocks you install into your own Next.js project with the shadcn CLI. You get the same result as a template, a complete page, but every section is a file you own and can edit. The order and the data shapes are documented on the industry page.</p>
<h3>Can I connect an IDX or MLS feed?</h3>
<p>Yes. Fetch the feed in a server component, map each record to the <code>PropertyListing</code> type and pass the array to the grid. The grid has no opinion about where the data comes from. IDX compliance rules about attribution and refresh intervals are between you and your MLS.</p>
<h3>Does it work with the Pages Router or only the App Router?</h3>
<p>The blocks are plain React client components. They work in the App Router, the Pages Router, Vite and Remix, as long as Tailwind v4 and the shadcn tokens are set up. The install docs use the App Router because that is what new Next.js projects start with.</p>
]]></content:encoded></item><item><title><![CDATA[How to build a mortgage calculator in React]]></title><description><![CDATA[Every real estate site I have built for a client ended up with the same request about two weeks after launch: "Can we show buyers what the monthly payment…]]></description><link>https://facadeui.hashnode.dev/how-to-build-a-mortgage-calculator-in-react</link><guid isPermaLink="true">https://facadeui.hashnode.dev/how-to-build-a-mortgage-calculator-in-react</guid><category><![CDATA[React]]></category><category><![CDATA[Next.js]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Marco Schulz]]></dc:creator><pubDate>Tue, 03 Nov 2026 16:33:06 GMT</pubDate><content:encoded><![CDATA[<p>Every real estate site I have built for a client ended up with the same request about two weeks after launch: "Can we show buyers what the monthly payment would be?" The first time, I wrote the calculator from scratch. The second time, I copied it and fixed the bugs I had shipped the first time. By the fourth site I packaged it as the <a href="https://facadeui.com/components/mortgage-calculator">Mortgage calculator</a> in Facade. This article walks through how it works, so you can build your own or install mine and know what is inside.</p>
<h2>The payment formula, explained once</h2>
<p>A fixed rate mortgage payment is one formula. Everything else in the component is presentation.</p>
<p>With a loan amount <code>P</code>, a monthly interest rate <code>r</code> (the annual rate divided by 12 and by 100) and a number of monthly payments <code>n</code> (years times 12), the monthly principal and interest payment is:</p>
<pre><code>M = P * r * (1 + r)^n / ((1 + r)^n - 1)
</code></pre>
<p>Two edge cases matter in code. When the rate is zero, the formula divides by zero, so you return <code>P / n</code> instead. When the down payment reaches 100 percent, the loan amount is zero and the payment is zero. Both happen the moment a visitor drags a slider to the end, so handle them before you touch any animation.</p>
<p>In TypeScript:</p>
<pre><code class="language-ts">export function monthlyPayment(loan: number, annualRate: number, years: number) {
  const n = years * 12;
  if (loan &lt;= 0) return 0;
  if (annualRate === 0) return loan / n;
  const r = annualRate / 100 / 12;
  const f = Math.pow(1 + r, n);
  return (loan * r * f) / (f - 1);
}
</code></pre>
<p>Principal and interest are only part of what a buyer pays each month. US buyers expect property tax, homeowners insurance and, for condos, HOA dues on the same screen. Tax is an annual percentage of the home price divided by 12. Insurance is an annual dollar amount divided by 12. HOA is already monthly. The total is the sum of the four, and that total is the number the visitor remembers.</p>
<h2>Controlled inputs with sliders</h2>
<p>The calculator has four inputs: home price, down payment, interest rate and loan term. The first three are range inputs with the formatted value printed next to the label, so a buyer always sees the exact number the slider is on. If you add a text input next to each slider, make both controls write to the same piece of state, which keeps them in sync without effects.</p>
<p>Down payment is the input that trips people up. Buyers think in percent, lenders think in dollars, and the slider has to show both. Store the percent, derive the dollar amount, and clamp the percent between 0 and 50 so the slider stays useful. A 90 percent down payment is technically valid and practically never happens.</p>
<p>A few details that make the inputs feel right:</p>
<ul>
<li>Format the displayed values with <code>Intl.NumberFormat</code>, not with string concatenation. <code>$1,250,000</code> reads, <code>$1250000</code> does not.</li>
<li>Pick a sensible step for the price slider, a few thousand dollars at least. Pixel precision on a 2 million dollar range is useless.</li>
<li>Label every range with a visible <code>&lt;label&gt;</code> and an <code>aria-valuetext</code> that reads the formatted value. Screen readers announce "650,000 dollars", not "650000".</li>
</ul>
<h2>The 15 or 30 year term toggle</h2>
<p>Most calculators put the term in a select. A segmented toggle with two options is faster and shows both choices at once. In Facade it is a small two button group that moves a sliding pill behind the active option, and the payment re-renders the moment it changes. Buyers click it back and forth to feel the difference, which is exactly what you want them doing on a listing page.</p>
<p>Under the hood it is a <code>role="radiogroup"</code> with two <code>role="radio"</code> buttons, arrow keys to switch, and the <code>defaultTerm</code> prop set to <code>15</code> or <code>30</code>. Nothing fancy, but the arrow keys matter: a toggle that only works with a mouse is a bug.</p>
<h2>A donut breakdown with motion</h2>
<p>A single total hides where the money goes. The donut shows four segments: principal and interest, tax, insurance and HOA. Each segment is an SVG circle with a <code>strokeDasharray</code> for its share and a <code>strokeDashoffset</code> to position it after the previous segment.</p>
<p>Animating the segments is where <code>motion/react</code> earns its place. Each circle's <code>strokeDasharray</code> and <code>strokeDashoffset</code> are animated when the values change, so dragging the price slider makes the segments breathe instead of jumping. The total in the middle of the donut uses the same count-up logic as the free <a href="https://facadeui.com/components/animated-counter">Animated counter</a>: it tweens from the previous value to the new one over a few hundred milliseconds, formatted as currency on every frame.</p>
<p>Two rules I follow for this kind of animation:</p>
<ul>
<li>Animate on change, not on mount only. The first render can animate in, but the interesting moment is the slider drag.</li>
<li>Respect <code>prefers-reduced-motion</code>. <code>useReducedMotion()</code> from motion returns true for those visitors, and the component then sets values directly instead of springing to them.</li>
</ul>
<h2>Install it with the shadcn CLI</h2>
<p>The Mortgage calculator is a Pro item, so it installs from the private registry once your license key is in <code>components.json</code> (the <a href="https://facadeui.com/docs/installation#pro">installation docs</a> show the one time setup).</p>
<pre><code class="language-bash">npx shadcn@latest add @facadeui/mortgage-calculator
</code></pre>
<p>Then drop it on a listing page with the listing's price as the default and your market's tax rate:</p>
<pre><code class="language-tsx">import { MortgageCalculator, type MortgageBreakdown } from "@/components/facade/mortgage-calculator";

export function ListingPayment({ price }: { price: number }) {
  return (
    &lt;MortgageCalculator
      defaultPrice={price}
      defaultDownPercent={20}
      defaultRate={6.5}
      defaultTerm={30}
      propertyTaxRate={1.1}
      annualInsurance={1800}
      monthlyHoa={0}
      title="Estimate your monthly payment"
      onChange={(result: MortgageBreakdown) =&gt; {
        // result.total, result.principalAndInterest, result.propertyTax, result.insurance, result.hoa, result.loanAmount
      }}
    /&gt;
  );
}
</code></pre>
<p>The <code>onChange</code> callback hands you the full breakdown on every change. I use it to update a "Payment from $X per month" line in the sticky header of the listing page, and some agents use it to prefill a pre-approval request.</p>
<p>If you only need the count-up number without the calculator, the Animated counter is free and MIT licensed:</p>
<pre><code class="language-bash">npx shadcn@latest add https://facadeui.com/r/animated-counter.json
</code></pre>
<h2>Where it belongs on a real estate site</h2>
<p>On a listing page, the calculator goes directly under the key facts, before the photo gallery ends and the agent card appears. Buyers scroll to the price, then to the payment, then decide whether to keep reading. On a brokerage home page it earns less, because there is no price to anchor it to. The block order I use for agent sites is on the <a href="https://facadeui.com/for/real-estate-agent">real estate agent page</a>.</p>
<p>One thing I do not do: pull live rates from an API. Rates change daily, the calculator becomes a compliance question, and buyers understand that 6.5 percent is an example. Set a sensible default, let them change it, and put a one line disclaimer under the donut.</p>
<h2>FAQ</h2>
<h3>Does the calculator include PMI?</h3>
<p>No. Private mortgage insurance depends on the lender and the loan program, and a wrong PMI number is worse than none. If your market needs it, add it as a fifth monthly line in the <code>onChange</code> consumer or extend the component; the source is yours after installation.</p>
<h3>Can I use it outside the US?</h3>
<p>Yes. The formula is universal for fixed rate loans. Pass <code>monthlyHoa={0}</code>, set <code>propertyTaxRate</code> to your local rate or 0, and format currency with your own <code>Intl</code> locale in the consuming component. The labels are props, so "HOA" can become "Service charge" or disappear.</p>
<h3>Why not use a mortgage calculator embed from a lender?</h3>
<p>Embeds load a third party script, carry the lender's branding, and often break Core Web Vitals on mobile because they inject an iframe above the fold. A component you own renders as part of the page, inherits your theme tokens and sends leads to you, not to the lender.</p>
]]></content:encoded></item></channel></rss>