Zum Inhalt springen
Alle Templates

Templates/Ofen- und Kaminbau

Eine warme, ruhige Seite für Ofenbauer, Kaminstudios und alle, deren Produkt das Feuer ist. Ein Terrakotta-Akzent auf warmem Off-White, zwei Bänder in hellem Sand und ein fast schwarzer Abschluss. Das Heldenbild wandert langsamer als die Seite, die zwei Vergleichskarten schieben sich auf dem Telefon übereinander, und beide Streifen werden dort zu wischbaren Schiebern mit dünner Fortschrittslinie. Die Bilder behalten ihr Abendlicht, nichts ist ausgewaschen.

Handwerk & BauKomplette Seite

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

Quellcode

Die komplette Seite als eine Datei. Kopiere sie nach ./components und binde die Komponente in deine Route ein.

import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { motion, useInView, useReducedMotion, useScroll, useTransform } from "framer-motion";
import {
  ArrowRight,
  Facebook,
  Flame,
  Gauge,
  Instagram,
  Layers,
  Leaf,
  Mail,
  MapPin,
  Menu,
  Phone,
  ShieldCheck,
  Star,
  Thermometer,
  Wrench,
  X,
  Youtube,
} from "lucide-react";

/*
 * Terrafeu: a full page for a maker of heat-retaining stoves and fireplaces.
 *
 * The subject is fire, so the page is allowed one warm accent and nothing
 * else: terracotta on a warm off-white, with two bands in a light sand tone
 * and a near-black closing section. Photographs keep their evening light,
 * nothing is washed out, and no coloured film lies over anything.
 *
 * Two ideas carry the motion. The hero photograph drifts a little more slowly
 * than the page, and every block arrives once with a short lift. On phones
 * the two comparison cards stack while scrolling and the two strips become
 * sliders with a thin progress line.
 */

const GRUND = "#f9f7f1";
const KARTE = "#fefdf9";
const AKZENT = "#c1582f";
const SAND = "#f0e9dd";
const SAND_2 = "#f1ece3";
const LEHM = "#eedbc6";
const TON = "#c8875a";
const STAHL = "#484e54";
const TINTE = "#1e1814";
const LEISE = "#6b615a";
const KANTE = "#ded8cf";

const SANS = '"Albert Sans", ui-sans-serif, system-ui, sans-serif';
const anzeige: CSSProperties = { fontFamily: SANS, letterSpacing: "-0.01em" };

/** Grosszuegig gerundete Bildrahmen, kleine Radien an den Knoepfen. */
const BILD = 24;
const KNOPF = 8;

const MARKE = {
  name: "Terrafeu",
  claim: "Warmth that stays",
  gegruendet: 1974,
  firma: "Terrafeu Workshop",
  mail: "hello@terrafeu.example",
  telefon: "+41 44 123 45 67",
  telefonHref: "tel:+41441234567",
  region: "Switzerland and southern Germany",
};

const NAV = [
  { label: "Fireplaces", href: "#categories", Icon: Flame },
  { label: "Heat-retaining stoves", href: "#categories", Icon: Layers },
  { label: "Tiled stoves", href: "#categories", Icon: Thermometer },
  { label: "Hybrid stoves", href: "#categories", Icon: Gauge },
  { label: "How it works", href: "#how-it-works", Icon: Leaf },
];

const KATEGORIEN = [
  {
    name: "Fireplace",
    bild: "/templates/terrafeu/cat-cheminee.webp",
    alt: "Glass fronted fireplace in a living room",
    claim: "For atmosphere and relaxed hours by the fire.",
    anteil: "Heat contribution 0 to 20 %",
  },
  {
    name: "Heat-retaining stove",
    bild: "/templates/terrafeu/cat-speicherofen.webp",
    alt: "Heat-retaining stove with a stone surround",
    claim: "A real fire plus welcome additional warmth.",
    anteil: "Heat contribution 20 to 80 %",
  },
  {
    name: "Tiled stove",
    bild: "/templates/terrafeu/cat-kachelofen.webp",
    alt: "Tiled stove with a bench along one side",
    claim: "A real fire and cosy heat for the whole house.",
    anteil: "Heat contribution 70 to 100 %",
  },
  {
    name: "Hybrid stove",
    bild: "/templates/terrafeu/cat-kombiofen.webp",
    alt: "Hybrid stove in a holiday home",
    claim: "For cold holiday homes: fast warmth that also lasts.",
    anteil: "Warm quickly. Warm for long.",
  },
];

const GRUNDSAETZE = [
  {
    Icon: Flame,
    titel: "Calm radiant heat",
    text: "Natural radiant heat that warms bodies and rooms gently and evenly.",
  },
  {
    Icon: Leaf,
    titel: "Natural materials",
    text: "The stone is healthy to live with, low in emissions and fully recyclable.",
  },
  {
    Icon: ShieldCheck,
    titel: "Built to last",
    text: "A solid stone core made for generations, with a 15 year warranty.",
  },
  {
    Icon: Gauge,
    titel: "Efficient and economical",
    text: "Heat is stored and released slowly, using very little fuel.",
  },
  {
    Icon: Wrench,
    titel: "Low maintenance",
    text: "Simple to run, little upkeep and dependable in daily use.",
  },
];

const VERGLEICH = [
  {
    bild: "/templates/terrafeu/speicherstein.webp",
    alt: "A block of solid heat-retaining stone",
    label: "Stone",
    heraus: true,
    punkte: ["buffers and stores", "even warmth", "radiant heat", "steady room temperature"],
  },
  {
    bild: "/templates/terrafeu/stahl.webp",
    alt: "A steel firebox",
    label: "Steel",
    heraus: false,
    punkte: [
      "releases heat quickly",
      "high surface temperatures",
      "mostly warm air",
      "large swings in temperature",
    ],
  },
];

