Zum Inhalt springen
Alle Templates

Templates/Immobilienmakler

Für Maklerbüros, Hausverwaltungen und alle, die Vertrauen vor Quadratmetern verkaufen. Sehr helles Grau und Weiß tragen die Seite, Fast-Schwarz ist die Schrift, und ein tiefes Grün markiert nur, was gerade aktiv ist. Albert Sans hält alles, dazu eine kursive Serifenschrift für ein oder zwei Wörter pro Überschrift. Jeder Abschnitt erscheint als kurze Folge, die Zahlen zählen einmal hoch, die Kontaktszene wächst auf volle Größe, und der Name in der Fußzeile steigt an seinen Platz. Auf dem Telefon und bei reduzierter Bewegung wird die stehende Ebene zu einem normalen Stapel.

ImmobilienKomplette Seite

Die fertige Seite zum Durchscrollen, umschaltbar zwischen Desktop, Tablet und Handy.

Quellcode

Die Seite ist eine Datei, lädt aber 13 Bilder (2,9 MB) aus public/. Kopierst du nur den Code, fehlen sie. Das Paket enthält beides an den richtigen Pfaden.

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type FormEvent,
  type ReactNode,
  type RefObject,
} from "react";
import {
  AnimatePresence,
  animate,
  motion,
  useInView,
  useReducedMotion,
  useScroll,
  useTransform,
  type MotionValue,
} from "framer-motion";
import {
  ArrowRight,
  ArrowUp,
  ArrowUpRight,
  BedDouble,
  BookOpen,
  Briefcase,
  Calculator,
  CalendarCheck,
  Check,
  Eye,
  Facebook,
  House,
  Instagram,
  KeyRound,
  Mail,
  MapPin,
  Menu,
  MessageCircle,
  MessagesSquare,
  Newspaper,
  Phone,
  Plus,
  Ruler,
  Search,
  Tag,
  UserRound,
  Users,
  X,
  type LucideIcon,
} from "lucide-react";

/*
 * A full page for an independent estate agency in a mid sized city.
 *
 * Very light grey and white carry the page, near black is the type, and a
 * deep green is the one accent: it marks what is active, never what is
 * merely there. Hierarchy comes from size and surface changes; the only
 * dark surfaces are the district map, the figures band, the contact scene
 * and the footer.
 *
 * Two voices: Albert Sans for everything, Instrument Serif italic for one or
 * two words per headline, so a sentence can change tone without changing
 * colour.
 *
 * The scroll is paced rather than busy:
 *
 *   - the hero opens out of a slightly inset frame, then recedes and darkens
 *     as the page slides over it,
 *   - one long sentence is read in word by word, with two photographs set
 *     into it like words,
 *   - the services hold still while the next layer slides over them,
 *   - the district map settles in from the centre outwards and names each
 *     district on hover or tap,
 *   - the figures count up once, the contact scene grows to full size and
 *     the name in the footer rises into place.
 *
 * Every section enters as one short sequence: label, headline, text, then
 * the content, a fifth of a second apart. With reduced motion everything is
 * simply there, and the held layer turns into an ordinary stack.
 */

const IMG = "/templates/real-estate";

const SANS = '"Albert Sans", ui-sans-serif, system-ui, sans-serif';
const SERIF = '"Instrument Serif", ui-serif, Georgia, serif';

const INK = "#15171a";
const INK_2 = "#3a3d42";
const MUTED = "#676b71";
const PAPER = "#f2f3f2";
const PAPER_2 = "#e7e9e7";
const NIGHT = "#1a1c1f";
const GREEN = "#2e5b3f";
const LEAF = "#a8cf96";

const EASE = [0.16, 1, 0.3, 1] as const;
const EASE_IN_OUT = [0.87, 0, 0.13, 1] as const;
// Gap between two entrances: wide enough to read as a sequence, short
// enough that the last item is in place before anyone reaches it.
const STEP = 0.2;

/* --------------------------------------------------------------------------
   Content
   ----------------------------------------------------------------------- */

const COMPANY = {
  name: "Hale & Linden",
  street: "14 Linden Row",
  district: "Old Harbour",
  phone: { display: "020 7946 0321", href: "tel:+442079460321" },
  email: "hello@haleandlinden.com",
  founded: 2009,
};

const NAV = [
  { label: "Homes", href: "#homes", icon: House },
  { label: "Sell", href: "#sell", icon: Tag },
  { label: "Let", href: "#let", icon: KeyRound },
  { label: "About", href: "#about", icon: UserRound },
  { label: "Journal", href: "#journal", icon: Newspaper },
];

type Service = { id: string; title: string; text: string; icon: LucideIcon };

const SERVICES: Service[] = [
  {
    id: "sell",
    title: "Sell",
    text: "Priced from the street up and shown to buyers who are already looking.",
    icon: Tag,
  },
  {
    id: "let",
    title: "Let",
    text: "Tenants checked, contracts drafted, keys handed over on the day.",
    icon: KeyRound,
  },
  {
    id: "find",
    title: "Find",
    text: "Homes that fit how you live, often before they reach the portals.",
    icon: Search,
  },
  {
    id: "value",
    title: "Value",
    text: "Walked through in person, not guessed by an algorithm. Free of charge.",
    icon: Calculator,
  },
];

type Listing = {
  location: string;
  type: string;
  area: string;
  rooms: string;
  price: string;
  note?: string;
  image: string;
  focus: string;
  alt: string;
};

const LISTINGS: Listing[] = [
  {
    location: "Kingsmead",
    type: "Detached new build",
    area: "212 m²",
    rooms: "6 rooms",
    price: "€1,180,000",
    image: `${IMG}/listing-villa.webp`,
    focus: "50% 60%",
    alt: "Low white villa with a flat roof, wide glazing and an old olive tree beside the drive",
  },
  {
    location: "Ashgrove",
    type: "Family home with garden",
    area: "164 m²",
    rooms: "5 rooms",
    price: "€865,000",
    note: "Home of the month",
    image: `${IMG}/listing-family-home.webp`,
    focus: "45% 55%",
    alt: "Pale stone family home between olive trees and a planted front garden",
  },
  {
    location: "Elmstead",
    type: "Bungalow under old oaks",
    area: "138 m²",
    rooms: "4 rooms",
    price: "€720,000",
    image: `${IMG}/listing-bungalow.webp`,
    focus: "60% 55%",
    alt: "Single storey timber and glass house under spreading oak trees in the late sun",
  },
];

const VOICES = [
  {
    quote:
      "They sold our flat in five weeks, and every viewing was one we would have picked ourselves.",
    author: "Hannah W.",
    context: "Sold a flat",
  },
  {
    quote:
      "What stood out was how well they knew the buyers. Two of them had been waiting for a house like ours.",
    author: "Dr. Markus T.",
    context: "Client since 2016",
  },
  {
    quote: "The valuation was the price we got in the end. No bidding games, no surprises.",
    author: "Sara and Tom K.",
    context: "Sold a house",
  },
];

const FIGURES: { count?: number; prefix?: string; value?: string; label: string }[] = [
  { count: 212, label: "client reviews" },
  { count: 240, prefix: "about ", label: "lettings a year" },
  { count: 38, label: "partner lenders" },
  { value: "2009", label: "founded in the city" },
];

const JOURNAL = [
  {
    category: "Letting",
    date: "October 2026",
    title: "Viewings: when a tenant has to open the door",
    image: `${IMG}/journal-1.webp`,
    alt: "Kitchen with a stone island, wide windows and a vase of olive branches in the late sun",
  },
  {
    category: "Selling",
    date: "October 2026",
    title: "Selling with tenants in place: what buyers ask first",
    image: `${IMG}/journal-2.webp`,
    alt: "Long stone corridor with tall openings and bands of sunlight on the floor",
  },
  {
    category: "Ownership",
    date: "September 2026",
    title: "Balcony repairs and who pays for them",
    image: `${IMG}/journal-3.webp`,
    alt: "A woman walking along a curved garden path between tall grasses, seen from above",
  },
];

type District = {
  name: string;
  d: string;
  cx: number;
  cy: number;
  price: string;
  note: string;
};

// A city that does not exist: eleven districts cut from one organic outline,
// so the shapes read as a real plan without pointing at a real place.
const DISTRICTS: District[] = [
  {
    name: "Old Harbour",
    d: "M292 316 L355 225 L475 243 L445 369 L383 400 Z",
    cx: 389,
    cy: 305,
    price: "€5,400",
    note: "Warehouse flats around the market square",
  },
  {
    name: "The Quays",
    d: "M445 369 L475 243 L495 230 L565 246 L605 321 L552 414 Z",
    cx: 523,
    cy: 320,
    price: "€6,100",
    note: "New builds along the water",
  },
  {
    name: "Ashgrove",
    d: "M322 132 L355 225 L292 316 L254 319 L172 203 Z",
    cx: 273,
    cy: 226,
    price: "€4,700",
    note: "Terraces, schools and the long park",
  },
  {
    name: "Millbrook",
    d: "M475 243 L355 225 L322 132 L343 44 L345 44 L400 46 L452 56 L478 64 L495 230 Z",
    cx: 411,
    cy: 142,
    price: "€4,200",
    note: "Former mill streets, now family homes",
  },
  {
    name: "Northfield",
    d: "M565 246 L495 230 L478 64 L500 70 L547 85 L594 98 L644 115 L684 132 Z",
    cx: 560,
    cy: 154,
    price: "€3,900",
    note: "Detached houses with deep gardens",
  },
  {
    name: "Kingsmead",
    d: "M778 282 L770 322 L761 343 L605 321 L565 246 L684 132 L693 136 L733 165 L762 201 L776 241 Z",
    cx: 683,
    cy: 249,
    price: "€4,900",
    note: "Villas on the edge of the meadows",
  },
  {
    name: "Riverside",
    d: "M761 343 L754 361 L728 397 L691 427 L645 450 L593 464 L568 468 L552 414 L605 321 Z",
    cx: 644,
    cy: 390,
    price: "€5,200",
    note: "Towpath, boathouses and quiet lanes",
  },
  {
    name: "Elmstead",
    d: "M568 468 L541 472 L493 478 L447 485 L400 496 L367 503 L383 400 L445 369 L552 414 Z",
    cx: 458,
    cy: 437,
    price: "€4,400",
    note: "Bungalows under old trees",
  },
  {
    name: "Westcote",
    d: "M129 460 L98 432 L71 397 L50 360 L37 321 L34 282 L44 243 L66 208 L97 176 L100 174 L172 203 L254 319 Z",
    cx: 131,
    cy: 309,
    price: "€3,600",
    note: "A village twelve minutes from the centre",
  },
  {
    name: "Fenwick",
    d: "M367 503 L348 507 L291 512 L233 507 L179 491 L134 465 L129 460 L254 319 L292 316 L383 400 Z",
    cx: 271,
    cy: 427,
    price: "€3,800",
    note: "Post war semis, good value",
  },
  {
    name: "Hollow Lane",
    d: "M100 174 L131 148 L166 121 L202 95 L243 71 L291 53 L343 44 L322 132 L172 203 Z",
    cx: 234,
    cy: 126,
    price: "€4,100",
    note: "Cottages and allotments",
  },
];

