Templates/Stoves & Fireplaces
A warm, quiet page for stove builders, fireplace studios and anyone whose product is fire. One terracotta accent on a warm off-white, two bands in light sand and a near-black closing section. The hero photograph drifts more slowly than the page, the two comparison cards stack on top of each other on phones, and both strips become swipeable sliders with a thin progress line. Photographs keep their evening light, nothing is washed out.
The finished page to scroll through, switchable between desktop, tablet and mobile.
Source code
The complete page as a single file. Copy it into ./components and add the component to your route.
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;
Frequently asked questions
A curated landing page made of matching library sections in a sensible order from hero to footer. You copy the sections one by one and assemble them with the composition snippet.
Didn't find your question? We're happy to help.
Get in touch