const ZAHLEN = [
  { bis: 1974, label: "Founded as a workshop", gruppe: false },
  { bis: 50000, suffix: "+", label: "Installations across Europe", gruppe: true },
  { bis: 15, suffix: " years", label: "Warranty on the stone core", gruppe: false },
];

const STIMMEN = [
  {
    text: "We are heating with the tiled stove for the second winter now. The whole house is still warm in the morning, without anyone getting up at night to add wood.",
    name: "Andrea Steiner",
    rolle: "Family home, new build",
    bild: "/templates/terrafeu/avatar-andrea.webp",
  },
  {
    text: "The advice was honest. They talked us out of the larger model because it would have been too much for our rooms.",
    name: "Marc Brunner",
    rolle: "Period house, renovation",
    bild: "/templates/terrafeu/avatar-marc.webp",
  },
  {
    text: "The difference to our old steel stove is easy to feel. The warmth is calmer and the air in the room stays pleasant.",
    name: "Sandra Koller",
    rolle: "Holiday home in the mountains",
    bild: "/templates/terrafeu/avatar-sandra.webp",
  },
];

const FUSS = [
  {
    titel: "Navigation",
    links: ["Fireplaces", "Heat-retaining stoves", "Tiled stoves", "Hybrid stoves", "About us"],
  },
  { titel: "Support", links: ["Downloads", "FAQ", "Care and tips", "Projects"] },
];

/* --------------------------------------------------------------- primitives */

function Reveal({
  children,
  delay = 0,
  className = "",
  style,
}: {
  children: ReactNode;
  delay?: number;
  className?: string;
  style?: CSSProperties;
}) {
  const reduce = useReducedMotion();
  const ref = useRef<HTMLDivElement>(null);
  const sichtbar = useInView(ref, { once: true, margin: "-70px" });
  return (
    <motion.div
      ref={ref}
      className={className}
      style={style}
      initial={reduce ? false : { opacity: 0, y: 24 }}
      animate={sichtbar || reduce ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
      transition={{
        duration: reduce ? 0 : 0.75,
        ease: [0.22, 1, 0.36, 1],
        delay: reduce ? 0 : delay / 1000,
      }}
    >
      {children}
    </motion.div>
  );
}

/** Die Marke: das Wort und eine Flamme mit einer zweiten Zunge an der Schulter. */
function Flamme({ className = "" }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden className={className}>
      <path
        fill="currentColor"
        d="M12.8 1.5c.2 2.8 1.6 4.3 3 6 1.4 1.7 2.4 3.5 2.4 5.8 0 4-3 7.2-6.6 7.2-3.2 0-5.7-2.5-5.7-5.7 0-2.4 1.2-4.2 2.8-5.9-.1 1.3.2 2.3.9 3 .1-3.6 1.5-6.4 3.2-10.4z"
      />
      <path
        fill="currentColor"
        opacity="0.38"
        d="M12.2 12.4c1.5 1.6 2.3 2.9 2.3 4.3 0 1.6-1.2 2.8-2.7 2.8s-2.6-1.2-2.6-2.7c0-1.4.9-2.6 1.9-3.6-.1.7.1 1.2.4 1.5.1-.9.4-1.6.7-2.3z"
      />
    </svg>
  );
}

function Wortmarke({ hell = false, gross = false }: { hell?: boolean; gross?: boolean }) {
  return (
    <span className="block" style={{ color: hell ? "#fdfaf4" : TINTE }}>
      <span className="flex items-end gap-2">
        <span
          className={`uppercase ${gross ? "text-[2rem]" : "text-[1.5rem] sm:text-[1.7rem]"}`}
          style={{ fontFamily: SANS, fontWeight: 800, lineHeight: 0.85, letterSpacing: "0.02em" }}
        >
          {MARKE.name}
        </span>
        <Flamme className={`w-auto shrink-0 ${gross ? "h-8" : "h-6 sm:h-7"}`} />
      </span>
      <span
        className="mt-1.5 block text-[0.68rem]"
        style={{
          fontFamily: SANS,
          letterSpacing: "0.02em",
          color: hell ? "rgba(253,250,244,0.6)" : LEISE,
        }}
      >
        {MARKE.claim}
      </span>
    </span>
  );
}

/**
 * Der Knopf. Beim Anfahren wandert die Beschriftung nach oben hinaus und eine
 * zweite Fassung kommt von unten nach: zwei Lagen im selben Kasten, keine
 * Groessenaenderung.
 */
function Knopf({
  children,
  href,
  variante = "voll",
  className = "",
}: {
  children: ReactNode;
  href: string;
  variante?: "voll" | "glas" | "linie";
  className?: string;
}) {
  const stil: CSSProperties =
    variante === "voll"
      ? { background: AKZENT, color: "#fdfaf4", border: "1px solid transparent" }
      : variante === "glas"
        ? {
            background: "rgba(253,250,244,0.14)",
            color: "#fdfaf4",
            border: "1px solid rgba(253,250,244,0.24)",
            backdropFilter: "blur(8px)",
          }
        : { background: KARTE, color: TINTE, border: `1px solid ${KANTE}` };

  return (
    <a
      href={href}
      className={`group inline-flex items-center justify-center overflow-hidden text-center transition-colors ${className}`}
      style={{ ...stil, borderRadius: KNOPF, fontFamily: SANS, fontWeight: 500 }}
    >
      <span className="relative block overflow-hidden">
        <span className="flex items-center justify-center gap-2 transition-transform duration-[420ms] ease-out group-hover:-translate-y-full">
          {children}
        </span>
        <span
          aria-hidden
          className="absolute inset-0 flex translate-y-full items-center justify-center gap-2 transition-transform duration-[420ms] ease-out group-hover:translate-y-0"
        >
          {children}
        </span>
      </span>
    </a>
  );
}