const TOPICS: { label: string; icon: LucideIcon }[] = [
  { label: "Sell a home", icon: Tag },
  { label: "Let a home", icon: KeyRound },
  { label: "Find a home", icon: Search },
  { label: "Valuation", icon: Calculator },
  { label: "Something else", icon: MessageCircle },
];

const DAYPARTS = ["Morning", "Afternoon", "Flexible"];

/* --------------------------------------------------------------------------
   Styles that Tailwind cannot express on its own: the type scale, the hover
   disclosures and the swipe rows. Prefixed, so a copied page does not
   collide with anything around it.
   ----------------------------------------------------------------------- */

const CSS = `
.hl-root {
  --frame: clamp(0.5rem, 0.9vw, 0.875rem);
  --gutter: clamp(1.25rem, 3.4vw, 3rem);
  --col-gap: clamp(1rem, 2vw, 2rem);
  --section-y: clamp(5.5rem, 13vw, 13rem);
  --nav-h: calc(4rem + var(--frame));
  font-family: ${SANS};
  background: ${PAPER};
  color: ${INK};
  -webkit-font-smoothing: antialiased;
  overflow-x: clip;
}
.hl-root ::selection { background: ${INK}; color: ${PAPER}; }
.hl-root :focus-visible { outline: 2px solid ${GREEN}; outline-offset: 4px; border-radius: 2px; }
.hl-root .hl-dark :focus-visible { outline-color: ${LEAF}; }
.hl-shell { width: 100%; max-width: 110rem; margin-inline: auto; padding-inline: var(--gutter); }
.hl-section-y { padding-block: var(--section-y); }
.hl-display { font-size: clamp(2.5rem, 6vw, 6.6rem); line-height: 0.98; letter-spacing: -0.042em; font-weight: 400; }
.hl-h2 { font-size: clamp(1.95rem, 3.6vw, 3.8rem); line-height: 1.08; letter-spacing: -0.032em; font-weight: 400; }
.hl-statement { font-size: clamp(1.7rem, 4.1vw, 4.7rem); line-height: 1.12; letter-spacing: -0.034em; font-weight: 400; }
@media (max-width: 47.99rem) { .hl-statement { font-size: clamp(2rem, 8.6vw, 2.4rem); } }
.hl-h3 { font-size: clamp(1.45rem, 1.9vw, 1.9rem); line-height: 1.14; letter-spacing: -0.022em; font-weight: 450; }
.hl-h4 { font-size: clamp(1.125rem, 1.3vw, 1.3rem); line-height: 1.25; letter-spacing: -0.016em; font-weight: 450; }
.hl-lead { font-size: clamp(1.0625rem, 1.2vw, 1.25rem); line-height: 1.5; letter-spacing: -0.01em; }
.hl-body { font-size: 0.9375rem; line-height: 1.62; }
.hl-small { font-size: 0.8125rem; line-height: 1.45; }
.hl-stat { font-size: clamp(2rem, 3.6vw, 3.5rem); line-height: 1; letter-spacing: -0.04em; font-weight: 400; }
.hl-serif { font-family: ${SERIF}; font-style: italic; font-weight: 400; font-size: 1.1em; letter-spacing: -0.01em; }
.hl-wordmark { font-size: 15.6vw; line-height: 0.76; letter-spacing: -0.06em; font-weight: 450; white-space: nowrap; }
.hl-balance { text-wrap: balance; }
.hl-pretty { text-wrap: pretty; }
.hl-line { display: block; overflow: clip; padding-bottom: 0.08em; margin-bottom: -0.08em; padding-right: 0.08em; }

/* Icon of a button: takes no room until hover or focus, then slides in. */
.hl-btn-icon { display: grid; grid-template-columns: 0fr; margin-left: 0; opacity: 0;
  transition: grid-template-columns .55s cubic-bezier(.16,1,.3,1), margin-left .55s cubic-bezier(.16,1,.3,1), opacity .3s ease; }
.hl-btn-icon > span { display: flex; min-width: 0; overflow: hidden; }
.group:hover .hl-btn-icon, .group:focus-visible .hl-btn-icon { grid-template-columns: 1fr; margin-left: .625rem; opacity: 1; }

/* Label beside an icon: collapsed until hover, then it slides out. */
.hl-btn-label { display: grid; grid-template-columns: 0fr; opacity: 0;
  transition: grid-template-columns .55s cubic-bezier(.16,1,.3,1), opacity .3s ease; }
.hl-btn-label > span { min-width: 0; overflow: hidden; white-space: nowrap; padding-left: 0; transition: padding-left .55s cubic-bezier(.16,1,.3,1); }
.group:hover .hl-btn-label, .group:focus-visible .hl-btn-label { grid-template-columns: 1fr; opacity: 1; }
.group:hover .hl-btn-label > span, .group:focus-visible .hl-btn-label > span { padding-left: .625rem; }

/* Hairline that draws in from the left on hover and leaves to the right. */
.hl-draw { position: relative; }
.hl-draw::after { content: ""; position: absolute; inset-inline: 0; bottom: -0.12em; height: 1px; background: currentColor;
  transform: scaleX(0); transform-origin: right center; transition: transform .6s cubic-bezier(.16,1,.3,1); }
.group:hover .hl-draw::after, .group:focus-visible .hl-draw::after, .hl-draw:hover::after { transform: scaleX(1); transform-origin: left center; }

/* Underline that is there at rest and leaves on hover. */
.hl-under { background-image: linear-gradient(currentColor, currentColor); background-size: 100% 1px; background-position: 0 100%;
  background-repeat: no-repeat; padding-bottom: .15em; transition: background-size .6s cubic-bezier(.16,1,.3,1); }
.hl-under:hover { background-size: 0% 1px; background-position: 100% 100%; }

.hl-zoom img { transition: transform 1.4s cubic-bezier(.16,1,.3,1); }
.group:hover .hl-zoom img, .group:focus-within .hl-zoom img { transform: scale(1.03); }

/* Secondary information appears on hover or focus; touch screens always show it. */
@media (hover: hover) and (pointer: fine) {
  .hl-reveal { opacity: 0; transform: translate3d(0, .4rem, 0); transition: opacity .45s ease, transform .6s cubic-bezier(.16,1,.3,1); }
  .group:hover .hl-reveal, .group:focus-within .hl-reveal { opacity: 1; transform: none; }
}

/* Phones and portrait tablets: card rows become a swipeable strip with the next card peeking in. */
@media (max-width: 63.99rem) {
  .hl-swipe { display: flex; gap: .75rem; margin-inline: calc(var(--gutter) * -1); padding-inline: var(--gutter);
    overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scroll-padding-inline: var(--gutter); scrollbar-width: none; }
  .hl-swipe::-webkit-scrollbar { display: none; }
  .hl-swipe > * { flex: 0 0 84%; scroll-snap-align: start; }
}
@media (min-width: 48rem) and (max-width: 63.99rem) {
  .hl-swipe { gap: var(--col-gap); }
  .hl-swipe > * { flex-basis: 44%; }
}

.hl-district { cursor: pointer; transition: fill .5s ease; }
.hl-ring { animation: hl-pulse 2.8s cubic-bezier(.25,1,.5,1) infinite; transform-box: fill-box; transform-origin: center; }
@keyframes hl-pulse { 0% { opacity: .9; transform: scale(.4); } 70%, 100% { opacity: 0; transform: scale(1.6); } }
@media (prefers-reduced-motion: reduce) { .hl-ring { animation: none; opacity: 0; } }
`;

/* --------------------------------------------------------------------------
   Small helpers
   ----------------------------------------------------------------------- */

function useMedia(query: string) {
  const [match, setMatch] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia(query);
    const on = () => setMatch(mq.matches);
    on();
    mq.addEventListener("change", on);
    return () => mq.removeEventListener("change", on);
  }, [query]);
  return match;
}

// A section knows when it has come into view; everything inside it enters
// from that moment on, in its own slot. That keeps the order fixed even when
// the items further down are already on screen.
const Shown = createContext(false);

function Section({
  id,
  className = "",
  style,
  labelledBy,
  margin = "0px 0px -25% 0px",
  children,
}: {
  id?: string;
  className?: string;
  style?: CSSProperties;
  labelledBy?: string;
  margin?: `${number}px ${number}px ${number}% ${number}px`;
  children: ReactNode;
}) {
  const ref = useRef<HTMLElement>(null);
  const shown = useInView(ref, { once: true, margin });
  return (
    <section ref={ref} id={id} aria-labelledby={labelledBy} className={className} style={style}>
      <Shown.Provider value={shown}>{children}</Shown.Provider>
    </section>
  );
}

type Kind = "up" | "left" | "right" | "fade" | "scale";

function Reveal({
  kind = "up",
  order = 0,
  as = "div",
  id,
  className,
  style,
  children,
}: {
  kind?: Kind;
  order?: number;
  as?: "div" | "li";
  id?: string;
  className?: string;
  style?: CSSProperties;
  children: ReactNode;
}) {
  const shown = useContext(Shown);
  const reduce = useReducedMotion();
  const small = useMedia("(max-width: 47.99rem)");
  const d = small ? 24 : 44;
  const hidden =
    kind === "left"
      ? { opacity: 0, x: -d }
      : kind === "right"
        ? { opacity: 0, x: d }
        : kind === "fade"
          ? { opacity: 0 }
          : kind === "scale"
            ? { opacity: 0, scale: 0.94 }
            : { opacity: 0, y: d * 0.75 };
  const Tag = as === "li" ? motion.li : motion.div;
  return (
    <Tag
      id={id}
      className={className}
      style={style}
      initial={reduce ? false : hidden}
      animate={reduce || shown ? { opacity: 1, x: 0, y: 0, scale: 1 } : hidden}
      transition={{
        duration: kind === "fade" ? 1.2 : 1.1,
        ease: kind === "fade" ? "easeOut" : EASE,
        delay: order * STEP,
      }}
    >
      {children}
    </Tag>
  );
}

function Serif({ children }: { children: ReactNode }) {
  return <span className="hl-serif">{children}</span>;
}

