Freeframe

Documentation

Atlas

v1.0.0Updated Oct 15, 2026A bold, magazine-style personal brand site for a real estate agent, with listings, a home value estimate and calculators.

Overview#

Atlas is a bold, magazine-style personal-brand website for an independent real estate agent or realtor. The demo content is for Delia Quintero, a fictional Nashville agent who trained as an architect and works in six neighborhoods. It has filterable active and sold listings, listing pages with illustrated room galleries and open houses you can add to a calendar, a three-step home value estimate, a seller net sheet, a buyer affordability calculator, an interactive neighborhood map and an online consultation booking flow. Gloock headlines and a cobalt, persimmon and butter palette give it an editorial look, and every house and room picture is an SVG drawn from data, so there are no photos to license. This is the Next.js version: Next.js 16 (App Router, static export), React 19, TypeScript and Tailwind CSS v4, with no other dependencies. This guide covers version 1.0.0 (see template.json and CHANGELOG.md). Live demo: freeframe.dev/templates/atlas.

Quick start#

Requirements: Node.js 24 and pnpm.

sh
pnpm install
pnpm dev

Open http://localhost:3000. Pages reload as you edit. Other scripts:

Script What it does
pnpm build Static export to out/
pnpm start Serve out/ locally
pnpm lint ESLint
pnpm typecheck Generate route types and run TypeScript

Pages#