function Kicker({ children }: { children: ReactNode }) {
  return (
    <p
      className="text-[0.9rem]"
      style={{ fontFamily: SANS, fontWeight: 600, letterSpacing: "0.005em", color: AKZENT }}
    >
      {children}
    </p>
  );
}

function Ueberschrift({ children, hell = false }: { children: ReactNode; hell?: boolean }) {
  return (
    <h2
      className="text-[1.75rem] leading-tight sm:text-4xl"
      style={{ ...anzeige, fontWeight: 600, color: hell ? "#fdfaf4" : TINTE }}
    >
      {children}
    </h2>
  );
}

/** Die duenne Linie unter einem Schieber, die beim Wischen mitfuellt. */
function Fortschritt({
  ziel,
  anzahl,
  dunkel = false,
  className = "",
}: {
  ziel: React.RefObject<HTMLDivElement | null>;
  anzahl: number;
  dunkel?: boolean;
  className?: string;
}) {
  const [wert, setWert] = useState(0);

  useEffect(() => {
    const el = ziel.current;
    if (!el) return;
    const messen = () => {
      const weg = el.scrollWidth - el.clientWidth;
      // Kein Ueberhang heisst: alles ist zu sehen, die Linie steht voll.
      setWert(weg <= 0 ? 1 : el.scrollLeft / weg);
    };
    messen();
    el.addEventListener("scroll", messen, { passive: true });
    window.addEventListener("resize", messen);
    return () => {
      el.removeEventListener("scroll", messen);
      window.removeEventListener("resize", messen);
    };
  }, [ziel, anzahl]);

  return (
    <div
      aria-hidden
      className={`h-px w-full ${className}`}
      style={{ background: dunkel ? "rgba(253,250,244,0.16)" : "rgba(30,24,20,0.12)" }}
    >
      <div
        className="h-px origin-left transition-transform duration-200 ease-out"
        style={{ background: AKZENT, transform: `scaleX(${Math.min(1, Math.max(0, wert))})` }}
      />
    </div>
  );
}

/** Zaehlt einmal hoch, sobald die Zahl im Bild steht. */
function Zaehler({
  bis,
  suffix = "",
  gruppe = false,
}: {
  bis: number;
  suffix?: string;
  gruppe?: boolean;
}) {
  const reduce = useReducedMotion();
  const ref = useRef<HTMLSpanElement>(null);
  const sichtbar = useInView(ref, { once: true, margin: "-60px" });
  const [wert, setWert] = useState(0);

  useEffect(() => {
    if (!sichtbar) return;
    if (reduce) {
      setWert(bis);
      return;
    }
    const dauer = 1500;
    const start = performance.now();
    let id = 0;
    const lauf = (jetzt: number) => {
      const t = Math.min(1, (jetzt - start) / dauer);
      setWert(Math.round(bis * (1 - Math.pow(1 - t, 3))));
      if (t < 1) id = requestAnimationFrame(lauf);
    };
    id = requestAnimationFrame(lauf);
    return () => cancelAnimationFrame(id);
  }, [sichtbar, reduce, bis]);

  return (
    <span ref={ref}>
      {gruppe ? wert.toLocaleString("en-US") : String(wert)}
      {suffix}
    </span>
  );
}

/* ------------------------------------------------------------------- header */