function Label({
  children,
  icon: Icon,
  tone = "ink",
}: {
  children: ReactNode;
  icon?: LucideIcon;
  tone?: "ink" | "paper";
}) {
  return (
    <span
      className="inline-flex h-7 w-fit items-center gap-1.5 rounded-[5px] px-2.5 text-[0.6875rem] leading-none font-medium tracking-[0.09em] uppercase"
      style={
        tone === "paper"
          ? { background: "rgba(255,255,255,0.1)", color: "rgba(255,255,255,0.78)" }
          : { background: "rgba(21,23,26,0.055)", color: "rgba(21,23,26,0.66)" }
      }
    >
      {Icon && <Icon size={14} strokeWidth={1.6} aria-hidden />}
      {children}
    </span>
  );
}

type Variant = "ink" | "paper" | "outline-paper" | "outline-ink" | "green";

const BUTTON =
  "group inline-flex h-11 items-center justify-center rounded-[8px] px-5 text-[0.875rem] font-medium tracking-[-0.005em] transition-colors duration-300";

const VARIANTS: Record<Variant, string> = {
  ink: "bg-[#15171a] text-white hover:bg-[#3a3d42]",
  green: "bg-[#2e5b3f] text-white hover:bg-[#244a33]",
  paper: "bg-white text-[#15171a] hover:bg-[#f2f3f2]",
  "outline-paper":
    "border border-white/45 bg-black/25 text-white hover:border-white hover:bg-white hover:text-[#15171a]",
  "outline-ink":
    "border border-[#15171a]/20 text-[#15171a] hover:border-[#15171a] hover:bg-[#15171a] hover:text-white",
};

function ButtonIcon({ icon: Icon, size = 17 }: { icon: LucideIcon; size?: number }) {
  return (
    <span className="hl-btn-icon" aria-hidden>
      <span>
        <Icon size={size} strokeWidth={1.7} />
      </span>
    </span>
  );
}

function Action({
  href,
  onClick,
  variant = "ink",
  icon,
  className = "",
  children,
}: {
  href?: string;
  onClick?: () => void;
  variant?: Variant;
  icon?: LucideIcon;
  className?: string;
  children: ReactNode;
}) {
  const inner = (
    <>
      <span>{children}</span>
      {icon && <ButtonIcon icon={icon} />}
    </>
  );
  const cls = `${BUTTON} ${VARIANTS[variant]} ${className}`;
  if (onClick)
    return (
      <button type="button" onClick={onClick} className={cls}>
        {inner}
      </button>
    );
  return (
    <a href={href} className={cls}>
      {inner}
    </a>
  );
}

function Logo({ style }: { style?: CSSProperties }) {
  return (
    <span
      className="inline-flex items-baseline leading-none font-medium tracking-[-0.035em]"
      style={{ fontSize: "1.375rem", ...style }}
    >
      Hale
      <span
        className="mx-[0.14em] text-[1.18em]"
        style={{ fontFamily: SERIF, fontStyle: "italic" }}
      >
        &amp;
      </span>
      Linden
    </span>
  );
}

/** Section opening: label and two tone headline on the left, the action on the right. */
function Intro({
  id,
  label,
  icon,
  lead,
  rest,
  action,
  from = "up",
  actionOnPhones = true,
}: {
  id: string;
  label: string;
  icon: LucideIcon;
  lead: ReactNode;
  rest?: ReactNode;
  action?: ReactNode;
  from?: Kind;
  actionOnPhones?: boolean;
}) {
  return (
    <div className="grid grid-cols-12 items-end gap-x-[var(--col-gap)] gap-y-8">
      <div className="col-span-12 lg:col-span-8">
        <Reveal kind={from} order={0}>
          <Label icon={icon}>{label}</Label>
        </Reveal>
        <Reveal kind={from} order={1}>
          <h2 id={id} className="hl-balance hl-h2 mt-6 max-w-[22ch]">
            {lead}
            {rest && (
              <>
                {" "}
                <span style={{ color: "rgba(21,23,26,0.45)" }}>{rest}</span>
              </>
            )}
          </h2>
        </Reveal>
      </div>
      {action && (
        <Reveal
          kind="fade"
          order={2}
          className={`col-span-12 lg:col-span-4 lg:col-start-9 lg:justify-self-end lg:pb-2 ${
            actionOnPhones ? "" : "max-md:hidden"
          }`}
        >
          {action}
        </Reveal>
      )}
    </div>
  );
}

/* --------------------------------------------------------------------------
   Navigation
   ----------------------------------------------------------------------- */

function Navigation({ onBook }: { onBook: () => void }) {
  const reduce = useReducedMotion();
  const [overHero, setOverHero] = useState(true);
  const [hidden, setHidden] = useState(false);
  const [open, setOpen] = useState(false);
  const [atFooter, setAtFooter] = useState(false);

  // Colour follows the hero, visibility follows the reading direction: the
  // bar steps away while reading down and comes back on the first scroll up.
  useEffect(() => {
    let last = window.scrollY;
    let ticking = false;
    const update = () => {
      const hero = document.getElementById("hl-top");
      const y = window.scrollY;
      if (hero) setOverHero(hero.getBoundingClientRect().bottom > 72);
      const delta = y - last;
      if (Math.abs(delta) > 6) {
        setHidden(delta > 0 && y > window.innerHeight * 0.9);
        last = y;
      }
      ticking = false;
    };
    const onScroll = () => {
      if (ticking) return;
      ticking = true;
      requestAnimationFrame(update);
    };
    update();
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, []);

  // The footer carries its own booking link, so the floating button steps aside there.
  useEffect(() => {
    const footer = document.getElementById("hl-footer");
    if (!footer) return;
    const io = new IntersectionObserver(([e]) => setAtFooter(e.isIntersecting), {
      rootMargin: "0px 0px -15% 0px",
    });
    io.observe(footer);
    return () => io.disconnect();
  }, []);

  useEffect(() => {
    if (!open) return;
    const prev = document.documentElement.style.overflow;
    document.documentElement.style.overflow = "hidden";
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
    window.addEventListener("keydown", onKey);
    return () => {
      document.documentElement.style.overflow = prev;
      window.removeEventListener("keydown", onKey);
    };
  }, [open]);

  const solid = !overHero || open;

  return (
    <>
      <motion.header
        initial={reduce ? false : { opacity: 0, y: -14 }}
        animate={{ opacity: 1, y: hidden && !open ? "-140%" : 0 }}
        transition={{
          opacity: { duration: 1.2, ease: EASE, delay: 0.6 },
          y: { duration: 0.7, ease: EASE, delay: 0 },
        }}
        className="pointer-events-none fixed inset-x-0 top-0 z-50 px-[calc(var(--frame)*2)] pt-[calc(var(--frame)*2)]"
      >
        <div
          className="pointer-events-auto mx-auto grid h-14 max-w-[104rem] grid-cols-[1fr_auto] items-center rounded-[12px] px-4 transition-colors duration-500 md:h-16 md:px-6 lg:grid-cols-[1fr_auto_1fr]"
          style={{
            background: open ? PAPER : solid ? NIGHT : "rgba(21,23,26,0)",
            color: open ? INK : "#ffffff",
          }}
        >
          <a
            href="#hl-top"
            className="-my-2 justify-self-start py-2"
            aria-label="Hale & Linden, back to top"
          >
            <Logo />
          </a>
          <nav aria-label="Main" className="hidden lg:block">
            <ul className="flex items-center gap-[clamp(1.5rem,2.4vw,2.5rem)]">
              {NAV.map((item) => (
                <li key={item.href}>
                  <a href={item.href} className="group py-2 text-[0.9375rem] tracking-[-0.01em]">
                    <span className="hl-draw">{item.label}</span>
                  </a>
                </li>
              ))}
            </ul>
          </nav>
          <div className="flex items-center gap-2 justify-self-end">
            <a href="#contact" className="group hidden px-3 py-2 text-[0.9375rem] lg:inline-flex">
              <span className="hl-draw">Contact</span>
            </a>
            <button
              type="button"
              onClick={onBook}
              className="group hidden h-10 items-center rounded-[8px] bg-white px-4 text-[0.875rem] font-medium text-[#15171a] transition-colors duration-300 hover:bg-[#e7e9e7] lg:inline-flex"
            >
              Book a viewing
              <ButtonIcon icon={CalendarCheck} size={16} />
            </button>
            <button
              type="button"
              onClick={() => setOpen((v) => !v)}
              aria-expanded={open}
              aria-controls="hl-menu"
              aria-label={open ? "Close menu" : "Open menu"}
              className="-mr-2 grid size-11 place-items-center lg:hidden"
            >
              {open ? <X size={22} strokeWidth={1.6} /> : <Menu size={22} strokeWidth={1.6} />}
            </button>
          </div>
        </div>
      </motion.header>

      {/* Phones and tablets: a full screen menu, one line icon per destination. */}
      <AnimatePresence>
        {open && (
          <motion.div
            id="hl-menu"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.35, ease: "easeOut" }}
            className="fixed inset-0 z-[45] overflow-y-auto pt-[calc(var(--frame)*2+5rem)] lg:hidden"
            style={{ background: PAPER, color: INK }}
          >
            <nav
              aria-label="Mobile"
              className="hl-shell flex min-h-full flex-col justify-between gap-10 pb-8"
            >
              <ul>
                {[...NAV, { label: "Contact", href: "#contact", icon: MessagesSquare }].map(
                  (item, i) => (
                    <motion.li
                      key={item.href}
                      initial={reduce ? false : { opacity: 0, y: 10 }}
                      animate={{ opacity: 1, y: 0 }}
                      transition={{ duration: 0.5, ease: EASE, delay: 0.05 + i * 0.05 }}
                    >
                      <a
                        href={item.href}
                        onClick={() => setOpen(false)}
                        className="flex items-center gap-4 py-4 transition-opacity active:opacity-60"
                      >
                        <item.icon
                          size={22}
                          strokeWidth={1.5}
                          className="shrink-0"
                          style={{ color: MUTED }}
                        />
                        <span className="text-[1.625rem] leading-none tracking-[-0.025em]">
                          {item.label}
                        </span>
                      </a>
                    </motion.li>
                  ),
                )}
              </ul>
              <motion.button
                type="button"
                initial={reduce ? false : { opacity: 0, y: 10 }}
                animate={{ opacity: 1, y: 0 }}
                transition={{ duration: 0.5, ease: EASE, delay: 0.4 }}
                onClick={() => {
                  setOpen(false);
                  onBook();
                }}
                className="hl-dark relative isolate flex min-h-40 items-end overflow-hidden rounded-[12px] p-5 text-left text-white"
                style={{ background: NIGHT }}
              >
                <img
                  src={`${IMG}/booking.webp`}
                  alt=""
                  className="absolute inset-0 -z-10 h-full w-full object-cover object-[50%_60%]"
                />
                <span
                  aria-hidden
                  className="absolute inset-0 -z-10"
                  style={{
                    background: "linear-gradient(to top, rgba(18,19,21,0.82), rgba(18,19,21,0.08))",
                  }}
                />
                <span className="flex w-full items-end justify-between gap-4">
                  <span>
                    <span className="hl-small block text-white/70">Talk it through in person</span>
                    <span className="mt-1 block text-[1.5rem] leading-none tracking-[-0.025em]">
                      Book a viewing
                    </span>
                  </span>
                  <span className="grid size-11 shrink-0 place-items-center rounded-[8px] bg-white text-[#15171a]">
                    <CalendarCheck size={20} strokeWidth={1.6} />
                  </span>
                </span>
              </motion.button>
            </nav>
          </motion.div>
        )}
      </AnimatePresence>

      {/* Phones: the booking stays one tap away; the label opens on hover where there is one. */}
      <button
        type="button"
        onClick={onBook}
        aria-label="Book a viewing"
        className={`group fixed right-[calc(var(--frame)+0.75rem)] bottom-[calc(var(--frame)+0.75rem)] z-40 inline-flex h-12 min-w-12 items-center justify-center rounded-[8px] px-3.5 text-[0.9375rem] font-medium text-white transition-[opacity,translate] duration-500 lg:hidden ${
          atFooter || open ? "pointer-events-none translate-y-3 opacity-0" : ""
        }`}
        style={{ background: NIGHT }}
      >
        <CalendarCheck size={20} strokeWidth={1.6} aria-hidden />
        <span className="hl-btn-label" aria-hidden>
          <span>Book a viewing</span>
        </span>
      </button>
    </>
  );
}