Route File Purpose and notable features
/ src/app/page.tsx Hero with portrait and "I'm buying" / "I'm selling" paths, instant estimate teaser, three featured listings, count-up stats, testimonial carousel, neighborhood teaser with map, booking call to action
/listings src/app/listings/page.tsx Status tabs (for sale, under contract, sold, all), neighborhood, bedroom and price filters with live counts, sort, empty state, URL state, off-market call to action
/listing src/app/listing/page.tsx One page for every home, chosen by ?id=: illustrated gallery, key facts, description, highlights, open houses with .ics downloads, tour request form, similar homes, not-found state
/sell src/app/sell/page.tsx Three-step home value estimate (#valuation), seller net proceeds sheet (#net-sheet), four-week selling plan
/buy src/app/buy/page.tsx Tour-notes sketch, six-step buyer guide, affordability calculator (#afford) with donut chart, FAQ accordion
/neighborhoods src/app/neighborhoods/page.tsx Interactive SVG map with an info panel, six guide cards with statistics and links to filtered listings
/about src/app/about/page.tsx Story and portrait, sold-history chart, timeline, three principles, credentials, personal facts
/contact src/app/contact/page.tsx Consult types, day strip, time slots, details form, live summary, confirmation with .ics download
404 src/app/not-found.tsx Map with a bobbing pin and links back into the site (noindex)

Project structure#

atlas/
  src/
    app/
      layout.tsx            <html>, fonts, default metadata, top bar, header, footer, reveals, back to top
      fonts.ts              Gloock and Albert Sans with next/font/local
      globals.css           Tailwind CSS v4: @theme tokens and component classes
      icon.svg              favicon (the DQ house monogram)
      page.tsx              home page
      listings/page.tsx     listings (filters read from the URL)
      listing/page.tsx      listing detail (?id=)
      sell/, buy/, neighborhoods/, about/, contact/   the other routes
      not-found.tsx         404 page
    components/
      TopBar.tsx            "Open this weekend" strip above the header
      SiteHeader.tsx        sticky header, current page, mobile menu (client)
      SiteFooter.tsx        footer with contact details and links
      Newsletter.tsx        Monthly Market Memo signup (client)
      Logo.tsx, Icons.tsx   logo mark and line icons
      Art.tsx               <HouseSvg> and <RoomSvg> wrappers for the illustrations
      MapArt.tsx            the neighborhood map: shapes, base layers, static map
      ListingCard.tsx       a listing card (server-rendered)
      ListingsBrowser.tsx   tabs, filters, sort, results and URL state (client)
      ListingDetail.tsx     listing page and not-found state (client)
      Gallery.tsx           listing gallery (client)
      TourForm.tsx          tour request form (client)
      EstimateTeaser.tsx    home page instant estimate (client)
      HomeEffects.tsx       count-up numbers and the testimonial carousel (client)
      Valuation.tsx         three-step home value estimate (client)
      NetSheet.tsx          seller net sheet (client)
      Affordability.tsx     affordability calculator (client)
      Faq.tsx               buyer FAQ accordion (client)
      AreaExplorer.tsx      interactive map and info panel (client)
      Booking.tsx           consultation booking (client)
      useValidation.tsx     form validation hook
      RevealObserver.tsx    scroll reveals (client)
      BackToTop.tsx         back-to-top button (client)
      Year.tsx              current year in the footer
    data/
      agent.ts              AGENT: name, phone, email, office, time zone, site URL
      areas.ts              AREAS: the six neighborhoods, their statistics and guide text
      listings.ts           LISTINGS, STATUS_LABEL, PRICE_STEPS, SORTS, GALLERY
      valuation.ts          VALUATION (home value factors) and AFFORD (28/36 rule)
      booking.ts            CONSULT_TYPES and AVAILABILITY
      content.ts            NAV, TESTIMONIALS, BUYER_STEPS, FAQ, SELL_STEPS, TIMELINE, SALES_BY_YEAR, CREDENTIALS
    lib/
      art.ts                house and room drawings (SVG markup from a listing's colors)
      estimate.ts           estimate(): the home value range
      listings.ts           listings filters, sorting and URL parsing
      time.ts               Nashville time, next open house, .ics files
      format.ts             money, moneyShort, num, dates, sold percentage
      metadata.ts           pageMeta(): per-page title, description and Open Graph
      asset.ts              asset(): prefixes files in public/ with the base path
    fonts/                  Gloock and Albert Sans woff2 files, OFL.txt
  public/img/
    delia-portrait.svg      illustrated portrait (480 x 600)
    og-image.svg, og-image.png   social sharing image (1200 x 630)
  next.config.ts            static export and base path
  template.json             gallery metadata

Customising#

Brand and colors#

All design tokens live in the @theme block at the top of src/app/globals.css. Each token becomes Tailwind utilities (bg-cobalt-600, text-persimmon-700, border-sand-200 and so on).

Token group Used for
--color-paper, --color-sand-50 to --color-sand-300 Page background, soft panels, borders
--color-ink-400 to --color-ink-950 Text, the footer and dark sections
--color-cobalt-50 to --color-cobalt-900 The brand blue: primary buttons, links, the estimate and calculator panels
--color-persimmon-50 to --color-persimmon-700 The warm accent: highlighted words, the "selling" path, section numbers
--color-butter-100 to --color-butter-400 Highlights on dark backgrounds, the "under contract" pill
--color-moss-100 to --color-moss-700 Parks and gardens in the art, "sold" results

To change the brand blue, replace the cobalt scale:

css
@theme {
  --color-cobalt-600: #0f766e;
  --color-cobalt-700: #0d5f59;
  --color-cobalt-800: #0b4a46;
}

Use persimmon-600 or darker behind white text, persimmon-700 for small persimmon text on paper, and ink-600 or darker for small grey text. Reusable classes such as .btn, .btn-primary, .card, .kicker, .display, .hl, .swash, .input, .tab, .seg, .slot and .listing-card are in the @layer components block of the same file.

The illustrations use hex colors directly: each listing's art colors in src/data/listings.ts, the GLASS, LAWN, TREE and TREE_DARK constants in src/lib/art.ts, the map in src/components/MapArt.tsx and the SVG files in public/img/. If you change the palette, search for the old hex values (for example #2742cc and #e4552b).

Typography#

src/app/fonts.ts loads two fonts with next/font/local from src/fonts/: the exact woff2 files Google Fonts serves to the HTML version, so text sets identically. Gloock (--font-display, one weight) is used for headlines, prices and big numbers through .display and font-display; Albert Sans (--font-sans, variable, 400 to 800) is the body face. Each family has a latin and a latin-ext file split by unicode range. Every face is registered under its plain family name, which is why globals.css and the SVG text in the logo, map, chart and footer wordmark can name "Gloock" and "Albert Sans" directly.

To swap a font, put its woff2 files in src/fonts/, update the src and family names in fonts.ts, and change --font-display or --font-sans in globals.css. Gloock has no italic, so emphasis in headlines uses the .hl color and the hand-drawn .swash underline instead.

Content#

Page copy is in each route's page.tsx. The data behind the interactive parts lives in src/data/:

Export File What it holds
AGENT agent.ts Name, site name, phone, email, office, time zone (America/Chicago) and site URL
AREAS areas.ts The six neighborhoods: id, name, ppsf (price per square foot), median, dom (days on market), yoy (one-year change), and the guide card text
LISTINGS listings.ts Every home, for sale, under contract or sold
VALUATION, AFFORD valuation.ts Home value factors and the affordability rule
CONSULT_TYPES, AVAILABILITY booking.ts Bookable consultations, weekly hours, notice, closed dates
TESTIMONIALS, BUYER_STEPS, FAQ, SELL_STEPS, TIMELINE, SALES_BY_YEAR, CREDENTIALS content.ts Reviews, guides, the about page chart and timeline

A listing looks like this (shortened):

ts
{ id: "utah-4512", status: "active", featured: true, address: "4512 Utah Ave", area: "sylvan-park", zip: "37209",
  price: 925000, beds: 3, baths: 2, sqft: 1780, lot: "0.21 acres", year: 1928, type: "Single-family",
  style: "Craftsman bungalow", dom: 3, listed: "2026-10-12",
  headline: "A Sylvan Park bungalow with the original built-ins",
  description: ["First paragraph.", "Second paragraph."],
  features: ["Original built-ins and fireplace", "Deep back deck"],
  openHouses: [["sat", "13:00", "15:00"]],
  art: { style: "bungalow", sky: "#e3ead9", body: "#8fa97a", roof: "#435a39", trim: "#f6f2ea", door: "#a13517" } },

To add a home, copy an object in LISTINGS and change it (TypeScript checks the shape):

  • id must be unique; it is the ?id= of the listing page.
  • status is active, pending or sold. Sold homes also need sold: { price, date } (date as YYYY-MM-DD).
  • area must be an id from AREAS.
  • featured: true puts a home for sale on the home page (the first three are shown).
  • listed (and sold.date) drive the "Newest" sort.
  • openHouses is a list of [weekday, start, end] in Nashville time, with weekdays sun to sat and 24-hour times. Each one is shown on its next date, so they never go stale. Only homes for sale show open houses.
  • art.style picks the house drawing (see Images and artwork).

Listing cards, listing pages, the comparable sales in the estimate, the map counts and the guide buttons all read this array, so nothing else needs updating. The headline numbers (238 homes, 8 days, 101.6%, $171M) appear on the home, sell and about pages; keep them in step when you change them.

Images and artwork#

Everything is SVG:

  • Houses. HOUSES in src/lib/art.ts draws a 400 x 300 (4:3) house from a listing's art object; <HouseSvg> in src/components/Art.tsx wraps it in an <svg>. The styles are victorian, farmhouse, bungalow, townhome, modern, ranch, foursquare, tudor and condo; the colors are sky, body, roof, trim and door. The cards are rendered on the server, so the drawings are in the exported HTML.
  • Rooms. The listing gallery shows the house plus five rooms (living, kitchen, bedroom, bath, yard) drawn by ROOMS and tinted from the same colors. The order and captions are in GALLERY in src/data/listings.ts.
  • Portrait. public/img/delia-portrait.svg (480 x 600, 4:5) is used on the home, about and listing pages. Replace it with a 4:5 photo; the .arch class rounds the top into an arch.
  • Map. src/components/MapArt.tsx holds the stylized map (not to scale): MAP_SHAPES lists each neighborhood's polygon, fill and label position, MapBase draws the parks, roads, river and downtown.

To use real photos for listings, add a photo field to Listing and return an <img> (with asset() for files in public/) instead of <HouseSvg> in ListingCard.tsx and of <RoomSvg> in Gallery.tsx. Keep a 4:3 aspect ratio so cards line up.

The social image is public/img/og-image.png (1200 x 630); og-image.svg is its editable source. Export the SVG to PNG after editing it. The favicon is src/app/icon.svg.

The desktop menu is NAV and the mobile menu MOBILE_NAV in src/data/content.ts; SiteHeader.tsx marks the current route with aria-current="page" (a listing page counts as Listings). The footer's link lists are EXPLORE and AREA_LINKS at the top of SiteFooter.tsx. The "Open this weekend" strip is TopBar.tsx: change its text and /listing?id= link, or remove <TopBar /> from layout.tsx.

Features#

Listings filters#

ListingsBrowser.tsx runs the listings page; the filter logic is in src/lib/listings.ts.

  • Status tabs are buttons with data-status (active, pending, sold, all) and aria-pressed.
  • Filters: neighborhood (area), minimum bedrooms (beds: 1 to 4), minimum and maximum price (min, max, from PRICE_STEPS), and sort (newest, price-asc, price-desc, size). Sold homes are filtered and sorted by their sold price. Picking a minimum above the maximum clears the other one.
  • Live counts: each tab and each neighborhood option shows how many homes you would see if you picked it.
  • URL state: the state is written to the address bar with history.replaceState, for example /listings?status=sold&area=east-nashville&beds=3. Defaults (status=active, sort=newest) are left out and unknown values (?status=banana, ?min=7) are dropped (parseListingState()).
  • Empty state ([data-empty]) appears when nothing matches, with a Clear filters button that keeps the status and sort and moves focus to the neighborhood filter.

The page reads the URL in the browser (inside a <Suspense> boundary), so the exported HTML shows the default view, homes for sale, newest first. The result count is an aria-live region.

Listing page#

ListingDetail.tsx reads ?id=. Without an id it shows the first listing; with an unknown one it shows "That listing isn't on my desk" with its own <h1>. The tab title becomes "address, neighborhood — Delia Quintero Real Estate", set in the browser because a static export can't know the id at build time. If listings matter for search, consider generating one static route per home instead (src/app/listing/[id]/page.tsx with generateStaticParams).

The gallery (Gallery.tsx) has previous and next buttons, thumbnails with aria-current, and responds to the left and right arrow keys when focused. Sold homes show the sold price, the percentage of asking and a "Want one like this?" panel instead of the tour form; homes under contract say so in the open-house box.

Open houses and calendar files#

nextOpenHouse() in src/lib/time.ts turns each [weekday, start, end] into its next real date in AGENT.timeZone, skipping one that has already ended today. Times are shown in Central Time, with the visitor's own time added when they are in another time zone. Because this depends on the clock, open houses are filled in by the browser after the page loads. "Add to calendar" builds an iCalendar file with buildIcs() (times in UTC, so every calendar app places them correctly; 11:00 Central on a Saturday in October is DTSTART:...T160000Z) and downloads it with downloadIcs().

Home value estimate#

The home page teaser (EstimateTeaser.tsx) and the three-step wizard on /sell (Valuation.tsx) share estimate() in src/lib/estimate.ts:

value = sqft x area ppsf x type factor x condition factor x (1 + bed and bath adjustment) x (1 + extras)
range = value plus or minus the condition's spread, rounded to $5,000

The factors are in VALUATION. For example, an updated 1,780 sq ft house in Sylvan Park comes out at $880,000 to $970,000. The teaser links to /sell?area=...&sqft=...#valuation, which fills in the wizard. Each step validates before moving on and moves focus to the next step's heading. The result shows where the estimate sits against the neighborhood median and lists the agent's sold homes in that area. "Use in the net sheet" copies the midpoint into the net sheet and scrolls to it.

Seller net sheet#

NetSheet.tsx recalculates on every keystroke: sale price minus commission (percent), closing costs (percent), mortgage payoff and repairs or credits (the defaults net $376,050). The colored bar shows each part as a share of the price, and a warning appears if costs exceed the price. Money fields (data-money) get thousands separators when you leave them.

Affordability calculator#

Affordability.tsx applies the 28/36 rule from AFFORD: housing costs up to 28% of gross monthly income, and housing plus debts up to 36%. affordability() finds the highest price whose monthly cost (principal and interest, property tax, insurance, mortgage insurance below 20% down, HOA dues) fits both limits, the component draws an SVG donut of the payment and links to /listings?max= with the highest price step at or below the result. With the defaults that is $601,000 at $3,843 a month, linking to homes under $600K. Change FRONT_END, BACK_END, PMI_RATE or PMI_BELOW to use other assumptions.

Neighborhood map#

On /neighborhoods, AreaExplorer.tsx draws the map and the info panel. Hovering or focusing an area highlights its guide card ([data-area-card]) and fills the panel; hovering or focusing a card highlights its area. Each area is a link to its guide, so Tab and Enter work. Selecting an area (or arriving with a hash such as #east-nashville) keeps it highlighted. Counts of homes for sale come from LISTINGS; the statistics and guide text from AREAS.

To add a neighborhood, add it to AREAS (the guide card appears automatically) and add its shape to MAP_SHAPES.

Consultation booking#

Booking.tsx on /contact runs the booking form:

  1. Consult type: radio cards for CONSULT_TYPES (buyer, seller, value, relocation). ?type=seller preselects one, and &listing=<id> fills in the message.
  2. Day: a strip of the next AVAILABILITY.daysShown days that have a free slot, skipping closed dates and days without hours.
  3. Time: slots every slotStep minutes inside the day's windows, long enough for the chosen consult and at least minNoticeHours from now, all in Nashville time. The summary adds the visitor's local time when it differs.
  4. Details: name, email, optional phone, timeline and message.

The summary panel updates live. On submit, missing choices are reported inline and focus moves to the first problem (the day strip when no day is picked). The confirmation repeats the booking and offers an .ics download; "Book another" starts again. demoBusyShare marks a fixed share of slots as taken (the same ones on every load) so the demo looks realistic; set it to 0 when connected to a real calendar. The days and times depend on the clock, so they are filled in by the browser.

  • Testimonials are a native scroll-snap track with previous and next buttons and a "1 / 6" status (HomeEffects.tsx). Swipe, trackpad and keyboard scrolling work.
  • Count-up stats animate elements with data-count (and optional data-decimals) once they scroll into view.
  • Scroll reveals (RevealObserver.tsx) fade in .reveal elements. Revealing an element also reveals everything above it, so jumping with the End key or an anchor never leaves hidden content.
  • FAQ buttons toggle their panel and aria-expanded (Faq.tsx).
  • Back to top (BackToTop.tsx) appears after a screen of scrolling; remove it from layout.tsx and delete the .back-to-top styles in globals.css to drop it.

Animations, smooth scrolling and transitions are turned off for visitors who prefer reduced motion.

Forms#

Every form is a demo: it validates in the browser and shows a success state, but nothing is sent or booked until you connect it. The success messages say so.

Validation is the useValidation() hook in src/components/useValidation.tsx. A field is checked when it has data-validate; data-message replaces the "empty" message and the wording is in fieldMessage():

data-validate Rule
required Must be filled
email A valid email address (required when the field has required)
phone-optional Blank, or at least 7 digits
address At least 5 characters
sqft Between VALUATION.minSqft and VALUATION.maxSqft square feet
future-date Today or later
choice One radio of the group is chosen (on the group's first radio)

Errors appear under the field (or the group), set aria-invalid, are linked with aria-describedby, focus the first problem and clear as the visitor fixes them.

Form File Validates
Newsletter src/components/Newsletter.tsx Email
Tour request src/components/TourForm.tsx Date (today or later), name, email, optional phone
Home value wizard src/components/Valuation.tsx Address, neighborhood, square feet
Booking src/components/Booking.tsx Consult type, day, time, name, email, optional phone

Connecting to Formspree#

Create a form at Formspree, then in onSubmit() in Newsletter.tsx or TourForm.tsx (search for "Connect a real endpoint here") send the data before showing the success state:

tsx
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
  const form = e.currentTarget;
  if (!validate(form)) return;
  const res = await fetch("https://formspree.io/f/your-id", {
    method: "POST",
    body: new FormData(form),
    headers: { Accept: "application/json" },
  });
  if (!res.ok) return alert("Sorry, that didn’t send. Please email hello@deliaquintero.example.");
  setDone(true);
}

Every field has a name, so FormData picks them all up; the tour form also sends the listing address in a hidden listing field. Then remove the "Demo only" sentence from the success panel.

If you remove the static export (see Building and deploying), you can post to a Route Handler instead (src/app/api/tour/route.ts with export async function POST(request: Request)) or call a Server Action from onSubmit.

Connecting a booking tool#

The booking flow is a front end only. Either replace <Booking /> in src/app/contact/page.tsx with your Cal.com or Calendly embed, or keep it and send the request in onSubmit() in Booking.tsx (the block marked "Connect a real booking tool"): post type, slot.start.toISOString() and the details fields to your form service or server. Set AVAILABILITY.demoBusyShare to 0 either way, and load real busy times from your calendar if you keep the flow.

SEO and sharing#

Each route exports metadata built with pageMeta(title, description) from src/lib/metadata.ts: the exact <title>, the description, Open Graph tags with public/img/og-image.png, and a Twitter card. The listing page sets its title in the browser. The 404 page adds robots: { index: false }. Social networks need absolute image URLs, so set your domain when building; it becomes metadataBase in layout.tsx:

sh
NEXT_PUBLIC_SITE_URL=https://www.your-domain.com pnpm build

Consider adding src/app/sitemap.ts and src/app/robots.ts, and RealEstateAgent structured data (schema.org JSON-LD) with your real name, office and phone. Real estate sites usually must show license and brokerage details and fair-housing wording: the footer has placeholders (license no. 000000, Larkspur & Main Realty); replace them with your own and check your state's advertising rules.

Accessibility#

Built in:

  • A skip link, landmarks (header, nav, main, footer), one <h1> per page (the listing not-found state has its own) and a logical heading order.
  • Visible focus outlines (cobalt on light, butter on dark backgrounds) and WCAG AA contrast for text.
  • The mobile menu button uses aria-expanded and aria-controls; Escape closes the menu and returns focus.
  • Status tabs and day buttons use aria-pressed, bedroom and consult choices are native radios; result counts, the estimate teaser, calculator results and the booking summary are aria-live regions.
  • The map areas are links with descriptive labels; illustrations are labelled (role="img") or hidden from screen readers when decorative. Booked time slots are announced as "booked" rather than offered.
  • Motion is reduced for visitors who prefer it.

When you edit, keep one <h1> per page, give every new input a <label>, and keep the data-* attributes the components use for validation and state.

Building and deploying#

sh
pnpm build        # static site in out/
pnpm start        # preview out/

next.config.ts sets output: "export", images.unoptimized and basePath from DEMO_BASE_PATH. To host the site in a sub-folder, build with that folder (DEMO_BASE_PATH=/atlas pnpm build); links, scripts and asset() image URLs all pick it up. Deploy out/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3) or import the repository into Vercel. Most hosts serve 404.html for missing pages automatically. demo/ is the freeframe.dev demo build and can be deleted.

To use server features (Route Handlers, Server Actions, one static page per listing with fresh data), remove output: "export" from next.config.ts and deploy to Vercel or any Node.js host with next build and next start.

@typescript/native provides TypeScript 7's tsc for pnpm typecheck; typescript is aliased to @typescript/typescript6 because typescript-eslint needs the TypeScript 6 API. Replace both with typescript@7 once typescript-eslint supports it.

Troubleshooting#

Problem Fix
A listing page says "That listing isn't on my desk" The ?id= doesn't match any id in LISTINGS. Check the spelling.
A new listing doesn't appear Its status must be active, pending or sold and its area an id in AREAS; pnpm typecheck reports shape errors. Homes for sale only show on the default tab.
Open houses show the wrong day or time Weekdays are sun to sat, times are 24-hour Nashville time, and the zone is AGENT.timeZone (an IANA name such as America/Chicago).
No booking days appear Check AVAILABILITY.hours (day 0 is Sunday) and closed, and that minNoticeHours isn't larger than your windows.
A filtered listings link opens unfiltered The values must be valid: an AREAS id for area, a PRICE_STEPS value for min and max, 1 to 4 for beds.
The social preview has no image Build with NEXT_PUBLIC_SITE_URL set to your domain so og:image is an absolute URL.
Images or styles are missing under a sub-folder Build with DEMO_BASE_PATH set to that folder and use asset() for files in public/.
Content stays invisible after editing .reveal elements are hidden until RevealObserver reveals them; check the browser console for errors.

License#

MIT, see the repository license (included as LICENSE.txt in the download). All illustrations (houses, rooms, the portrait, map, icons and social image) are original to this template and covered by the same license. Gloock and Albert Sans are under the SIL Open Font License 1.1 (src/fonts/OFL.txt). The agent, brokerage, homes, prices and clients are fictional.

Search documentation

Search the guides and every template's documentation