function Kopfzeile() {
  const [offen, setOffen] = useState(false);
  const [gescrollt, setGescrollt] = useState(false);

  useEffect(() => {
    const beim = () => setGescrollt(window.scrollY > 40);
    beim();
    window.addEventListener("scroll", beim, { passive: true });
    return () => window.removeEventListener("scroll", beim);
  }, []);

  useEffect(() => {
    if (!offen) return;
    const vorher = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      document.body.style.overflow = vorher;
    };
  }, [offen]);

  const fest = gescrollt || offen;

  return (
    <>
      {/* Das Blatt liegt absichtlich neben der Kopfzeile und nicht darin: ein
          backdrop-filter macht das Element zum Bezugsrahmen, und fixed waere
          dann auf die Hoehe der Leiste beschnitten. */}
      {offen ? (
        <div
          className="fixed inset-0 z-40 flex flex-col overflow-y-auto pt-24 pb-10 lg:hidden"
          style={{ background: GRUND }}
        >
          <nav className="mx-auto flex w-full max-w-7xl flex-1 flex-col px-5 sm:px-6">
            {NAV.map((eintrag) => (
              <a
                key={eintrag.label}
                href={eintrag.href}
                onClick={() => setOffen(false)}
                className="flex items-center gap-4 py-4 text-[1.35rem] transition-colors"
                style={{ fontFamily: SANS, color: TINTE }}
              >
                <span
                  className="flex size-11 shrink-0 items-center justify-center rounded-full"
                  style={{ background: "rgba(240,233,221,0.7)", color: AKZENT }}
                >
                  <eintrag.Icon className="size-5" strokeWidth={1.6} />
                </span>
                {eintrag.label}
              </a>
            ))}

            <div className="mt-auto pt-10">
              <Knopf href="#contact" className="w-full px-6 py-4 text-[0.95rem]">
                Find a specialist
              </Knopf>
              <div
                className="mt-6 flex flex-col gap-3 text-[0.92rem]"
                style={{ fontFamily: SANS, color: LEISE }}
              >
                <a href={MARKE.telefonHref} className="flex items-center gap-3">
                  <Phone className="size-4 shrink-0" style={{ color: AKZENT }} strokeWidth={1.6} />
                  {MARKE.telefon}
                </a>
                <a href={`mailto:${MARKE.mail}`} className="flex items-center gap-3">
                  <Mail className="size-4 shrink-0" style={{ color: AKZENT }} strokeWidth={1.6} />
                  {MARKE.mail}
                </a>
              </div>
            </div>
          </nav>
        </div>
      ) : null}

      {/* Ueber dem Hero durchsichtig, ab der ersten Scroll-Bewegung hell. Am
          Schreibtisch steht sie von Anfang an hell: dort liegt die Leiste
          neben der Ueberschrift, nicht darauf. */}
      <header
        className={`fixed inset-x-0 top-0 z-50 transition-colors duration-500 lg:sticky lg:bg-[#f9f7f1] lg:backdrop-blur-md ${
          fest ? "bg-[#f9f7f1] backdrop-blur-md" : "bg-transparent"
        }`}
      >
        <div className="relative z-10 mx-auto grid max-w-7xl grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 sm:px-6 sm:py-5 lg:flex lg:justify-between lg:gap-8 lg:px-10">
          <a href="#top" aria-label="Back to the top" className="min-w-0 shrink-0">
            <span className={fest ? "" : "hidden lg:block"}>
              <Wortmarke />
            </span>
            {fest ? null : (
              <span className="lg:hidden">
                <Wortmarke hell />
              </span>
            )}
          </a>

          <nav className="hidden items-center gap-7 lg:flex">
            {NAV.map((eintrag) => (
              <a
                key={eintrag.label}
                href={eintrag.href}
                className="text-[0.95rem] transition-colors"
                style={{ fontFamily: SANS, color: LEISE }}
              >
                {eintrag.label}
              </a>
            ))}
          </nav>

          <Knopf
            href="#contact"
            className="hidden shrink-0 px-5 py-2.5 text-[0.92rem] whitespace-nowrap lg:inline-flex"
          >
            Find a specialist
          </Knopf>

          <button
            type="button"
            onClick={() => setOffen((v) => !v)}
            aria-expanded={offen}
            aria-label={offen ? "Close menu" : "Open menu"}
            className="flex size-11 shrink-0 items-center justify-center lg:hidden"
            style={{
              borderRadius: KNOPF,
              background: fest ? "rgba(30,24,20,0.05)" : "rgba(253,250,244,0.14)",
              border: fest ? `1px solid ${KANTE}` : "1px solid rgba(253,250,244,0.24)",
              color: fest ? TINTE : "#fdfaf4",
              backdropFilter: fest ? undefined : "blur(8px)",
            }}
          >
            {offen ? <X className="size-5" /> : <Menu className="size-5" />}
          </button>
        </div>
      </header>
    </>
  );
}

/* --------------------------------------------------------------------- hero */

function Held() {
  const reduce = useReducedMotion();
  const ref = useRef<HTMLElement>(null);
  const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end start"] });
  // Das Bild laeuft langsamer als die Seite, der Text ein wenig schneller.
  const bildY = useTransform(scrollYProgress, [0, 1], ["0%", "12%"]);
  const textY = useTransform(scrollYProgress, [0, 1], ["0%", "-18%"]);

  return (
    <section id="top" ref={ref} className="relative isolate overflow-hidden">
      <motion.div
        className="absolute inset-x-0 -inset-y-[8%]"
        style={reduce ? undefined : { y: bildY }}
      >
        <picture>
          <source media="(max-width: 639px)" srcSet="/templates/terrafeu/hero-mobile-2.webp" />
          <img
            src="/templates/terrafeu/hero-wide-2.webp"
            alt="Glass fronted fireplace in a living room in evening light"
            loading="eager"
            decoding="async"
            className="size-full object-cover"
          />
        </picture>
      </motion.div>

      {/* Nur so viel Schleier, wie die weisse Schrift braucht, in der Farbe des
          Bildes statt in einer fremden. */}
      <div
        className="absolute inset-0"
        style={{
          background:
            "linear-gradient(to top, rgba(20,16,12,0.94) 0%, rgba(20,16,12,0.5) 36%, rgba(20,16,12,0.18) 72%)",
        }}
      />

      <motion.div
        className="relative mx-auto flex min-h-svh max-w-4xl flex-col items-center justify-end px-5 pt-28 pb-24 text-center sm:justify-center sm:px-6 sm:pt-32 sm:pb-40 lg:px-10"
        style={reduce ? undefined : { y: textY }}
      >
        <Reveal>
          <p
            className="inline-flex items-center gap-2.5 px-4 py-2 text-[0.85rem] backdrop-blur-md"
            style={{
              borderRadius: KNOPF,
              background: "rgba(253,250,244,0.14)",
              color: "rgba(253,250,244,0.92)",
              fontFamily: SANS,
            }}
          >
            <Flamme className="size-4 shrink-0" />
            Building heat-retaining stoves since {MARKE.gegruendet}
          </p>
        </Reveal>

        <Reveal delay={120}>
          <h1
            className="mt-9 max-w-4xl text-[clamp(3.25rem,15vw,5.5rem)] sm:mt-11 sm:text-[5.25rem] lg:text-[6.5rem]"
            style={{
              fontFamily: SANS,
              fontWeight: 500,
              lineHeight: 0.92,
              letterSpacing: "-0.04em",
              color: "#fdfaf4",
              textShadow: "0 1px 26px rgba(28,22,16,0.3), 0 0 64px rgba(28,22,16,0.22)",
            }}
          >
            <span className="block">Warmth</span>
            <span className="block" style={{ color: "rgba(253,250,244,0.75)" }}>
              that stays
            </span>
          </h1>
        </Reveal>

        <Reveal delay={240}>
          <p
            className="mx-auto mt-8 max-w-lg text-[1.1rem] leading-relaxed sm:mt-10 sm:max-w-xl sm:text-xl"
            style={{
              fontFamily: SANS,
              color: "rgba(253,250,244,0.86)",
              textShadow: "0 1px 22px rgba(28,22,16,0.34)",
            }}
          >
            A different principle for stoves and fireplaces. For a calm, comfortable fire in your
            home.
          </p>
        </Reveal>

        <Reveal delay={360}>
          <div className="mt-12 flex flex-col items-center gap-3.5 sm:mt-14 sm:flex-row sm:flex-wrap sm:justify-center sm:gap-4">
            <Knopf href="#categories" className="min-w-[16rem] px-8 py-4 text-[0.9rem] sm:min-w-0">
              Explore the range
              <ArrowRight className="size-4 shrink-0" strokeWidth={1.8} />
            </Knopf>
            <Knopf
              href="#how-it-works"
              variante="glas"
              className="min-w-[16rem] px-8 py-4 text-[0.9rem] sm:min-w-0"
            >
              How it works
            </Knopf>
          </div>
        </Reveal>
      </motion.div>
    </section>
  );
}