/* --------------------------------------------------------------------------
   Hero
   ----------------------------------------------------------------------- */

function Hero() {
  const reduce = useReducedMotion();
  const small = useMedia("(max-width: 47.99rem)");
  const card = useRef<HTMLDivElement>(null);
  const { scrollYProgress } = useScroll({ target: card, offset: ["start start", "end start"] });
  const scale = useTransform(scrollYProgress, [0, 1], [1, reduce ? 1 : small ? 0.94 : 0.9]);
  const shade = useTransform(scrollYProgress, [0, 1], [0, reduce ? 0 : 0.45]);
  const fade = useTransform(scrollYProgress, [0, 0.55], [1, reduce ? 1 : 0]);
  const lift = useTransform(scrollYProgress, [0, 0.55], [0, reduce ? 0 : small ? -60 : -120]);
  const drift = useTransform(scrollYProgress, [0, 1], ["0%", reduce ? "0%" : "12%"]);

  // The end state is always set: with reduced motion the copy simply jumps
  // there, instead of keeping the hidden state the server rendered.
  const enter = (delay: number, from: Record<string, number | string>) => ({
    initial: reduce ? false : from,
    animate: { opacity: 1, y: 0 },
    transition: reduce ? { duration: 0 } : { duration: 1.1, ease: EASE, delay },
  });

  return (
    <section
      id="hl-top"
      aria-labelledby="hl-hero-title"
      className="px-[var(--frame)] pt-[var(--frame)]"
    >
      <motion.div
        ref={card}
        style={{ scale, transformOrigin: "50% 35%" }}
        className="hl-dark relative isolate flex h-[calc(100svh-2*var(--frame))] min-h-[36rem] flex-col overflow-hidden rounded-[12px] text-white max-sm:h-[80svh] max-sm:min-h-[33rem]"
      >
        <motion.div
          className="absolute inset-0 -z-10 overflow-hidden rounded-[12px]"
          initial={reduce ? false : { clipPath: "inset(4% 3% 4% 3% round 12px)" }}
          animate={{ clipPath: "inset(0% 0% 0% 0% round 12px)" }}
          transition={{ duration: 1.6, ease: EASE_IN_OUT }}
          style={{ background: NIGHT }}
        >
          <motion.div className="absolute inset-0" style={{ y: drift }}>
            <motion.img
              src={`${IMG}/hero.webp`}
              alt="Stone steps through a planted front garden up to the timber door of a low, white house"
              className="h-full w-full object-cover object-[55%_50%] md:object-[50%_55%]"
              initial={reduce ? false : { scale: 1.12 }}
              animate={{ scale: 1 }}
              transition={{ duration: 2.4, ease: EASE }}
              fetchPriority="high"
            />
          </motion.div>
          {/* Shade only where the type sits: top edge for the bar, bottom left for the headline. */}
          <div
            className="absolute inset-x-0 top-0 h-[22%]"
            style={{
              background: "linear-gradient(to bottom, rgba(12,13,14,0.3), rgba(12,13,14,0))",
            }}
          />
          <div
            className="absolute inset-0 hidden md:block"
            style={{
              background:
                "radial-gradient(ellipse 100% 90% at 0% 100%, rgba(12,13,14,0.86) 0%, rgba(12,13,14,0.64) 34%, rgba(12,13,14,0.3) 58%, rgba(12,13,14,0) 85%)",
            }}
          />
          <div
            className="absolute inset-0 md:hidden"
            style={{
              background:
                "radial-gradient(ellipse 95% 46% at 50% 54%, rgba(12,13,14,0.58) 0%, rgba(12,13,14,0.22) 75%), rgba(12,13,14,0.28)",
            }}
          />
        </motion.div>

        {/* Darkens as the page slides over the hero. */}
        <motion.div
          aria-hidden
          className="pointer-events-none absolute inset-0 -z-[5]"
          style={{ background: NIGHT, opacity: shade }}
        />

        <motion.div
          style={{ opacity: fade, y: lift }}
          className="flex flex-1 flex-col justify-end px-[clamp(1.25rem,2.8vw,2.5rem)] pt-[calc(var(--nav-h)+2rem)] pb-[clamp(1.25rem,2.8vw,2.5rem)] max-sm:justify-center max-sm:text-center"
        >
          <motion.div
            className="mb-6 flex justify-center sm:hidden"
            {...enter(0.55, { opacity: 0, y: 10 })}
          >
            <Label tone="paper" icon={MapPin}>
              Estate agents since 2009
            </Label>
          </motion.div>
          <h1
            id="hl-hero-title"
            className="hl-display max-w-[15ch] max-sm:mx-auto max-sm:max-w-[20rem] max-sm:text-[2.5rem] max-sm:leading-[1.04] lg:max-w-none"
          >
            <span className="max-sm:hidden">
              <span className="hl-line">
                <motion.span
                  className="block"
                  initial={reduce ? false : { y: "108%" }}
                  animate={{ y: "0%" }}
                  transition={{ duration: 1.3, ease: EASE, delay: 0.8 }}
                >
                  Homes across the city.
                </motion.span>
              </span>{" "}
              <span className="hl-line">
                <motion.span
                  className="block"
                  initial={reduce ? false : { y: "108%" }}
                  animate={{ y: "0%" }}
                  transition={{ duration: 1.3, ease: EASE, delay: 0.98 }}
                >
                  Sold <Serif>in person.</Serif>
                </motion.span>
              </span>
            </span>
            <span className="sm:hidden">
              <span className="hl-line">
                <motion.span
                  className="block"
                  initial={reduce ? false : { y: "108%" }}
                  animate={{ y: "0%" }}
                  transition={{ duration: 1.3, ease: EASE, delay: 0.8 }}
                >
                  One advisor, from first call to keys.
                </motion.span>
              </span>
            </span>
          </h1>

          <div className="mt-10 grid grid-cols-12 items-end gap-x-[var(--col-gap)] gap-y-6 md:mt-12">
            <motion.p
              className="hl-pretty hl-body col-span-12 max-w-[28rem] text-white/85 max-sm:hidden md:col-span-6 lg:col-span-5"
              {...enter(1.35, { opacity: 0, y: 14 })}
            >
              Hale &amp; Linden looks after buying, selling and letting, with one named advisor from
              the first call to the handover of the keys.
            </motion.p>
            <motion.div
              className="col-span-12 flex flex-wrap items-center gap-3 max-sm:flex-col md:col-span-6 md:justify-self-end lg:col-span-5 lg:col-start-8"
              {...enter(1.53, { opacity: 0, y: 14 })}
            >
              <Action href="#homes" variant="paper" icon={Search} className="max-sm:w-60">
                Browse homes
              </Action>
              <Action href="#sell" variant="outline-paper" icon={Tag} className="max-sm:w-60">
                Sell your home
              </Action>
            </motion.div>
          </div>
        </motion.div>
      </motion.div>
    </section>
  );
}

/* --------------------------------------------------------------------------
   Statement: read in word by word, two photographs set in like words
   ----------------------------------------------------------------------- */

type Token =
  | { kind: "word"; text: string; serif?: boolean; keep?: boolean }
  | { kind: "image"; src: string; position: string; phones: boolean };

function words(text: string, serif = false): Token[] {
  return text.split(" ").map((w) => ({ kind: "word" as const, text: w, serif }));
}

const STATEMENT: Token[] = [
  ...words("At Hale & Linden"),
  { kind: "image", src: `${IMG}/statement-a.webp`, position: "50% 40%", phones: true },
  ...words("a price starts with a walk down"),
  ...words("the street,", true),
  ...words("not a spreadsheet,"),
  { kind: "image", src: `${IMG}/statement-b.webp`, position: "40% 50%", phones: false },
  ...words("and ends with a sale you can"),
  ...words("live with.", true),
];

function InkToken({
  token,
  progress,
  index,
  total,
  still,
}: {
  token: Token;
  progress: MotionValue<number>;
  index: number;
  total: number;
  still: boolean;
}) {
  const start = (index / total) * 0.86;
  // Always a motion value: switching to a plain number after hydration would
  // leave the value the server rendered in place.
  const opacity = useTransform(progress, [start, start + 0.14], [still ? 1 : 0.16, 1]);
  const style = { opacity };
  if (token.kind === "image") {
    return (
      <motion.span
        aria-hidden
        style={style}
        className={`relative mx-[0.05em] inline-block h-[0.86em] w-[2.3em] overflow-hidden rounded-[0.09em] align-[-0.07em] ${
          token.phones ? "" : "max-md:hidden"
        }`}
      >
        <img
          src={token.src}
          alt=""
          className="absolute inset-0 h-full w-full object-cover"
          style={{ objectPosition: token.position, background: PAPER_2 }}
        />
      </motion.span>
    );
  }
  return (
    <motion.span style={style} className={token.serif ? "hl-serif" : undefined}>
      {token.text}
    </motion.span>
  );
}