/* ------------------------------------------------------------- stein vs stahl */

function Prinzip() {
  return (
    <section
      id="how-it-works"
      className="mx-auto max-w-7xl scroll-mt-24 px-5 py-20 sm:px-6 sm:py-28 lg:scroll-mt-28 lg:px-10 lg:py-36"
    >
      <div className="grid gap-12 sm:gap-16 lg:grid-cols-[0.9fr_1.1fr] lg:gap-20">
        <div>
          <Reveal>
            <Kicker>The difference you can feel</Kicker>
          </Reveal>
          <Reveal delay={100}>
            <h2
              className="mt-4 text-[1.75rem] leading-tight sm:mt-5 sm:text-4xl"
              style={{ ...anzeige, fontWeight: 600, color: TINTE }}
            >
              Our stoves and fireplaces are built around a solid core of heat-retaining stone.
            </h2>
          </Reveal>
          <Reveal delay={200}>
            <p
              className="mt-6 text-base leading-relaxed sm:mt-7"
              style={{ fontFamily: SANS, color: LEISE }}
            >
              And not around a steel firebox. A different principle for a calm, comfortable home:
              the stone buffers, stores and releases the heat gently into the room over many hours.
            </p>
          </Reveal>
        </div>

        {/* Auf dem Telefon schieben sich die beiden Karten beim Scrollen
            uebereinander, ab sm stehen sie nebeneinander. */}
        <div className="flex flex-col gap-6 sm:grid sm:grid-cols-2 sm:gap-8">
          {VERGLEICH.map((karte, i) => (
            <div key={karte.label} className={`sticky sm:static ${i === 0 ? "top-24" : "top-32"}`}>
              <div
                className="group h-full overflow-hidden"
                style={{ borderRadius: BILD, background: karte.heraus ? LEHM : SAND_2 }}
              >
                <img
                  src={karte.bild}
                  alt={karte.alt}
                  loading="lazy"
                  className="aspect-[4/3] w-full object-cover transition-transform duration-[1400ms] ease-out group-hover:scale-105"
                />
                <div className="p-6 sm:p-7">
                  <h3
                    className="text-[0.95rem]"
                    style={{
                      fontFamily: SANS,
                      fontWeight: 600,
                      color: karte.heraus ? AKZENT : STAHL,
                    }}
                  >
                    {karte.label}
                  </h3>
                  <ul className="mt-5 space-y-3">
                    {karte.punkte.map((punkt) => (
                      <li
                        key={punkt}
                        className="flex items-start gap-3 text-sm leading-relaxed"
                        style={{ fontFamily: SANS, color: karte.heraus ? "#5c4a3d" : LEISE }}
                      >
                        <span
                          aria-hidden
                          className="mt-[0.55em] size-1.5 shrink-0 rounded-full"
                          style={{ background: karte.heraus ? AKZENT : STAHL }}
                        />
                        {punkt}
                      </li>
                    ))}
                  </ul>
                </div>
              </div>
            </div>
          ))}
        </div>
      </div>

      <ul className="mt-16 grid gap-8 sm:mt-28 sm:grid-cols-2 sm:gap-10 lg:grid-cols-5">
        {GRUNDSAETZE.map((satz, i) => (
          <li key={satz.titel}>
            <Reveal delay={i * 110} className="group">
              <satz.Icon
                className="size-7 transition-transform duration-500 group-hover:-translate-y-1"
                style={{ color: AKZENT }}
                strokeWidth={1.5}
              />
              <h3
                className="mt-5 text-[0.95rem]"
                style={{ fontFamily: SANS, fontWeight: 600, color: TINTE }}
              >
                {satz.titel}
              </h3>
              <p
                className="mt-3 text-sm leading-relaxed"
                style={{ fontFamily: SANS, color: LEISE }}
              >
                {satz.text}
              </p>
            </Reveal>
          </li>
        ))}
      </ul>
    </section>
  );
}

/* --------------------------------------------------------------- kategorien */