function Statement() {
  const reduce = useReducedMotion();
  const ref = useRef<HTMLParagraphElement>(null);
  const { scrollYProgress } = useScroll({ target: ref, offset: ["start 82%", "end 52%"] });
  return (
    <Section className="hl-section-y" labelledBy="hl-statement-kicker">
      <div className="hl-shell">
        <div className="mx-auto max-w-[96rem] text-center">
          <Reveal className="mb-10 md:mb-12">
            <span id="hl-statement-kicker">
              <Label icon={Users}>Who we are</Label>
            </span>
          </Reveal>
          <p ref={ref} className="hl-balance hl-statement">
            {STATEMENT.map((token, i) => (
              <span key={i}>
                <InkToken
                  token={token}
                  progress={scrollYProgress}
                  index={i}
                  total={STATEMENT.length}
                  still={!!reduce}
                />
                {i < STATEMENT.length - 1 && " "}
              </span>
            ))}
          </p>
        </div>
      </div>
    </Section>
  );
}

/* --------------------------------------------------------------------------
   A layer that holds still while the next one slides over it
   ----------------------------------------------------------------------- */

function HeldLayer({
  cover,
  children,
}: {
  cover: RefObject<HTMLDivElement | null>;
  children: ReactNode;
}) {
  const reduce = useReducedMotion();
  const wide = useMedia("(min-width: 48rem)");
  const ref = useRef<HTMLDivElement>(null);
  const [geo, setGeo] = useState({ top: 0, origin: "50% 50%" });

  // A layer taller than the window waits until its end is on screen before
  // it stops; it recedes around the middle of what is still visible.
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const measure = () => {
      const h = el.offsetHeight;
      const vh = window.innerHeight;
      setGeo({
        top: Math.min(0, vh - h),
        origin: `50% ${Math.round(h - Math.min(h, vh) / 2)}px`,
      });
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    window.addEventListener("resize", measure);
    return () => {
      ro.disconnect();
      window.removeEventListener("resize", measure);
    };
  }, []);

  const { scrollYProgress } = useScroll({ target: cover, offset: ["start end", "start start"] });
  const scale = useTransform(scrollYProgress, [0, 1], [1, 0.94]);
  const radius = useTransform(scrollYProgress, [0, 1], [0, 20]);
  const veil = useTransform(scrollYProgress, [0, 1], [0, 0.3], { ease: (t) => t * t });
  const hold = wide && !reduce;

  return (
    <div
      ref={ref}
      className="relative z-0"
      style={hold ? { position: "sticky", top: geo.top } : undefined}
    >
      <motion.div
        className="relative overflow-hidden"
        style={hold ? { scale, borderRadius: radius, transformOrigin: geo.origin } : undefined}
      >
        {children}
        {hold && (
          <motion.div
            aria-hidden
            className="pointer-events-none absolute inset-0"
            style={{ background: INK, opacity: veil }}
          />
        )}
      </motion.div>
    </div>
  );
}

/* --------------------------------------------------------------------------
   Services
   ----------------------------------------------------------------------- */

function Services() {
  return (
    <Section
      id="services"
      labelledBy="hl-services-title"
      className="hl-section-y"
      style={{ background: "#ffffff" }}
    >
      <div className="hl-shell">
        <Intro
          id="hl-services-title"
          label="What we do"
          icon={Briefcase}
          from="left"
          lead="One advisor"
          rest="from the first viewing to the handover."
          action={
            <Action href="#contact" variant="outline-ink" icon={Briefcase}>
              All services
            </Action>
          }
        />
        <ul className="mt-12 grid gap-3 md:mt-28 md:grid-cols-2 md:gap-[var(--col-gap)] lg:grid-cols-4">
          {SERVICES.map((s, i) => (
            <Reveal as="li" key={s.id} id={s.id} order={3 + i} className="scroll-mt-28">
              <a
                href="#contact"
                className="group flex h-full gap-5 rounded-[12px] p-5 transition-colors duration-500 md:min-h-[19rem] md:flex-col md:gap-0 md:p-8"
                style={{ background: PAPER }}
              >
                {/* The icon gives way to an arrow on hover: the card leads somewhere. */}
                <span
                  aria-hidden
                  className="relative grid size-12 shrink-0 place-items-center rounded-full bg-white md:size-16"
                >
                  <s.icon
                    size={26}
                    strokeWidth={1.4}
                    className="absolute transition-[opacity,transform] duration-500 group-hover:-translate-y-2 group-hover:opacity-0"
                  />
                  <ArrowUpRight
                    size={26}
                    strokeWidth={1.4}
                    className="absolute translate-y-2 opacity-0 transition-[opacity,transform] duration-500 group-hover:translate-y-0 group-hover:opacity-100"
                    style={{ color: GREEN }}
                  />
                </span>
                <span className="block min-w-0 md:mt-20">
                  <span className="hl-h4 block md:text-[clamp(1.45rem,1.9vw,1.9rem)]">
                    {s.title}
                  </span>
                  <span className="hl-body mt-1.5 block md:mt-3" style={{ color: MUTED }}>
                    {s.text}
                  </span>
                </span>
              </a>
            </Reveal>
          ))}
        </ul>
      </div>
    </Section>
  );
}

/* --------------------------------------------------------------------------
   About: a short, signed note
   ----------------------------------------------------------------------- */

function About() {
  const reduce = useReducedMotion();
  return (
    <Section id="about" labelledBy="hl-about-title" className="p-[var(--frame)]">
      <div className="rounded-[20px] bg-white">
        <div className="hl-shell py-[clamp(5rem,10vw,10rem)]">
          <div className="mx-auto flex max-w-[76rem] flex-col items-center text-center">
            <Reveal order={0}>
              <Label icon={UserRound}>About us</Label>
            </Reveal>
            <Reveal order={1}>
              <h2 id="hl-about-title" className="hl-balance hl-h2 mt-7">
                Personal, <span style={{ color: "rgba(21,23,26,0.45)" }}>when it matters.</span>
              </h2>
            </Reveal>
            <Reveal order={2}>
              <p className="hl-body mt-5 max-w-[34rem]" style={{ color: INK_2 }}>
                Few decisions about a home are purely practical. That is why every client keeps one
                named contact and hears from them without having to ask.
              </p>
            </Reveal>
            <figure className="mt-12 flex flex-col items-center md:mt-14">
              <Reveal kind="scale" order={3}>
                {/* Head and shoulders, black and white until the pointer finds it. */}
                <div className="group relative size-28 overflow-hidden rounded-full md:size-32">
                  <img
                    src={`${IMG}/advisor.webp`}
                    alt="Clara Hale, managing director of Hale & Linden"
                    className="h-full w-full object-cover object-center grayscale transition-[filter] duration-700 group-hover:grayscale-0"
                  />
                </div>
              </Reveal>
              <figcaption className="mt-6">
                <Signature order={4} still={!!reduce} />
                <Reveal kind="fade" order={5}>
                  <p
                    className="mt-3 text-[0.6875rem] leading-none font-medium tracking-[0.09em] uppercase"
                    style={{ color: "rgba(21,23,26,0.42)" }}
                  >
                    Clara Hale, managing director
                  </p>
                </Reveal>
              </figcaption>
            </figure>
          </div>
        </div>
      </div>
    </Section>
  );
}

/** The signature is written left to right at the pace of a hand. */
function Signature({ order, still }: { order: number; still: boolean }) {
  const shown = useContext(Shown);
  const hidden = "inset(-20% 100% -20% 0%)";
  return (
    <motion.img
      src={`${IMG}/signature.svg`}
      alt=""
      aria-hidden
      className="mx-auto h-auto w-full max-w-[13rem]"
      initial={still ? false : { clipPath: hidden }}
      animate={{ clipPath: still || shown ? "inset(-20% 0% -20% 0%)" : hidden }}
      transition={{ duration: 2, ease: "easeInOut", delay: order * STEP }}
    />
  );
}

/* --------------------------------------------------------------------------
   Selected homes
   ----------------------------------------------------------------------- */

function ListingCard({ listing }: { listing: Listing }) {
  return (
    <article className="group relative h-full">
      <div
        className="hl-zoom relative aspect-[4/3] overflow-hidden rounded-[12px]"
        style={{ background: PAPER }}
      >
        <img
          src={listing.image}
          alt={listing.alt}
          loading="lazy"
          className="absolute inset-0 h-full w-full object-cover"
          style={{ objectPosition: listing.focus }}
        />
        {listing.note && (
          <span className="absolute top-4 left-4 inline-flex h-7 items-center rounded-[5px] bg-white/92 px-2.5 text-[0.6875rem] leading-none font-medium tracking-[0.09em] uppercase">
            {listing.note}
          </span>
        )}
        <span className="hl-reveal absolute right-4 bottom-4 inline-flex h-10 items-center gap-2 rounded-[8px] bg-white px-4 text-[0.8125rem] font-medium">
          View home <Eye size={16} strokeWidth={1.6} aria-hidden />
        </span>
      </div>
      <div className="mt-6 flex items-start justify-between gap-6">
        <div>
          <p className="hl-small" style={{ color: MUTED }}>
            {listing.type}
          </p>
          <h3 className="hl-h4 mt-1">
            <a href="#contact" className="after:absolute after:inset-0 after:content-['']">
              {listing.location}
              <span className="sr-only">
                , {listing.type}, {listing.price}
              </span>
            </a>
          </h3>
        </div>
        <p className="hl-h4 shrink-0 tabular-nums">{listing.price}</p>
      </div>
      <p className="hl-small mt-3 flex items-center gap-5" style={{ color: MUTED }}>
        <span className="inline-flex items-center gap-1.5">
          <Ruler size={16} strokeWidth={1.5} aria-hidden />
          {listing.area}
        </span>
        <span className="inline-flex items-center gap-1.5">
          <BedDouble size={16} strokeWidth={1.5} aria-hidden />
          {listing.rooms}
        </span>
      </p>
    </article>
  );
}

function Homes() {
  return (
    <Section
      id="homes"
      labelledBy="hl-homes-title"
      className="hl-section-y"
      style={{ background: PAPER_2 }}
    >
      <div className="hl-shell">
        <Intro
          id="hl-homes-title"
          label="Selected homes"
          icon={House}
          lead="Places worth"
          rest="a closer look."
          action={
            <Action href="#contact" variant="ink" icon={House}>
              All homes
            </Action>
          }
        />
        <div className="hl-swipe mt-16 grid gap-x-[var(--col-gap)] gap-y-14 md:mt-24 lg:grid-cols-3">
          {LISTINGS.map((listing, i) => (
            <Reveal key={listing.location} kind="right" order={3 + i}>
              <ListingCard listing={listing} />
            </Reveal>
          ))}
        </div>
      </div>
    </Section>
  );
}

/* --------------------------------------------------------------------------
   Local knowledge: the city as a plan of districts
   ----------------------------------------------------------------------- */