function Kategorien() {
  const streifen = useRef<HTMLDivElement>(null);

  return (
    <section
      id="categories"
      className="scroll-mt-24 py-20 sm:py-28 lg:scroll-mt-28 lg:py-36"
      style={{ background: SAND }}
    >
      <div className="mx-auto max-w-7xl px-5 sm:px-6 lg:px-10">
        <Reveal>
          <Kicker>One principle, four ways to use it</Kicker>
        </Reveal>
        <Reveal delay={100}>
          <h2
            className="mt-4 max-w-2xl text-[1.75rem] leading-tight sm:mt-5 sm:text-4xl"
            style={{ ...anzeige, fontWeight: 600, color: TINTE }}
          >
            What role should the fire play in your home?
          </h2>
        </Reveal>

        {/* Der Streifen kommt als Ganzes: einzelne Karten wuerden sonst
            waehrend des Wischens nacheinander erscheinen. */}
        <Reveal delay={150} className="mt-10 sm:mt-16">
          <div
            ref={streifen}
            className="-mx-5 flex snap-x snap-mandatory scroll-px-5 gap-4 overflow-x-auto px-5 pb-2 sm:mx-0 sm:grid sm:snap-none sm:grid-cols-2 sm:gap-8 sm:overflow-visible sm:px-0 lg:grid-cols-4 [&::-webkit-scrollbar]:hidden"
            style={{ scrollbarWidth: "none" }}
          >
            {KATEGORIEN.map((kat) => (
              <figure
                key={kat.name}
                className="group w-[76vw] shrink-0 snap-start sm:w-auto sm:shrink"
              >
                <div
                  className="relative overflow-hidden"
                  style={{ borderRadius: BILD, background: SAND_2 }}
                >
                  <img
                    src={kat.bild}
                    alt={kat.alt}
                    loading="lazy"
                    className="aspect-[4/5] w-full object-cover transition-transform duration-[1400ms] ease-out group-hover:scale-105"
                  />
                  {/* Der Waermeanteil steht auf dem Telefon immer da, am
                      Schreibtisch erst beim Anfahren. */}
                  <span
                    className="pointer-events-none absolute inset-x-3 bottom-3 px-3 py-2 text-center text-[0.8rem] backdrop-blur-sm transition-opacity duration-500 sm:opacity-0 sm:group-hover:opacity-100"
                    style={{
                      borderRadius: KNOPF,
                      background: "rgba(30,24,20,0.58)",
                      color: "#fdfaf4",
                      fontFamily: SANS,
                    }}
                  >
                    {kat.anteil}
                  </span>
                </div>
                <figcaption className="mt-5 sm:mt-6">
                  <h3
                    className="text-xl transition-colors duration-500 group-hover:text-[#c1582f]"
                    style={{
                      fontFamily: SANS,
                      fontWeight: 600,
                      letterSpacing: "-0.015em",
                      color: TINTE,
                    }}
                  >
                    {kat.name}
                  </h3>
                  <p
                    className="mt-3 text-sm leading-relaxed"
                    style={{ fontFamily: SANS, color: LEISE }}
                  >
                    {kat.claim}
                  </p>
                </figcaption>
              </figure>
            ))}
          </div>

          <Fortschritt ziel={streifen} anzahl={KATEGORIEN.length} className="mt-6 sm:hidden" />
        </Reveal>
      </div>
    </section>
  );
}

/* ----------------------------------------------------------------- pionier */

function Geschichte() {
  return (
    <section className="mx-auto max-w-7xl px-5 py-20 sm:px-6 sm:py-28 lg:px-10 lg:py-36">
      <div className="grid items-center gap-12 sm:gap-16 lg:grid-cols-2 lg:gap-24">
        <Reveal>
          <div className="group overflow-hidden" style={{ borderRadius: BILD, background: SAND_2 }}>
            <img
              src="/templates/terrafeu/story.webp"
              alt="Couple by the fire of a heat-retaining stove in evening light"
              loading="lazy"
              className="aspect-[4/3] w-full object-cover transition-transform duration-[1400ms] ease-out group-hover:scale-105 lg:aspect-[7/5]"
            />
          </div>
        </Reveal>
        <div>
          <Reveal delay={120}>
            <Kicker>Craft by conviction. Since {MARKE.gegruendet}.</Kicker>
          </Reveal>
          <Reveal delay={200}>
            <h2
              className="mt-4 text-[1.75rem] leading-tight sm:mt-5 sm:text-4xl"
              style={{ ...anzeige, fontWeight: 600, color: TINTE }}
            >
              Half a century of experience with stored heat.
            </h2>
          </Reveal>
          <Reveal delay={280}>
            <p
              className="mt-6 text-base leading-relaxed sm:mt-7"
              style={{ fontFamily: SANS, color: LEISE }}
            >
              Since {MARKE.gegruendet} we have been designing and building stoves and fireplaces
              around a solid stone core. More than 50,000 installations across Europe are the best
              proof of that experience.
            </p>
          </Reveal>
          <dl className="mt-10 grid grid-cols-3 gap-5 sm:mt-14 sm:gap-10">
            {ZAHLEN.map((zahl, i) => (
              <Reveal key={zahl.label} delay={380 + i * 120}>
                <dt
                  className="text-2xl sm:text-3xl"
                  style={{ fontFamily: SANS, fontWeight: 600, color: AKZENT }}
                >
                  <Zaehler bis={zahl.bis} suffix={zahl.suffix} gruppe={zahl.gruppe} />
                </dt>
                <dd
                  className="mt-2 text-[0.8rem] leading-relaxed"
                  style={{ fontFamily: SANS, color: LEISE }}
                >
                  {zahl.label}
                </dd>
              </Reveal>
            ))}
          </dl>
        </div>
      </div>
    </section>
  );
}

/* ----------------------------------------------------------------- stimmen */

function Stimmen() {
  const streifen = useRef<HTMLDivElement>(null);

  return (
    <section
      className="overflow-hidden px-5 py-20 sm:px-6 sm:py-28 lg:px-10 lg:py-36"
      style={{ background: TINTE, color: "#fdfaf4" }}
    >
      <div className="mx-auto max-w-7xl">
        <div className="flex flex-col items-center gap-3 text-center">
          <Kicker>What our customers say</Kicker>
          <Ueberschrift hell>From people who already sit by the fire.</Ueberschrift>
          <p
            className="max-w-md text-sm leading-relaxed sm:text-base"
            style={{ fontFamily: SANS, color: "rgba(253,250,244,0.66)" }}
          >
            Three notes from homes with one of our stoves in them.
          </p>
        </div>

        <div
          ref={streifen}
          className="-mx-5 mt-12 flex snap-x snap-mandatory scroll-px-5 gap-4 overflow-x-auto px-5 pb-2 sm:mx-0 sm:mt-16 sm:grid sm:snap-none sm:grid-cols-3 sm:gap-6 sm:overflow-visible sm:px-0 [&::-webkit-scrollbar]:hidden"
          style={{ scrollbarWidth: "none" }}
        >
          {STIMMEN.map((stimme) => (
            <figure
              key={stimme.name}
              className="flex w-[82vw] shrink-0 snap-start flex-col p-7 sm:w-auto sm:shrink sm:p-8"
              style={{ borderRadius: BILD, background: GRUND, color: TINTE }}
            >
              <span className="flex gap-0.5" aria-hidden style={{ color: TON }}>
                {Array.from({ length: 5 }).map((_, i) => (
                  <Star key={i} className="size-[0.95rem]" fill="currentColor" strokeWidth={0} />
                ))}
              </span>

              <blockquote
                className="mt-5 grow text-[0.95rem] leading-relaxed"
                style={{ fontFamily: SANS, color: LEISE }}
              >
                {stimme.text}
              </blockquote>

              <figcaption className="mt-7 flex items-center gap-3.5">
                <img
                  src={stimme.bild}
                  alt=""
                  aria-hidden
                  loading="lazy"
                  className="size-12 shrink-0 rounded-full object-cover"
                />
                <span className="min-w-0">
                  <span
                    className="block text-[0.95rem]"
                    style={{ fontFamily: SANS, fontWeight: 600, color: TINTE }}
                  >
                    {stimme.name}
                  </span>
                  <span
                    className="mt-0.5 block text-[0.8rem]"
                    style={{ fontFamily: SANS, color: LEISE }}
                  >
                    {stimme.rolle}
                  </span>
                </span>
              </figcaption>
            </figure>
          ))}
        </div>

        <Fortschritt ziel={streifen} anzahl={STIMMEN.length} dunkel className="mt-6 sm:hidden" />
      </div>
    </section>
  );
}

/* ----------------------------------------------------------------- kontakt */

const FELD: CSSProperties = {
  marginTop: 8,
  width: "100%",
  borderRadius: KNOPF,
  border: `1px solid ${KANTE}`,
  background: KARTE,
  padding: "0.7rem 0.9rem",
  fontSize: "0.95rem",
  fontFamily: SANS,
  color: TINTE,
};

function Kontakt() {
  const [gesendet, setGesendet] = useState(false);

  return (
    <section id="contact" className="scroll-mt-24 lg:scroll-mt-28" style={{ background: SAND }}>
      <div className="mx-auto grid max-w-7xl gap-10 px-5 py-20 sm:gap-14 sm:px-6 sm:py-28 lg:grid-cols-2 lg:items-center lg:px-10 lg:py-36">
        <div>
          <Reveal>
            <Kicker>Find a specialist</Kicker>
          </Reveal>
          <Reveal delay={100}>
            <h2
              className="mt-4 text-[1.75rem] leading-tight sm:mt-5 sm:text-4xl"
              style={{ ...anzeige, fontWeight: 600, color: TINTE }}
            >
              Tell us about your project.
            </h2>
          </Reveal>
          <Reveal delay={200}>
            <p
              className="mt-6 max-w-lg text-base leading-relaxed sm:mt-7"
              style={{ fontFamily: SANS, color: LEISE }}
            >
              Selected partner workshops advise you in person and near you, with no obligation. From
              the first idea to the finished stove.
            </p>
          </Reveal>
          <Reveal delay={300}>
            <ul className="mt-8 space-y-3.5 sm:mt-10" style={{ fontFamily: SANS }}>
              <li>
                <a
                  href={MARKE.telefonHref}
                  className="flex items-center gap-3 text-[0.95rem] transition-colors"
                  style={{ color: TINTE }}
                >
                  <Phone
                    className="size-[1.15rem] shrink-0"
                    style={{ color: AKZENT }}
                    strokeWidth={1.6}
                  />
                  {MARKE.telefon}
                </a>
              </li>
              <li>
                <a
                  href={`mailto:${MARKE.mail}`}
                  className="flex items-center gap-3 text-[0.95rem] transition-colors"
                  style={{ color: TINTE }}
                >
                  <Mail
                    className="size-[1.15rem] shrink-0"
                    style={{ color: AKZENT }}
                    strokeWidth={1.6}
                  />
                  {MARKE.mail}
                </a>
              </li>
              <li className="flex items-center gap-3 text-[0.95rem]" style={{ color: LEISE }}>
                <MapPin
                  className="size-[1.15rem] shrink-0"
                  style={{ color: AKZENT }}
                  strokeWidth={1.6}
                />
                Partner workshops in {MARKE.region}
              </li>
            </ul>
          </Reveal>
        </div>

        <Reveal delay={260}>
          <form
            onSubmit={(e) => {
              e.preventDefault();
              setGesendet(true);
            }}
            className="p-6 sm:p-8"
            style={{ borderRadius: BILD, background: GRUND }}
          >
            <div className="grid gap-4 sm:grid-cols-2 sm:gap-5">
              <div>
                <label className="text-[0.82rem]" style={{ fontFamily: SANS, color: LEISE }}>
                  Name
                  <input name="name" type="text" placeholder="Jane Doe" style={FELD} />
                </label>
              </div>
              <div>
                <label className="text-[0.82rem]" style={{ fontFamily: SANS, color: LEISE }}>
                  Email
                  <input
                    name="email"
                    type="email"
                    placeholder="jane.doe@example.com"
                    style={FELD}
                  />
                </label>
              </div>
              <div className="sm:col-span-2">
                <label className="text-[0.82rem]" style={{ fontFamily: SANS, color: LEISE }}>
                  Your project
                  <textarea
                    name="message"
                    rows={4}
                    placeholder="Floor area, the role the fire should play, new build or renovation, timeline"
                    style={{ ...FELD, resize: "none" }}
                  />
                </label>
                <p className="mt-2 text-[0.78rem]" style={{ fontFamily: SANS, color: LEISE }}>
                  The more we know, the better our recommendation will fit.
                </p>
              </div>
            </div>

            <div className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-5">
              <button
                type="submit"
                className="group inline-flex w-full items-center justify-center overflow-hidden px-7 py-3.5 text-[0.95rem] sm:w-auto"
                style={{
                  borderRadius: KNOPF,
                  background: AKZENT,
                  color: "#fdfaf4",
                  fontFamily: SANS,
                  fontWeight: 500,
                }}
              >
                {gesendet ? "Thank you" : "Send enquiry"}
              </button>
              <p className="text-[0.78rem]" style={{ fontFamily: SANS, color: LEISE }}>
                We usually reply within two working days.
              </p>
            </div>
          </form>
        </Reveal>
      </div>
    </section>
  );
}