function DistrictMap() {
  const shown = useContext(Shown);
  const reduce = useReducedMotion();
  const [active, setActive] = useState<string | null>(null);
  const office = DISTRICTS[0];
  // Fields settle in from the centre outwards.
  const order = [...DISTRICTS]
    .map((d) => ({ name: d.name, r: Math.hypot(d.cx - 400, d.cy - 280) }))
    .sort((a, b) => a.r - b.r)
    .map((d) => d.name);
  const current = DISTRICTS.find((d) => d.name === active);

  return (
    <div className="relative">
      <svg
        viewBox="0 0 800 560"
        className="h-auto w-full overflow-visible"
        role="group"
        aria-label="Districts of the city"
        onMouseLeave={() => setActive(null)}
      >
        {DISTRICTS.map((d) => {
          const on = active === d.name;
          const at = order.indexOf(d.name);
          return (
            <motion.path
              key={d.name}
              d={d.d}
              className="hl-district"
              fill={on ? "#2b3d31" : "#202326"}
              stroke="rgba(255,255,255,0.32)"
              strokeWidth={1.2}
              strokeLinejoin="round"
              tabIndex={0}
              role="button"
              aria-label={`${d.name}: ${d.note}, average asking price ${d.price} per square metre`}
              aria-pressed={on}
              onMouseEnter={() => setActive(d.name)}
              onFocus={() => setActive(d.name)}
              onBlur={() => setActive(null)}
              onClick={() => setActive(on ? null : d.name)}
              style={{ transformBox: "fill-box", transformOrigin: "center" }}
              initial={reduce ? false : { opacity: 0, scale: 0.97 }}
              animate={reduce || shown ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.97 }}
              transition={{ duration: 1.4, ease: "easeOut", delay: 0.4 + at * 0.07 }}
            />
          );
        })}
        {/* The river runs under the names, above the fields. */}
        <motion.path
          d="M38 334 C140 372 230 318 330 352 S520 392 600 350 S716 296 774 298"
          fill="none"
          stroke={LEAF}
          strokeOpacity={0.35}
          strokeWidth={3}
          strokeLinecap="round"
          className="pointer-events-none"
          initial={reduce ? false : { pathLength: 0 }}
          animate={reduce || shown ? { pathLength: 1 } : { pathLength: 0 }}
          transition={{ duration: 2.2, ease: EASE_IN_OUT, delay: 0.9 }}
        />
        {current && (
          <path
            d={current.d}
            fill="none"
            stroke={LEAF}
            strokeWidth={1.6}
            strokeLinejoin="round"
            className="pointer-events-none"
          />
        )}
        {DISTRICTS.map((d) => (
          <motion.text
            key={d.name}
            x={d.cx}
            y={d.cy + (d.name === office.name ? 26 : 4)}
            textAnchor="middle"
            className="pointer-events-none"
            style={{ fontFamily: SANS, fontSize: 13, letterSpacing: "0.02em" }}
            fill={active === d.name ? "#ffffff" : "rgba(255,255,255,0.5)"}
            initial={reduce ? false : { opacity: 0 }}
            animate={reduce || shown ? { opacity: 1 } : { opacity: 0 }}
            transition={{ duration: 0.8, delay: 1.6 }}
          >
            {d.name}
          </motion.text>
        ))}
        {/* The office, with a slow pulse so the eye finds it. */}
        <g className="pointer-events-none">
          <circle
            cx={office.cx}
            cy={office.cy}
            r={13}
            fill="none"
            stroke={LEAF}
            strokeWidth={1.4}
            className="hl-ring"
          />
          <circle cx={office.cx} cy={office.cy} r={5} fill={LEAF} />
        </g>
      </svg>
      <div
        className="mt-4 min-h-[3.25rem] md:absolute md:right-0 md:bottom-0 md:mt-0 md:text-right"
        aria-live="polite"
      >
        {current ? (
          <p className="hl-small text-white/80">
            <span className="block text-[1.0625rem] text-white">{current.name}</span>
            {current.note}, around {current.price} per m²
          </p>
        ) : (
          <p className="hl-small text-white/45">
            Point at a district to see what homes there cost.
          </p>
        )}
      </div>
    </div>
  );
}

function useGrow(target: RefObject<HTMLElement | null>) {
  const reduce = useReducedMotion();
  const from = reduce ? 1 : 0.92;
  const { scrollYProgress } = useScroll({ target, offset: ["start end", "start 30%"] });
  return useTransform(scrollYProgress, [0, 1], [from, 1]);
}

function LocalKnowledge() {
  const reduce = useReducedMotion();
  const panel = useRef<HTMLDivElement>(null);
  const scale = useGrow(panel);
  return (
    <Section
      id="districts"
      labelledBy="hl-districts-title"
      className="hl-section-y pt-[clamp(2.5rem,5vw,5rem)]"
      margin="0px 0px -20% 0px"
    >
      <div className="hl-shell">
        <motion.div
          ref={panel}
          style={{ scale, transformOrigin: "50% 0%", background: NIGHT }}
          className="hl-dark grid overflow-hidden rounded-[20px] text-white lg:grid-cols-12"
        >
          <div className="flex flex-col justify-center p-8 md:p-12 lg:col-span-5 lg:p-16">
            <Reveal kind="left" order={0}>
              <Label tone="paper" icon={MapPin}>
                Local knowledge
              </Label>
            </Reveal>
            <Reveal kind="left" order={1}>
              <h2 id="hl-districts-title" className="hl-balance hl-h2 mt-6">
                Knowing the market means <span className="text-white/50">knowing the streets.</span>
              </h2>
            </Reveal>
            <Reveal kind="left" order={2}>
              <p className="hl-body mt-5 max-w-[30rem] text-white/75">
                Neighbourhoods move at different speeds, sometimes from one street to the next. Good
                advice starts with knowing which, not with a price per square metre.
              </p>
            </Reveal>
            <Reveal kind="fade" order={4}>
              <p className="hl-small mt-10 flex items-center gap-2.5 text-white/60">
                <span
                  aria-hidden
                  className="size-2 shrink-0 rounded-full"
                  style={{ background: LEAF }}
                />
                Our office: {COMPANY.street}, {COMPANY.district}
              </p>
            </Reveal>
          </div>
          <figure className="relative flex flex-col justify-center px-6 pb-8 md:px-12 lg:col-span-7 lg:py-14 lg:pr-16 lg:pl-4">
            <DistrictMap />
          </figure>
        </motion.div>
      </div>
    </Section>
  );
}

/* --------------------------------------------------------------------------
   Trust: three voices, then the figures
   ----------------------------------------------------------------------- */

function Counter({ to, order }: { to: number; order: number }) {
  const shown = useContext(Shown);
  const reduce = useReducedMotion();
  const ref = useRef<HTMLSpanElement>(null);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (reduce) {
      el.textContent = to.toLocaleString("en-GB");
      return;
    }
    if (!shown) {
      el.textContent = "0";
      return;
    }
    const controls = animate(0, to, {
      duration: 1.6,
      ease: [0.33, 1, 0.68, 1],
      delay: order * STEP,
      onUpdate: (v) => {
        el.textContent = Math.round(v).toLocaleString("en-GB");
      },
    });
    return () => controls.stop();
  }, [shown, reduce, to, order]);
  return <span ref={ref}>{to.toLocaleString("en-GB")}</span>;
}

function Trust() {
  return (
    <Section labelledBy="hl-trust-title" className="hl-section-y" style={{ background: PAPER_2 }}>
      <div className="hl-shell">
        <Intro
          id="hl-trust-title"
          label="Experience"
          icon={MessagesSquare}
          lead="Trust is earned"
          rest="one sale at a time."
        />
        <ul className="hl-swipe mt-12 grid gap-[var(--col-gap)] md:mt-24 lg:grid-cols-3">
          {VOICES.map((v, i) => (
            <Reveal
              as="li"
              key={v.author}
              order={3 + i}
              className="h-full rounded-[12px] bg-white p-6 md:p-10"
            >
              <figure className="flex h-full flex-col justify-between gap-10">
                <blockquote className="hl-lead">
                  <p>{v.quote}</p>
                </blockquote>
                <figcaption className="hl-small" style={{ color: MUTED }}>
                  <span style={{ color: INK }}>{v.author}</span> · {v.context}
                </figcaption>
              </figure>
            </Reveal>
          ))}
        </ul>
        <Reveal order={6}>
          <div
            className="hl-dark mt-12 grid gap-8 rounded-[20px] p-6 text-white md:mt-16 md:gap-12 md:p-12 lg:grid-cols-12 lg:items-center"
            style={{ background: NIGHT }}
          >
            <p className="lg:col-span-4">
              <span className="hl-small block text-white/55">Independent and owner run</span>
              <span className="hl-h4 mt-1 block">
                One office, one city, since {COMPANY.founded}.
              </span>
            </p>
            <dl className="grid grid-cols-2 gap-x-[var(--col-gap)] gap-y-6 md:gap-y-10 lg:col-span-8 lg:grid-cols-4">
              {FIGURES.map((f, i) => (
                <div key={f.label} className="flex flex-col-reverse justify-end">
                  <dt className="hl-small mt-2 text-white/55">{f.label}</dt>
                  <dd className="hl-stat max-md:text-[1.75rem]">
                    {f.prefix && (
                      <span className="text-[0.5em] tracking-normal text-white/60">{f.prefix}</span>
                    )}
                    {f.count !== undefined ? <Counter to={f.count} order={7 + i} /> : f.value}
                  </dd>
                </div>
              ))}
            </dl>
          </div>
        </Reveal>
      </div>
    </Section>
  );
}

/* --------------------------------------------------------------------------
   Journal
   ----------------------------------------------------------------------- */

function Journal() {
  return (
    <Section id="journal" labelledBy="hl-journal-title" className="hl-section-y">
      <div className="hl-shell">
        <Intro
          id="hl-journal-title"
          label="Journal"
          icon={Newspaper}
          lead="What is moving"
          rest="the market right now."
          actionOnPhones={false}
          action={
            <Action href="#journal" variant="outline-ink" icon={Newspaper}>
              All articles
            </Action>
          }
        />
        <ul className="hl-swipe mt-16 grid gap-[var(--col-gap)] md:mt-24 lg:grid-cols-3">
          {JOURNAL.map((item, i) => (
            <Reveal
              as="li"
              key={item.title}
              order={3 + i}
              className="group relative flex h-full flex-col rounded-[12px] bg-white p-3"
            >
              <div
                className="hl-zoom relative aspect-[16/10] overflow-hidden rounded-[8px]"
                style={{ background: PAPER_2 }}
              >
                <img
                  src={item.image}
                  alt={item.alt}
                  loading="lazy"
                  className="absolute inset-0 h-full w-full object-cover"
                />
              </div>
              <div className="flex flex-1 flex-col px-3 pt-6 pb-4">
                <p className="hl-small" style={{ color: MUTED }}>
                  {item.category} <span aria-hidden>·</span> <time>{item.date}</time>
                </p>
                <h3 className="hl-pretty hl-h4 mt-3">
                  <a
                    href="#journal"
                    className="after:absolute after:inset-0 after:rounded-[12px] after:content-['']"
                  >
                    {item.title}
                  </a>
                </h3>
                <span
                  className="hl-small mt-auto inline-flex items-center self-start pt-8 font-medium"
                  aria-hidden
                >
                  <span className="hl-draw">Read article</span>
                  <ButtonIcon icon={BookOpen} size={16} />
                </span>
              </div>
            </Reveal>
          ))}
        </ul>
      </div>
    </Section>
  );
}

/* --------------------------------------------------------------------------
   Contact: the closing scene
   ----------------------------------------------------------------------- */

function Contact({ onBook }: { onBook: () => void }) {
  const reduce = useReducedMotion();
  const panel = useRef<HTMLDivElement>(null);
  const grow = useGrow(panel);
  const { scrollYProgress } = useScroll({ target: panel, offset: ["start end", "end start"] });
  const zoom = useTransform(scrollYProgress, [0, 1], [reduce ? 1 : 1.18, 1]);
  return (
    <Section
      id="contact"
      labelledBy="hl-contact-title"
      className="px-[var(--frame)] pb-[var(--frame)]"
    >
      <motion.div
        ref={panel}
        style={{ scale: grow, transformOrigin: "50% 0%", background: NIGHT }}
        className="hl-dark relative isolate flex min-h-[40rem] items-center justify-center overflow-hidden rounded-[20px] px-6 py-24 text-center text-white md:h-[calc(100svh-2*var(--frame))] md:max-h-[64rem] md:px-10"
      >
        <motion.div className="absolute inset-0 -z-10" style={{ scale: zoom }}>
          <img
            src={`${IMG}/contact-lake.webp`}
            alt="A still lake at dawn, the corner of a wooden jetty in the foreground"
            loading="lazy"
            className="h-full w-full object-cover object-[30%_60%]"
          />
        </motion.div>
        {/* An even shade for legibility, a touch deeper behind the copy. */}
        <span
          aria-hidden
          className="absolute inset-0 -z-10"
          style={{
            background:
              "radial-gradient(ellipse 60% 55% at 50% 50%, rgba(16,17,19,0.42), rgba(16,17,19,0) 75%), rgba(16,17,19,0.6)",
          }}
        />
        <div className="flex max-w-[48rem] flex-col items-center">
          <Reveal order={0}>
            <Label tone="paper" icon={MessageCircle}>
              Let us talk
            </Label>
          </Reveal>
          <Reveal order={1}>
            <h2 id="hl-contact-title" className="hl-h2 mt-7">
              <span className="block">A property in mind?</span>{" "}
              <span className="block">
                We are <Serif>listening.</Serif>
              </span>
            </h2>
          </Reveal>
          <Reveal order={2}>
            <p className="hl-pretty hl-lead mt-6 max-w-[42rem] text-white/80">
              Thinking of selling, letting or finding a home in the city? Tell us what it is about,
              and we will call you back the same day.
            </p>
          </Reveal>
          <Reveal
            order={3}
            className="mt-10 flex flex-col items-center gap-x-8 gap-y-5 sm:flex-row"
          >
            <Action onClick={onBook} variant="paper" icon={CalendarCheck}>
              Book a viewing
            </Action>
            <a
              href={COMPANY.phone.href}
              className="inline-flex items-center gap-2.5 py-2.5 text-[0.9375rem] tabular-nums"
              aria-label={`Call ${COMPANY.phone.display}`}
            >
              <Phone size={18} strokeWidth={1.6} aria-hidden />
              <span className="hl-under">{COMPANY.phone.display}</span>
            </a>
          </Reveal>
        </div>
      </motion.div>
    </Section>
  );
}

/* --------------------------------------------------------------------------
   Footer
   ----------------------------------------------------------------------- */

function FooterColumn({
  title,
  order,
  className = "",
  children,
}: {
  title: string;
  order: number;
  className?: string;
  children: ReactNode;
}) {
  const [open, setOpen] = useState(false);
  const id = useId();
  const heading =
    "text-[0.6875rem] leading-none font-medium tracking-[0.09em] text-white/40 uppercase";
  return (
    <div className={`max-md:border-t max-md:border-white/12 ${className}`}>
      <Reveal order={order}>
        <h2>
          <span className={`${heading} max-md:hidden`}>{title}</span>
          <button
            type="button"
            aria-expanded={open}
            aria-controls={id}
            onClick={() => setOpen((v) => !v)}
            className="flex w-full items-center justify-between py-5 md:hidden"
          >
            <span className={heading}>{title}</span>
            <Plus
              size={18}
              strokeWidth={1.6}
              className={`text-white/50 transition-transform duration-500 ${open ? "rotate-45" : ""}`}
            />
          </button>
        </h2>
      </Reveal>
      {/* Folds on phones, always open from tablets up. */}
      <div
        id={id}
        className={`grid transition-[grid-template-rows,visibility] duration-500 md:visible md:grid-rows-[1fr] ${
          open ? "grid-rows-[1fr]" : "invisible grid-rows-[0fr]"
        }`}
      >
        <div className="min-h-0 overflow-hidden">
          <Reveal order={order + 0.5}>
            <ul className="grid gap-1 pb-5 text-[0.9375rem] md:mt-7 md:pb-0">{children}</ul>
          </Reveal>
        </div>
      </div>
    </div>
  );
}

function FooterLink({
  href,
  onClick,
  children,
}: {
  href?: string;
  onClick?: () => void;
  children: ReactNode;
}) {
  const cls =
    "group inline-flex items-center py-1 text-white/70 transition-colors duration-300 hover:text-white max-md:py-2";
  if (onClick)
    return (
      <button type="button" onClick={onClick} className={cls}>
        <span className="hl-draw">{children}</span>
      </button>
    );
  return (
    <a href={href} className={cls}>
      <span className="hl-draw">{children}</span>
    </a>
  );
}

function Footer({ onBook }: { onBook: () => void }) {
  const reduce = useReducedMotion();
  const mark = useRef<HTMLParagraphElement>(null);
  const { scrollYProgress } = useScroll({ target: mark, offset: ["start end", "end end"] });
  const rise = useTransform(scrollYProgress, [0, 1], [reduce ? "0%" : "35%", "0%"]);
  const opacity = useTransform(scrollYProgress, [0, 1], [reduce ? 1 : 0.15, 1]);
  const year = new Date().getFullYear();
  return (
    <Section className="px-[var(--frame)] pb-[var(--frame)]" margin="0px 0px -15% 0px">
      <footer
        id="hl-footer"
        className="hl-dark overflow-hidden rounded-[20px] text-white"
        style={{ background: NIGHT }}
      >
        <div className="px-[clamp(1.5rem,6vw,7rem)] pt-12 md:pt-28">
          <div className="grid grid-cols-12 gap-x-[var(--col-gap)] md:gap-y-16">
            <div className="col-span-12 max-md:pb-8 lg:col-span-4">
              <Reveal order={0}>
                <Logo style={{ fontSize: "clamp(1.75rem, 2.4vw, 2.125rem)" }} />
              </Reveal>
              <Reveal order={0.5}>
                <p className="hl-body mt-5 max-w-[24rem] text-white/55 md:mt-6">
                  Estate agents for the city and the villages around it. Since {COMPANY.founded}.
                </p>
              </Reveal>
              <Reveal order={1}>
                <ul className="mt-6 flex gap-2 md:mt-10">
                  {[
                    { label: "Instagram", icon: Instagram },
                    { label: "Facebook", icon: Facebook },
                  ].map((s) => (
                    <li key={s.label}>
                      <a
                        href="#hl-footer"
                        aria-label={s.label}
                        className="grid size-10 place-items-center rounded-[8px] bg-white/[0.07] transition-colors duration-300 hover:bg-white hover:text-[#15171a] md:size-11"
                      >
                        <s.icon size={19} strokeWidth={1.6} />
                      </a>
                    </li>
                  ))}
                </ul>
              </Reveal>
            </div>
            <FooterColumn
              title="Navigation"
              order={1.5}
              className="order-2 col-span-12 md:order-none md:col-span-3 lg:col-span-2 lg:col-start-5"
            >
              {NAV.map((item) => (
                <li key={item.href}>
                  <FooterLink href={item.href}>{item.label}</FooterLink>
                </li>
              ))}
            </FooterColumn>
            <FooterColumn
              title="Contact"
              order={2}
              className="order-1 col-span-12 md:order-none md:col-span-6 lg:col-span-4"
            >
              {[
                {
                  icon: MapPin,
                  node: (
                    <address className="text-white/70 not-italic">
                      {COMPANY.street}
                      <br />
                      {COMPANY.district}
                    </address>
                  ),
                },
                {
                  icon: Phone,
                  node: <FooterLink href={COMPANY.phone.href}>{COMPANY.phone.display}</FooterLink>,
                },
                {
                  icon: Mail,
                  node: <FooterLink href={`mailto:${COMPANY.email}`}>{COMPANY.email}</FooterLink>,
                },
                {
                  icon: CalendarCheck,
                  node: <FooterLink onClick={onBook}>Book a viewing</FooterLink>,
                },
              ].map((line, i) => (
                <li key={i} className="flex gap-3 py-1 max-md:py-2">
                  <line.icon
                    size={18}
                    strokeWidth={1.5}
                    className="mt-[0.2em] shrink-0 text-white/45"
                    aria-hidden
                  />
                  <div className="-my-1 min-w-0 max-md:-my-2">{line.node}</div>
                </li>
              ))}
            </FooterColumn>
            <FooterColumn
              title="Legal"
              order={2.5}
              className="order-3 col-span-12 md:order-none md:col-span-3 lg:col-span-2"
            >
              {["Imprint", "Privacy", "Terms"].map((label) => (
                <li key={label}>
                  <FooterLink href="#hl-footer">{label}</FooterLink>
                </li>
              ))}
            </FooterColumn>
          </div>
          <div className="hl-small flex items-center justify-between gap-6 border-white/12 text-white/40 max-md:border-t max-md:py-6 md:mt-32">
            <Reveal kind="fade" order={4}>
              <p>
                © {year} {COMPANY.name}
              </p>
            </Reveal>
            <Reveal kind="fade" order={4.5}>
              <a
                href="#hl-top"
                className="group -my-3 inline-flex shrink-0 items-center gap-2 py-3 whitespace-nowrap transition-colors duration-300 hover:text-white"
              >
                <span className="hl-draw">Back to top</span>
                <ArrowUp
                  size={16}
                  strokeWidth={1.6}
                  className="transition-transform duration-500 group-hover:-translate-y-1"
                  aria-hidden
                />
              </a>
            </Reveal>
          </div>
        </div>
        {/* The closing gesture: the name across the full width, barely there. */}
        <motion.p
          ref={mark}
          aria-hidden
          className="hl-wordmark pointer-events-none -mt-[0.02em] -mb-[0.05em] text-center text-white/[0.07] select-none"
          style={{ y: rise, opacity }}
        >
          Hale{" "}
          <span style={{ fontFamily: SERIF, fontStyle: "italic", fontWeight: 400 }}>&amp;</span>{" "}
          Linden
        </motion.p>
      </footer>
    </Section>
  );
}