/* --------------------------------------------------------------- fusszeile */

function Fusszeile() {
  return (
    <footer style={{ background: TINTE, color: "#fdfaf4" }}>
      <div className="mx-auto grid max-w-7xl gap-10 px-5 py-16 sm:grid-cols-2 sm:gap-12 sm:px-6 lg:grid-cols-4 lg:gap-14 lg:px-10 lg:py-20">
        <div className="sm:col-span-2 lg:col-span-1">
          <a href="#top" aria-label="Back to the top" className="group inline-block">
            <Wortmarke hell gross />
          </a>
          <p
            className="mt-6 max-w-xs text-sm leading-relaxed"
            style={{ fontFamily: SANS, color: "rgba(253,250,244,0.7)" }}
          >
            Stone instead of steel. For a calm, comfortable fire at home. Since {MARKE.gegruendet}.
          </p>
        </div>

        {FUSS.map((spalte) => (
          <div key={spalte.titel}>
            <h3
              className="text-[0.9rem]"
              style={{ fontFamily: SANS, fontWeight: 600, color: "rgba(253,250,244,0.5)" }}
            >
              {spalte.titel}
            </h3>
            <ul className="mt-5 space-y-3">
              {spalte.links.map((link) => (
                <li key={link}>
                  <a
                    href="#categories"
                    className="text-sm transition-colors hover:text-[#fdfaf4]"
                    style={{ fontFamily: SANS, color: "rgba(253,250,244,0.7)" }}
                  >
                    {link}
                  </a>
                </li>
              ))}
            </ul>
          </div>
        ))}

        <div>
          <h3
            className="text-[0.9rem]"
            style={{ fontFamily: SANS, fontWeight: 600, color: "rgba(253,250,244,0.5)" }}
          >
            Contact
          </h3>
          <ul
            className="mt-5 space-y-3 text-sm"
            style={{ fontFamily: SANS, color: "rgba(253,250,244,0.7)" }}
          >
            <li className="flex items-center gap-2">
              <Phone className="size-4 shrink-0" strokeWidth={1.6} /> {MARKE.telefon}
            </li>
            <li className="flex items-center gap-2">
              <Mail className="size-4 shrink-0" strokeWidth={1.6} /> {MARKE.mail}
            </li>
            <li className="flex items-center gap-2">
              <MapPin className="size-4 shrink-0" strokeWidth={1.6} /> {MARKE.region}
            </li>
            <li className="mt-4 flex gap-4" style={{ color: "rgba(253,250,244,0.6)" }}>
              <Instagram className="size-4" strokeWidth={1.6} />
              <Facebook className="size-4" strokeWidth={1.6} />
              <Youtube className="size-4" strokeWidth={1.6} />
            </li>
          </ul>
        </div>
      </div>

      <div style={{ background: "rgba(253,250,244,0.05)" }}>
        <div
          className="mx-auto flex max-w-7xl flex-col gap-3 px-5 py-6 text-xs leading-relaxed sm:flex-row sm:items-center sm:justify-between sm:px-6 sm:py-7 lg:px-10"
          style={{ fontFamily: SANS, color: "rgba(253,250,244,0.55)" }}
        >
          <span>© 2026 {MARKE.firma} · Imprint · Privacy · Terms</span>
          <span>All figures and contact details are placeholders.</span>
        </div>
      </div>
    </footer>
  );
}

/* --------------------------------------------------------------------- page */

export function TerrafeuStovesPage() {
  return (
    <div style={{ background: GRUND, color: "#29231d", fontFamily: SANS }}>
      <Kopfzeile />
      <main>
        <Held />
        <Prinzip />
        <Kategorien />
        <Geschichte />
        <Stimmen />
        <Kontakt />
      </main>
      <Fusszeile />
    </div>
  );
}

export default TerrafeuStovesPage;
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