/* --------------------------------------------------------------------------
   Booking dialog
   ----------------------------------------------------------------------- */

const FIELD =
  "h-11 w-full rounded-[8px] border-0 bg-white px-4 text-base outline-none transition-shadow placeholder:text-[#9aa0a6] focus:shadow-[inset_0_0_0_1.5px_#15171a] md:h-12 md:text-[0.9375rem]";

function Chip({
  name,
  value,
  icon: Icon,
  required,
}: {
  name: string;
  value: string;
  icon?: LucideIcon;
  required?: boolean;
}) {
  return (
    <label className="cursor-pointer">
      <input type="radio" name={name} value={value} required={required} className="peer sr-only" />
      <span className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-3.5 text-[0.875rem] transition-colors duration-300 peer-checked:bg-[#15171a] peer-checked:text-white peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[#2e5b3f] sm:w-auto">
        {Icon && <Icon size={16} strokeWidth={1.6} aria-hidden />}
        {value}
      </span>
    </label>
  );
}

function Booking({ open, onClose }: { open: boolean; onClose: () => void }) {
  const reduce = useReducedMotion();
  const [sent, setSent] = useState(false);
  const [today, setToday] = useState("");
  const first = useRef<HTMLInputElement>(null);

  useEffect(() => {
    setToday(new Date().toISOString().slice(0, 10));
  }, []);

  useEffect(() => {
    if (!open) return;
    setSent(false);
    const prev = document.documentElement.style.overflow;
    document.documentElement.style.overflow = "hidden";
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    window.addEventListener("keydown", onKey);
    const t = window.setTimeout(() => first.current?.focus(), 350);
    return () => {
      document.documentElement.style.overflow = prev;
      window.removeEventListener("keydown", onKey);
      window.clearTimeout(t);
    };
  }, [open, onClose]);

  // No backend: the request is written as an email, ready to send.
  const submit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    const get = (key: string) => String(data.get(key) ?? "").trim();
    const date = get("date")
      ? new Date(`${get("date")}T12:00:00`).toLocaleDateString("en-GB", {
          day: "numeric",
          month: "long",
          year: "numeric",
        })
      : "any day";
    const body = [
      "Hello,",
      "",
      "I would like to book an appointment.",
      "",
      `Topic: ${get("topic")}`,
      `Preferred: ${date}, ${get("daypart") || "flexible"}`,
      get("message") ? `\n${get("message")}\n` : "",
      `${get("name")}`,
      get("phone") ? `Phone: ${get("phone")}` : "",
      `Email: ${get("email")}`,
    ]
      .filter((l) => l !== "")
      .join("\n");
    window.location.href = `mailto:${COMPANY.email}?subject=${encodeURIComponent(
      `Appointment: ${get("topic")}`,
    )}&body=${encodeURIComponent(body)}`;
    setSent(true);
  };

  return (
    <AnimatePresence>
      {open && (
        <motion.div
          className="fixed inset-0 z-[60] flex items-end justify-center p-[var(--frame)] md:items-center"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.35, ease: "easeOut" }}
        >
          <button
            type="button"
            aria-label="Close"
            onClick={onClose}
            className="absolute inset-0 cursor-default"
            style={{ background: "rgba(16,17,19,0.55)" }}
          />
          <motion.div
            role="dialog"
            aria-modal="true"
            aria-labelledby="hl-booking-title"
            className="relative grid max-h-[calc(100svh-2*var(--frame))] w-full max-w-[68rem] overflow-hidden rounded-[20px] lg:grid-cols-[0.8fr_1.2fr]"
            style={{ background: PAPER }}
            initial={reduce ? false : { opacity: 0, y: 20, scale: 0.985 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, y: 12 }}
            transition={{ duration: 0.6, ease: EASE }}
          >
            <div
              className="hl-dark relative isolate hidden flex-col justify-end p-10 text-white lg:flex"
              style={{ background: NIGHT }}
            >
              <img
                src={`${IMG}/booking.webp`}
                alt=""
                className="absolute inset-0 -z-10 h-full w-full object-cover"
              />
              <span
                aria-hidden
                className="absolute inset-0 -z-10"
                style={{
                  background:
                    "linear-gradient(to top, rgba(16,17,19,0.85), rgba(16,17,19,0.1) 70%)",
                }}
              />
              <Label tone="paper" icon={CalendarCheck}>
                Appointment
              </Label>
              <p className="hl-h3 mt-5">
                Half an hour, <Serif>in person.</Serif>
              </p>
              <p className="hl-body mt-3 max-w-[22rem] text-white/75">
                At our office, at your home or on the phone. We confirm the time by email.
              </p>
            </div>

            <div className="overflow-y-auto p-6 md:p-10">
              <button
                type="button"
                onClick={onClose}
                aria-label="Close"
                className="absolute top-4 right-4 grid size-11 place-items-center rounded-[8px] transition-colors hover:bg-white"
              >
                <X size={20} strokeWidth={1.6} />
              </button>

              {sent ? (
                <div className="flex min-h-[24rem] flex-col justify-center">
                  <span
                    className="grid size-12 place-items-center rounded-full text-white"
                    style={{ background: GREEN }}
                  >
                    <Check size={22} strokeWidth={1.8} />
                  </span>
                  <h2 id="hl-booking-title" className="hl-h3 mt-6">
                    Thank you. Your request is ready to send.
                  </h2>
                  <p className="hl-body mt-3 max-w-[30rem]" style={{ color: INK_2 }}>
                    Your email app should have opened with everything filled in. If it did not,
                    write to{" "}
                    <a href={`mailto:${COMPANY.email}`} className="hl-under">
                      {COMPANY.email}
                    </a>{" "}
                    or call {COMPANY.phone.display}.
                  </p>
                </div>
              ) : (
                <form onSubmit={submit} className="grid gap-7">
                  <h2 id="hl-booking-title" className="hl-h3 pr-12">
                    Book a viewing
                  </h2>
                  <fieldset className="grid gap-3">
                    <legend className="hl-small mb-3 font-medium">What is it about?</legend>
                    <div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap max-sm:[&>label:last-child]:col-span-2">
                      {TOPICS.map((t) => (
                        <Chip key={t.label} name="topic" value={t.label} icon={t.icon} required />
                      ))}
                    </div>
                  </fieldset>
                  <fieldset className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
                    <legend className="hl-small mb-3 font-medium">When would suit you?</legend>
                    <label className="sr-only" htmlFor="hl-date">
                      Preferred date
                    </label>
                    <input id="hl-date" type="date" name="date" min={today} className={FIELD} />
                    <div className="grid grid-cols-3 gap-2">
                      {DAYPARTS.map((d) => (
                        <Chip key={d} name="daypart" value={d} />
                      ))}
                    </div>
                  </fieldset>
                  <div className="grid gap-3 sm:grid-cols-2">
                    <label className="grid gap-1.5 sm:col-span-2">
                      <span className="hl-small font-medium">Name</span>
                      <input
                        ref={first}
                        name="name"
                        required
                        autoComplete="name"
                        className={FIELD}
                      />
                    </label>
                    <label className="grid gap-1.5">
                      <span className="hl-small font-medium">Email</span>
                      <input
                        name="email"
                        type="email"
                        required
                        autoComplete="email"
                        className={FIELD}
                      />
                    </label>
                    <label className="grid gap-1.5">
                      <span className="hl-small font-medium">Phone, optional</span>
                      <input name="phone" type="tel" autoComplete="tel" className={FIELD} />
                    </label>
                    <label className="grid gap-1.5 sm:col-span-2">
                      <span className="hl-small font-medium">Message, optional</span>
                      <textarea
                        name="message"
                        rows={3}
                        placeholder="For example the area, the kind of home or your questions"
                        className={`${FIELD} h-auto resize-none py-3`}
                      />
                    </label>
                  </div>
                  <label
                    className="hl-small flex max-w-[34rem] cursor-pointer items-start gap-3"
                    style={{ color: INK_2 }}
                  >
                    <input
                      type="checkbox"
                      required
                      className="mt-0.5 size-4 shrink-0 accent-[#2e5b3f]"
                    />
                    <span>I agree that my details are used to answer this request.</span>
                  </label>
                  <div>
                    <button type="submit" className={`${BUTTON} ${VARIANTS.green}`}>
                      Request appointment
                      <ButtonIcon icon={ArrowRight} />
                    </button>
                  </div>
                </form>
              )}
            </div>
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

/* --------------------------------------------------------------------------
   Page
   ----------------------------------------------------------------------- */

export function HaleLindenEstatesPage() {
  const [booking, setBooking] = useState(false);
  const cover = useRef<HTMLDivElement>(null);
  const openBooking = useCallback(() => setBooking(true), []);
  const closeBooking = useCallback(() => setBooking(false), []);
  return (
    <div className="hl-root">
      <style>{CSS}</style>
      <Navigation onBook={openBooking} />
      <main className="relative">
        <Hero />
        <Statement />
        {/* The one held moment: the services stay put while the note from the
            founder slides over them, and the homes cover both. */}
        <div className="relative">
          <HeldLayer cover={cover}>
            <Services />
          </HeldLayer>
          <div ref={cover} className="relative z-[1]" style={{ background: PAPER }}>
            <About />
          </div>
          <div className="relative z-[1]">
            <Homes />
          </div>
        </div>
        <LocalKnowledge />
        <Trust />
        <Journal />
        <Contact onBook={openBooking} />
      </main>
      <Footer onBook={openBooking} />
      <Booking open={booking} onClose={closeBooking} />
    </div>
  );
}

export default HaleLindenEstatesPage;
FAQ

Häufige Fragen

Eine kuratierte Landingpage aus aufeinander abgestimmten Sections der Library, in sinnvoller Reihenfolge von Hero bis Footer. Du kopierst die Sections einzeln und setzt sie mit dem Kompositions-Snippet zusammen.

Deine Frage war nicht dabei? Wir helfen gern weiter.

Kontakt aufnehmen