Templates/Baumpflege
Eine Full-Page-Vorlage für Baumpflege, Garten- und Landschaftsbau, Dachdecker und jedes Handwerk, das erst einen Termin verkauft und dann einen Preis. Der Hero öffnet dunkel auf einem sommerlichen Kronendach, die stärkste Zahl der Seite steht als eigene Karte. Drei Leistungskarten stapeln sich beim Scrollen übereinander, während die Überschrift die linke Spalte hält. Der Ablauf läuft auf einer Schiene, die sich selbst zeichnet und jeden Schritt beim Erreichen aufhellt, begleitet von einer ruhigen Parallaxe auf dem Bild daneben. Die Referenzen ziehen als Laufband vorbei, halten unter dem Cursor an und öffnen jedes Bild in voller Größe. Ein tiefes Waldgrün, ein heller Limetten-Akzent, echte Fotografie und ein Anfrageformular, das nur fragt, was ein Ortstermin braucht.
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 {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import {
AnimatePresence,
motion,
useAnimationFrame,
useInView,
useMotionValue,
useReducedMotion,
useScroll,
useTransform,
} from "framer-motion";
import {
ArrowRight,
Award,
Axe,
CheckCircle2,
Clock,
Construction,
FileText,
Home,
Images,
Instagram,
Leaf,
Mail,
MapPin,
MapPinned,
Menu,
MessageSquare,
Phone,
Quote,
Route,
Send,
ShieldCheck,
Sprout,
Star,
Tag,
Trees,
Users,
X,
} from "lucide-react";
/*
* Northmoor Tree Care: a full page for a tree surgery business.
*
* Four colours and a grey scale. Dark green carries the brand, white and one
* neutral grey carry the page, and a single bright lime is the only accent, kept
* for the primary action and the marks that have to hold on the dark ground.
*
* House rules for this page: no shadows, no gradients as decoration, no pill
* buttons, no outlines around blocks. Sections are separated by a change of
* surface, never by a rule.
*/
const FOREST = "#061d13";
const FOREST_2 = "#1a3127";
const LIME = "#dfffa3";
const LIME_DEEP = "#cdf283";
const CREAM = "#f8f8f8";
const INK = "#0b0b0b";
const INK_2 = "#3e3e3e";
const MUTED = "#6f6f6f";
const HAIRLINE = "#e6e6e6";
const DISPLAY = '"Manrope", ui-sans-serif, system-ui, sans-serif';
const BODY = '"Albert Sans", ui-sans-serif, system-ui, sans-serif';
const display: CSSProperties = { fontFamily: DISPLAY };
const EASE = [0.44, 0, 0.56, 1] as const;
const SETTLE = [0.16, 1, 0.3, 1] as const;
const RADIUS_BTN = 10;
const RADIUS_CARD = 16;
/* ------------------------------------------------------------------ content */
type Bild = { src: string; alt: string; w: number; h: number; pos?: string };
const foto = (name: string, w: number, h: number, alt: string, pos?: string): Bild => ({
src: `/templates/tree-care/${name}.webp`,
alt,
w,
h,
...(pos ? { pos } : {}),
});
const FIRMA = {
name: "Northmoor Tree Care",
wordmark: { top: "Northmoor", bottom: "Tree Care" },
base: "Ashcombe",
phone: "01632 960 148",
phoneHref: "tel:+441632960148",
email: "hello@northmoortreecare.com",
emailHref: "mailto:hello@northmoortreecare.com",
};
const NAV = [
{ label: "Services", href: "#services", Icon: Leaf },
{ label: "Safety", href: "#safety", Icon: ShieldCheck },
{ label: "Recent work", href: "#work", Icon: Images },
{ label: "Contact", href: "#contact", Icon: Mail },
];
const VERSPRECHEN = [
{ label: "Free quote", Icon: FileText },
{ label: "Fixed price", Icon: Tag },
{ label: "Fully insured", Icon: ShieldCheck },
{ label: "Certified arborists", Icon: Award },
];
const HERO = {
eyebrow: "Local to Ashcombe",
h1: "Tree felling and stump grinding across the Ashcombe valley",
intro:
"Safe felling, stump grinding and crown care in Ashcombe, Barrowdale and Hollowmere. Free quote, fixed price, fully insured.",
bild: foto(
"hero-crown",
900,
1200,
"An arborist working a bare winter crown from a raised platform against a deep blue sky.",
"50% 45%",
),
fakt: {
title: "Emergency callout",
figure: "24/7",
note: "storm damage and unsafe trees, any hour",
},
};
const LEISTUNGEN = [
{
title: "Tree felling",
Icon: Trees,
lead: "We take down trees of any size, including the awkward ones close to houses, carports, power lines and boundaries. When a tree cannot drop in one piece, we take it down in sections.",
points: [
"Sectional felling in tight spots",
"Climbing and platform work",
"Removal and chipping",
],
bild: foto("svc-felling", 900, 1200, "A tree surgeon working at the base of a thick trunk."),
},
{
title: "Stump grinding",
Icon: Axe,
lead: "We grind the stump well below ground level, so you can seed grass, lay a path or plant again straight away. Kinder to the garden than digging it out.",
points: [
"Machines that fit through a narrow gate",
"Ground down below the surface",
"Chips left as mulch or taken away",
],
bild: foto(
"svc-stump",
900,
1200,
"A garden corner raked flat where a stump has been ground out.",
),
},
{
title: "Pruning and crown care",
Icon: Sprout,
lead: "Crown reduction, thinning, deadwooding and formative pruning. On an older tree it is often enough to prune rather than fell, and we will say so.",
points: ["Crown reduction and thinning", "Deadwood removal", "Hazard assessment"],
bild: foto("svc-pruning", 900, 1200, "An arborist working the upper crown of a mature tree."),
},
];
const SICHERHEIT = {
heading: "Safe felling, even in tight spots",
body: [
"Not every tree can drop in one piece. When one stands close to a house, a carport, a power line or a boundary, we take it down in sections, lowering each piece on ropes or working from the platform. That is how the building, the fence and the planting come through it unmarked.",
"We are fully insured, so you are covered from the first cut to the last load.",
],
bullets: [
{ label: "Sectional felling in tight and sensitive spots", Icon: Home },
{ label: "Climbing technique and platform work", Icon: Construction },
{ label: "Fully insured", Icon: Award },
{ label: "Right protective equipment on every job", Icon: ShieldCheck },
],
bild: foto(
"safety-garden",
1200,
900,
"A garden at the end of a day's work, cut logs stacked at the edge.",
"50% 55%",
),
zweitbild: foto(
"safety-house",
1200,
900,
"A tall tree growing tight against the corner of a house.",
),
};
const ABLAUF = {
bild: foto("process-light", 900, 1200, "Morning light through a misty canopy."),
steps: [
{ title: "Free quote", body: "We come out, look at the tree and give you a fixed price." },
{ title: "Planning", body: "We work out access, method and any permission the tree needs." },
{ title: "The work", body: "Carried out properly, with the right kit, fully insured." },
{ title: "Clear-up", body: "We tidy the site and take the timber away, or stack it for you." },
],
};
const GEBIETE = {
heading: "Ashcombe, Barrowdale and Hollowmere",
intro:
"We work out of a yard just outside Ashcombe and cover the valley and the lakeside, including Kettlebridge, Ardwick and Fenn Cross.",
primary: [
{
name: "Ashcombe",
body: "From the big garden trees on the edge of town to the tighter plots near the centre where there is barely room to swing a rope.",
home: true,
},
{
name: "Barrowdale",
body: "Farmland, hedgerow trees and long drives. Wind damage after a storm is most of what we see out here.",
},
{
name: "Hollowmere",
body: "The whole lakeside. Pines and old oaks at holiday houses, often exposed and often overdue a look.",
},
],
more: ["Kettlebridge", "Ardwick", "Fenn Cross", "Marsden", "Oakhill", "Thornbury"],
};
const GALERIE: Bild[] = [
foto(
"job-3",
1200,
900,
"A garden corner cleared and raked flat, firewood stacked along the fence.",
"50% 55%",
),
foto(
"job-4",
900,
1200,
"A tall tree growing tight against the corner of a house, checked from the ground.",
"50% 45%",
),
foto(
"job-5",
1200,
900,
"A tree surgeon making the felling cut low on a thick trunk, sawdust in the air.",
),
foto(
"job-6",
900,
1200,
"A wide lawn in evening light, cut logs stacked at the edge at the end of the day.",
),
];
const UEBER = {
heading: "About Northmoor Tree Care",
body: [
"Northmoor Tree Care is run by Daniel Reeve from a yard just outside Ashcombe. We work for homeowners, residents' associations, businesses and the council on felling, stump grinding, crown care and general tree work across the valley.",
"We are local and we know the ground here, from town gardens in Ashcombe to exposed plots along the water at Hollowmere. Climbing technique, a platform and a stump grinder cover most of it, and the kit grows every year.",
],
stats: [
{ value: 15, suffix: "+", label: "years in the trade" },
{ value: 800, suffix: "+", label: "trees taken down" },
{ value: 24, suffix: "/7", label: "emergency callout" },
],
certifications: ["Certified arborist", "Chainsaw and aerial rescue certificates"],
bild: foto(
"team-portrait",
1200,
800,
"A tree surgeon standing in a garden he has just finished working.",
"50% 35%",
),
};
const STIMMEN = [
{
quote:
"A forty foot ash right over the conservatory. They took it down in sections in a morning and I could not tell afterwards that anyone had been in the garden.",
name: "Helen M.",
source: "Ashcombe",
},
{
quote:
"Quoted a fixed price on the Tuesday, felled on the Friday, and the price was the price. No surprises at the end.",
name: "Rob T.",
source: "Barrowdale",
},
{
quote:
"Two old oaks at the holiday house we had been worrying about for years. They talked us out of felling one of them and pruned it instead, which I appreciated.",
name: "Sarah K.",
source: "Hollowmere",
},
{
quote:
"Storm took a limb off over the driveway on a Sunday night. Someone answered the phone and they were out first thing.",
name: "James P.",
source: "Kettlebridge",
},
{
quote:
"Ground out six stumps across the site and left it ready to turf. Tidy work and easy to deal with.",
name: "Ardwick Grounds Ltd",
source: "Ardwick",
},
];
const FRAGEN = [
{
q: "Can you fell a tree standing close to the house?",
a: "Yes. That is most of what we do. When a tree cannot drop in one piece we take it down in sections, lowering each piece on ropes or working from the platform, so nothing lands where it should not.",
},
{
q: "Do I need permission to fell a tree?",
a: "Sometimes. Trees under a preservation order, and any tree in a conservation area, need consent before work starts. We check before we quote and we will tell you what applies to your tree.",
},
{
q: "Are you insured?",
a: "Fully. Public liability cover is in place on every job and we are happy to send the certificate before we start.",
},
{
q: "What happens to the wood and the brash?",
a: "Whatever you prefer. We can chip the brash on site and take it away, leave the chips as mulch, or cut and stack the timber for firewood.",
},
{
q: "Which areas do you cover?",
a: "Ashcombe, Barrowdale and Hollowmere, and the villages between them, including Kettlebridge, Ardwick and Fenn Cross. Ask if you are further out, we often are anyway.",
},
{
q: "How do I get a quote?",
a: "Call or send the form. We come out, look at the tree and give you a fixed price before any work begins. The visit and the quote are free.",
},
];
const ANFRAGE_THEMEN = [
{ label: "Tree felling", Icon: Trees },
{ label: "Stump grinding", Icon: Axe },
{ label: "Pruning", Icon: Sprout },
{ label: "Something else", Icon: MessageSquare },
];
const FOOTER_BILD = foto("footer-canopy", 1440, 810, "", "50% 40%");
/* --------------------------------------------------------------- primitives */
/** Scroll reveal. One shape for the whole page, so nothing arrives differently. */
function Reveal({
children,
delay = 0,
as = "div",
className = "",
style,
distance = 26,
}: {
children: ReactNode;
delay?: number;
as?: "div" | "li" | "figure";
className?: string;
style?: CSSProperties;
distance?: number;
}) {
const reduce = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const sichtbar = useInView(ref, { once: true, amount: 0.25 });
const Tag = motion[as];
return (
<Tag
ref={ref as never}
className={className}
style={style}
initial={reduce ? false : { opacity: 0, y: distance }}
animate={sichtbar || reduce ? { opacity: 1, y: 0 } : { opacity: 0, y: distance }}
transition={{ duration: reduce ? 0 : 0.8, ease: SETTLE, delay: reduce ? 0 : delay }}
>
{children}
</Tag>
);
}
/** An image settling out of a slow push-in, rather than simply appearing. */
function Medium({
bild,
className = "",
eager = false,
}: {
bild: Bild;
className?: string;
eager?: boolean;
}) {
const reduce = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const sichtbar = useInView(ref, { once: true, amount: 0.2 });
return (
<div ref={ref} className={`overflow-hidden ${className}`}>
<motion.img
src={bild.src}
alt={bild.alt}
width={bild.w}
height={bild.h}
loading={eager ? "eager" : "lazy"}
decoding="async"
className="h-full w-full object-cover"
style={{ objectPosition: bild.pos ?? "50% 50%" }}
initial={reduce ? false : { scale: 1.1, opacity: 0 }}
animate={sichtbar || reduce ? { scale: 1, opacity: 1 } : { scale: 1.1, opacity: 0 }}
transition={{ duration: reduce ? 0 : 1.2, ease: SETTLE }}
/>
</div>
);
}
/** Plain image, for places that carry their own motion. */
function Foto({
bild,
className = "",
eager = false,
}: {
bild: Bild;
className?: string;
eager?: boolean;
}) {
return (
<img
src={bild.src}
alt={bild.alt}
width={bild.w}
height={bild.h}
loading={eager ? "eager" : "lazy"}
decoding="async"
className={`h-full w-full object-cover ${className}`}
style={{ objectPosition: bild.pos ?? "50% 50%" }}
/>
);
}
/*
* Buttons: solid tonal fills only. Hover moves within the button's own colour
* family and rolls the label up out of a mask, with an identical copy pushing in
* from below. Nothing scales.
*/
const KNOPF_BASIS =
"group relative inline-flex min-h-12 items-center justify-center overflow-hidden px-6 py-3 text-[0.98rem] font-semibold transition-colors duration-300";
const KNOPF_TON = {
lime: "text-[#061d13]",
forest: "text-[#f8f8f8]",
onDark: "text-[#f8f8f8]",
onLight: "text-[#061d13]",
} as const;
const KNOPF_FLAECHE: Record<keyof typeof KNOPF_TON, { ruhe: string; hover: string }> = {
lime: { ruhe: LIME, hover: LIME_DEEP },
forest: { ruhe: FOREST, hover: FOREST_2 },
onDark: { ruhe: "rgba(255,255,255,0.10)", hover: "rgba(255,255,255,0.20)" },
onLight: { ruhe: CREAM, hover: "#eceff0" },
};
function Rolle({ Icon, children }: { Icon?: typeof ArrowRight; children: ReactNode }) {
const inhalt = (
<>
{Icon ? <Icon className="h-4 w-4 shrink-0" strokeWidth={1.6} /> : null}
{children}
</>
);
return (
<span className="relative block h-5 overflow-hidden">
<span className="flex h-5 items-center justify-center gap-2 transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:-translate-y-full group-focus-visible:-translate-y-full">
{inhalt}
</span>
<span
aria-hidden="true"
className="absolute inset-0 flex h-5 translate-y-full items-center justify-center gap-2 transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:translate-y-0 group-focus-visible:translate-y-0"
>
{inhalt}
</span>
</span>
);
}
function Knopf({
href,
ton = "lime",
Icon,
children,
className = "",
}: {
href: string;
ton?: keyof typeof KNOPF_TON;
Icon?: typeof ArrowRight;
children: ReactNode;
className?: string;
}) {
const [ueber, setUeber] = useState(false);
const f = KNOPF_FLAECHE[ton];
return (
<a
href={href}
className={`${KNOPF_BASIS} ${KNOPF_TON[ton]} ${className}`}
style={{ background: ueber ? f.hover : f.ruhe, borderRadius: RADIUS_BTN }}
onMouseEnter={() => setUeber(true)}
onMouseLeave={() => setUeber(false)}
onFocus={() => setUeber(true)}
onBlur={() => setUeber(false)}
>
<Rolle {...(Icon ? { Icon } : {})}>{children}</Rolle>
</a>
);
}
function KnopfEcht({
ton = "lime",
Icon,
children,
className = "",
...rest
}: {
ton?: keyof typeof KNOPF_TON;
Icon?: typeof ArrowRight;
children: ReactNode;
className?: string;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const [ueber, setUeber] = useState(false);
const f = KNOPF_FLAECHE[ton];
return (
<button
className={`${KNOPF_BASIS} ${KNOPF_TON[ton]} ${className}`}
style={{ background: ueber ? f.hover : f.ruhe, borderRadius: RADIUS_BTN }}
onMouseEnter={() => setUeber(true)}
onMouseLeave={() => setUeber(false)}
onFocus={() => setUeber(true)}
onBlur={() => setUeber(false)}
{...rest}
>
<Rolle {...(Icon ? { Icon } : {})}>{children}</Rolle>
</button>
);
}
/**
* The kicker above a heading, set as a chip in the same shape as the buttons.
* A chip on grey takes white, a chip on white takes grey: the surface it stands
* on decides, so the mark never merges into its ground.
*/
function Kicker({
children,
Icon,
ton = "dark",
grund = "white",
}: {
children: ReactNode;
Icon?: typeof Leaf;
ton?: "dark" | "light";
grund?: "white" | "grey";
}) {
const flaeche = ton === "light" ? "rgba(255,255,255,0.10)" : grund === "grey" ? "#ffffff" : CREAM;
const farbe = ton === "light" ? LIME : FOREST_2;
return (
<p
className="mb-6 inline-flex items-center gap-2 px-3.5 py-2 text-[0.76rem] font-semibold uppercase tracking-[0.16em]"
style={{ background: flaeche, color: farbe, borderRadius: RADIUS_BTN }}
>
{Icon ? <Icon className="h-4 w-4 shrink-0" strokeWidth={1.6} /> : null}
{children}
</p>
);
}
function Ueberschrift({
kicker,
KickerIcon,
title,
intro,
ton = "dark",
grund = "white",
}: {
kicker?: string;
KickerIcon?: typeof Leaf;
title: string;
intro?: string;
ton?: "dark" | "light";
grund?: "white" | "grey";
}) {
return (
<div className="max-w-3xl">
{kicker ? (
<Reveal>
<Kicker ton={ton} grund={grund} {...(KickerIcon ? { Icon: KickerIcon } : {})}>
{kicker}
</Kicker>
</Reveal>
) : null}
<Reveal delay={0.07}>
<h2
className="text-[2rem] font-semibold leading-[1.1] tracking-[-0.02em] sm:text-[2.75rem]"
style={{ ...display, color: ton === "light" ? CREAM : INK }}
>
{title}
</h2>
</Reveal>
{intro ? (
<Reveal delay={0.14}>
<p
className="mt-6 text-lg leading-[1.55]"
style={{ color: ton === "light" ? "rgba(248,248,248,0.7)" : MUTED }}
>
{intro}
</p>
</Reveal>
) : null}
</div>
);
}
function Abschnitt({
children,
ton = "cream",
id,
className = "",
randlos = false,
}: {
children: ReactNode;
ton?: "cream" | "white" | "forest";
id?: string;
className?: string;
randlos?: boolean;
}) {
const flaechen = {
cream: { background: CREAM, color: INK },
white: { background: "#ffffff", color: INK },
forest: { background: FOREST, color: CREAM },
} as const;
return (
<section id={id} className={`py-20 lg:py-32 ${className}`} style={flaechen[ton]}>
<div className={`mx-auto max-w-[1240px] ${randlos ? "" : "px-5 sm:px-8"}`}>{children}</div>
</section>
);
}
/** The lockup: a lobed crown on a short trunk, drawn as one path. */
function Logo({ ton = "light" }: { ton?: "light" | "dark" }) {
return (
<span className="flex items-center gap-3">
<svg
viewBox="0 0 140 100"
aria-hidden="true"
className="h-8 w-[44px] shrink-0 sm:h-9 sm:w-[50px]"
style={{ color: ton === "dark" ? LIME : FOREST }}
>
<path
fill="currentColor"
d="M70 18.2 A36.8 20.8 0 0 1 104.5 44.2 A34.5 19.5 0 0 1 90.7 72.8 L79.2 72.8 L79.2 91 L60.8 91 L60.8 72.8 A34.5 19.5 0 0 1 35.5 44.2 A36.8 20.8 0 0 1 70 18.2 Z"
/>
</svg>
<span className="flex flex-col leading-none">
<span
className="text-[0.95rem] font-semibold uppercase tracking-[0.02em] sm:text-[1.02rem]"
style={{ ...display, color: ton === "dark" ? CREAM : INK }}
>
{FIRMA.wordmark.top}
</span>
<span
className="mt-1 text-[0.56rem] font-semibold uppercase tracking-[0.22em] sm:text-[0.6rem]"
style={{ color: ton === "dark" ? "rgba(248,248,248,0.55)" : MUTED }}
>
{FIRMA.wordmark.bottom}
</span>
</span>
<span className="sr-only">{FIRMA.name}</span>
</span>
);
}
/* ------------------------------------------------------------------ header */
function Kopf() {
const [offen, setOffen] = useState(false);
const reduce = useReducedMotion();
useEffect(() => {
if (!offen) return;
const zu = (e: KeyboardEvent) => e.key === "Escape" && setOffen(false);
window.addEventListener("keydown", zu);
return () => window.removeEventListener("keydown", zu);
}, [offen]);
return (
<>
<header className="sticky top-0 z-50 lg:static" style={{ background: FOREST, color: CREAM }}>
<div className="mx-auto flex h-16 max-w-[1240px] items-center justify-between gap-6 px-5 sm:px-8 lg:h-[4.75rem]">
<a href="#top" className="shrink-0" aria-label={`${FIRMA.name}, back to the top`}>
<Logo ton="dark" />
</a>
<nav aria-label="Main menu" className="hidden items-center gap-8 lg:flex">
{NAV.map((item) => (
<a
key={item.href}
href={item.href}
className="group relative py-1 text-[0.98rem] font-medium transition-colors duration-300"
style={{ color: "rgba(248,248,248,0.75)" }}
>
<span className="block transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:-translate-y-0.5">
{item.label}
</span>
<span
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-px origin-left scale-x-0 transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-x-100 group-focus-visible:scale-x-100"
style={{ background: LIME }}
/>
</a>
))}
</nav>
<div className="hidden items-center gap-3 lg:flex">
<a
href={FIRMA.phoneHref}
className="inline-flex items-center gap-2 text-[0.98rem] font-semibold transition-colors duration-300 hover:opacity-80"
style={{ color: CREAM }}
>
<Phone className="h-4 w-4" strokeWidth={1.6} />
{FIRMA.phone}
</a>
<Knopf href="#contact" Icon={FileText} className="px-5">
Free quote
</Knopf>
</div>
<button
type="button"
onClick={() => setOffen((o) => !o)}
aria-expanded={offen}
aria-controls="tree-care-menu"
className="inline-flex h-10 w-10 items-center justify-center transition-colors duration-300 lg:hidden"
style={{ background: "rgba(255,255,255,0.10)", color: CREAM, borderRadius: RADIUS_BTN }}
>
<span className="sr-only">{offen ? "Close menu" : "Open menu"}</span>
{offen ? (
<X className="h-5 w-5" strokeWidth={1.6} />
) : (
<Menu className="h-5 w-5" strokeWidth={1.6} />
)}
</button>
</div>
</header>
<AnimatePresence>
{offen ? (
<motion.div
id="tree-care-menu"
className="fixed inset-x-0 bottom-0 top-16 z-50 overflow-y-auto lg:hidden"
style={{ background: FOREST, color: CREAM }}
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={reduce ? undefined : { opacity: 0 }}
transition={{ duration: 0.24, ease: EASE }}
>
<nav
aria-label="Mobile menu"
className="mx-auto flex min-h-full max-w-[1240px] flex-col px-5 pb-8 pt-4 sm:px-8"
>
<ul className="grid gap-2">
{NAV.map((item, i) => (
<motion.li
key={item.href}
initial={reduce ? false : { opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
ease: SETTLE,
delay: reduce ? 0 : 0.05 + i * 0.05,
}}
>
<a
href={item.href}
onClick={() => setOffen(false)}
className="group flex items-center gap-4 px-5 py-5 text-[1.35rem] font-semibold transition-colors duration-300"
style={{
...display,
background: "rgba(255,255,255,0.06)",
color: CREAM,
borderRadius: RADIUS_CARD,
}}
>
<item.Icon
className="h-[1.35rem] w-[1.35rem] shrink-0"
strokeWidth={1.6}
style={{ color: LIME }}
/>
{item.label}
<ArrowRight
className="ml-auto h-5 w-5 transition-transform duration-500 group-hover:translate-x-1"
strokeWidth={1.6}
style={{ color: LIME }}
/>
</a>
</motion.li>
))}
</ul>
<div className="mt-auto grid gap-3 pt-8">
<Knopf href="#contact" Icon={FileText}>
Request a free quote
</Knopf>
<Knopf href={FIRMA.phoneHref} ton="onDark" Icon={Phone}>
Call {FIRMA.phone}
</Knopf>
<a
href={FIRMA.emailHref}
className="mt-2 inline-flex items-center justify-center gap-2.5 text-[0.95rem] font-semibold"
style={{ color: LIME }}
>
<Mail className="h-[1.1rem] w-[1.1rem]" strokeWidth={1.6} />
{FIRMA.email}
</a>
</div>
</nav>
</motion.div>
) : null}
</AnimatePresence>
</>
);
}
/* -------------------------------------------------------------------- hero */
function Hero() {
const reduce = useReducedMotion();
const auftritt = (verzug: number) =>
reduce
? {}
: {
initial: { opacity: 0, y: 22 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.85, ease: SETTLE, delay: verzug },
};
return (
<section style={{ background: FOREST, color: CREAM }}>
<div className="mx-auto grid max-w-[1240px] items-center gap-14 px-5 py-20 sm:px-8 lg:min-h-[calc(100vh-4.75rem)] lg:grid-cols-[1.05fr_0.95fr] lg:gap-20 lg:py-24">
<div>
<motion.p
className="mb-6 inline-flex items-center gap-2 px-3.5 py-2 text-[0.76rem] font-semibold uppercase tracking-[0.16em]"
style={{ background: "rgba(255,255,255,0.10)", color: LIME, borderRadius: RADIUS_BTN }}
{...auftritt(0.06)}
>
<MapPinned className="h-4 w-4" strokeWidth={1.6} />
{HERO.eyebrow}
</motion.p>
<motion.h1
className="max-w-[16ch] text-[2.6rem] font-semibold leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem] lg:text-[4.6rem]"
style={display}
{...auftritt(0.16)}
>
{HERO.h1}
</motion.h1>
<motion.p
className="mt-7 max-w-xl text-lg leading-[1.55] lg:text-[1.2rem]"
style={{ color: "rgba(248,248,248,0.7)" }}
{...auftritt(0.28)}
>
{HERO.intro}
</motion.p>
<motion.div className="mt-10 flex flex-col gap-3 sm:flex-row" {...auftritt(0.4)}>
<Knopf href="#contact" Icon={FileText}>
Request a free quote
</Knopf>
<Knopf href={FIRMA.phoneHref} ton="onDark" Icon={Phone}>
Call {FIRMA.phone}
</Knopf>
</motion.div>
</div>
<motion.div className="relative mb-10 sm:mb-0" {...auftritt(0.34)}>
<div
className="relative aspect-square overflow-hidden"
style={{ background: FOREST_2, borderRadius: RADIUS_CARD }}
>
<Foto bild={HERO.bild} eager />
</div>
{/* The strongest single number on the page, set as one. No phone
number: the hero already carries a call button two columns left. */}
<div
className="absolute -bottom-7 right-4 w-[14.5rem] overflow-hidden sm:-bottom-8 sm:right-6 sm:w-[18.5rem]"
style={{ background: "#ffffff", color: INK, borderRadius: RADIUS_CARD }}
>
<div className="px-5 py-5 sm:px-7 sm:py-7">
<p
className="flex items-center gap-2 text-[0.66rem] font-semibold uppercase tracking-[0.16em] sm:text-[0.7rem] sm:tracking-[0.18em]"
style={{ color: MUTED }}
>
<Clock className="h-4 w-4" strokeWidth={1.6} style={{ color: FOREST_2 }} />
{HERO.fakt.title}
</p>
<p
className="mt-2 text-[2.35rem] font-semibold leading-[0.9] tracking-[-0.03em] sm:mt-3 sm:text-[3.1rem]"
style={{ ...display, color: FOREST }}
>
{HERO.fakt.figure}
</p>
<p
className="mt-2 text-[0.82rem] leading-[1.4] sm:mt-3 sm:text-[0.9rem]"
style={{ color: MUTED }}
>
{HERO.fakt.note}
</p>
</div>
</div>
</motion.div>
</div>
<div className="h-8 lg:h-4" style={{ background: FOREST }} />
</section>
);
}
/* ------------------------------------------------------------- trust strip */
function Versprechen() {
return (
<div style={{ background: "#ffffff" }}>
<ul className="mx-auto flex max-w-[1240px] flex-col items-center gap-7 px-5 py-11 sm:px-8 lg:flex-row lg:flex-wrap lg:justify-between lg:gap-x-8 lg:gap-y-6 lg:py-12">
{VERSPRECHEN.map((t, i) => (
<Reveal
as="li"
key={t.label}
delay={i * 0.09}
className="flex flex-col items-center gap-2.5 text-center lg:flex-row lg:gap-3 lg:text-left"
>
<t.Icon
className="h-7 w-7 shrink-0 lg:h-6 lg:w-6"
strokeWidth={1.4}
style={{ color: FOREST_2 }}
/>
<span className="text-[1.06rem] font-semibold lg:text-[1.02rem]" style={{ color: INK }}>
{t.label}
</span>
</Reveal>
))}
</ul>
</div>
);
}
/* ---------------------------------------------------------------- services */
/**
* The three services stack on top of each other as you scroll: each card sticks
* at the same height, so the next slides exactly over the one before it and the
* section reads as a deck being dealt. The heading holds the left column
* throughout, which a three-up grid cannot do.
*/
function Leistungen() {
return (
<Abschnitt ton="cream" id="services" randlos>
<div className="lg:grid lg:grid-cols-[0.72fr_1.28fr] lg:gap-12 lg:px-8">
<div className="px-5 sm:px-8 lg:sticky lg:top-24 lg:self-start lg:px-0">
<Ueberschrift
kicker="What we do"
KickerIcon={Leaf}
grund="grey"
title="Felling, stump grinding and crown care"
intro="We handle the whole job, from the free quote to hauling the timber away. Always at a fixed price."
/>
<Reveal delay={0.22}>
<div className="mt-9 hidden lg:block">
<Knopf href="#contact" Icon={FileText}>
Request a free quote
</Knopf>
</div>
</Reveal>
</div>
<div className="mt-14 grid gap-8 px-5 sm:px-8 lg:mt-0 lg:block lg:px-0 lg:pb-40">
{/* Every card sticks at the same height and carries the same minimum,
so the next one covers the one before it completely except for the
2 rem it is offset by. Without the minimum on the sticky element
itself, a shorter card is left half visible under the next and the
deck reads as a layout fault rather than a deck. */}
{LEISTUNGEN.map((s, i) => (
<div
key={s.title}
className={`lg:sticky lg:top-20 lg:min-h-[26rem] ${i === 0 ? "" : "lg:mt-8"}`}
style={{ zIndex: i + 1 }}
>
<div>
<a
href="#contact"
className="group flex h-full flex-col overflow-hidden lg:grid lg:min-h-[26rem] lg:grid-cols-[0.8fr_1.2fr] lg:items-stretch"
style={{ background: "#ffffff", borderRadius: RADIUS_CARD }}
>
<div className="aspect-[16/9] overflow-hidden lg:aspect-auto lg:h-full lg:min-h-[18rem]">
<Foto
bild={s.bild}
className="transition-transform duration-[1100ms] ease-[cubic-bezier(0.44,0,0.56,1)] group-hover:scale-[1.04]"
/>
</div>
<div className="flex flex-1 flex-col p-5 sm:p-8 lg:p-9">
<h3
className="flex items-center gap-2.5 text-[1.3rem] font-semibold tracking-[-0.01em] lg:text-[1.7rem]"
style={{ ...display, color: INK }}
>
<s.Icon
className="h-6 w-6 shrink-0"
strokeWidth={1.4}
style={{ color: FOREST_2 }}
/>
{s.title}
</h3>
<p
className="mt-2.5 text-[0.94rem] leading-[1.5] lg:mt-4 lg:text-[1.02rem] lg:leading-[1.6]"
style={{ color: MUTED }}
>
{s.lead}
</p>
<ul className="mt-4 grid gap-2 lg:mt-5 lg:gap-2.5">
{s.points.map((p) => (
<li
key={p}
className="flex items-start gap-2.5 text-[0.92rem] leading-[1.45] lg:text-[0.99rem] lg:leading-[1.55]"
style={{ color: INK_2 }}
>
<CheckCircle2
className="mt-px h-[1.05rem] w-[1.05rem] shrink-0"
strokeWidth={1.5}
style={{ color: FOREST_2 }}
/>
{p}
</li>
))}
</ul>
<span
className="mt-5 inline-flex items-center gap-2 text-[0.95rem] font-semibold lg:mt-6 lg:text-[0.98rem]"
style={{ color: FOREST_2 }}
>
More about {s.title.toLowerCase()}
<ArrowRight
className="h-4 w-4 transition-transform duration-500 group-hover:translate-x-1.5"
strokeWidth={1.6}
/>
</span>
</div>
</a>
</div>
</div>
))}
</div>
</div>
</Abschnitt>
);
}
/* ------------------------------------------------------------------ safety */
function Sicherheit() {
return (
<Abschnitt ton="forest" id="safety">
<div className="grid gap-10 lg:grid-cols-2 lg:items-center lg:gap-20">
<Medium bild={SICHERHEIT.bild} className="aspect-[4/3] lg:order-1 lg:hidden" />
<div className="order-1 lg:order-2">
<Ueberschrift
kicker="Safety"
KickerIcon={ShieldCheck}
title={SICHERHEIT.heading}
ton="light"
/>
<div className="mt-7 grid gap-5">
{SICHERHEIT.body.map((p, i) => (
<Reveal key={p.slice(0, 24)} delay={0.16 + i * 0.09}>
<p
className="text-[1.05rem] leading-[1.6] lg:text-lg"
style={{ color: "rgba(248,248,248,0.7)" }}
>
{p}
</p>
</Reveal>
))}
</div>
<ul className="mt-9 grid gap-4">
{SICHERHEIT.bullets.map((b, i) => (
<Reveal
as="li"
key={b.label}
delay={0.34 + i * 0.09}
className="flex items-start gap-3"
>
<b.Icon
className="mt-0.5 h-5 w-5 shrink-0"
strokeWidth={1.4}
style={{ color: LIME }}
/>
<span className="text-[1.02rem] leading-[1.5]" style={{ color: CREAM }}>
{b.label}
</span>
</Reveal>
))}
</ul>
</div>
<div className="order-2 grid gap-6 lg:order-1">
<Medium bild={SICHERHEIT.bild} className="hidden aspect-[4/3] lg:block" />
<Medium bild={SICHERHEIT.zweitbild} className="aspect-[4/3] lg:aspect-[16/7]" />
</div>
</div>
</Abschnitt>
);
}
/* ----------------------------------------------------------------- process */
/**
* The four steps read as a list beside a photo of a job in progress: the image
* anchors the abstract sequence in something real. One vertical rail carries the
* numbers at every breakpoint, drawn by scrolling. Each step lights up as the
* line reaches its number, and the photo answers the same value with a slow
* parallax drift.
*/
function Ablauf() {
const reduce = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start 0.85", "end 0.55"] });
const drift = useTransform(scrollYProgress, [0, 1], reduce ? ["0%", "0%"] : ["-4%", "4%"]);
const [fortschritt, setFortschritt] = useState(reduce ? 1 : 0);
useEffect(() => {
if (reduce) return;
return scrollYProgress.on("change", setFortschritt);
}, [reduce, scrollYProgress]);
const anzahl = ABLAUF.steps.length;
return (
<Abschnitt ton="white">
<div ref={ref} className="grid gap-12 lg:grid-cols-[0.85fr_1.15fr] lg:items-start lg:gap-20">
<Reveal className="order-2 overflow-hidden lg:order-1 lg:sticky lg:top-20" distance={0}>
<div
className="aspect-[16/11] overflow-hidden lg:aspect-[4/5]"
style={{ borderRadius: RADIUS_CARD }}
>
<motion.div className="h-[112%] w-full" style={{ y: drift }}>
<Foto bild={ABLAUF.bild} />
</motion.div>
</div>
</Reveal>
<div className="order-1 lg:order-2 lg:pt-6">
<Ueberschrift kicker="How it works" KickerIcon={Route} title="How a job runs" />
<ol className="mt-11 grid gap-10 lg:mt-12">
{ABLAUF.steps.map((s, i) => {
const schwelle = (i + 0.35) / anzahl;
const erreicht = fortschritt >= schwelle;
const segment = Math.max(
0,
Math.min(1, (fortschritt - (i + 0.35) / anzahl) * anzahl * 1.25),
);
return (
<li key={s.title} className="relative pl-14">
{i < anzahl - 1 ? (
<span
aria-hidden="true"
className="absolute -bottom-10 left-[19px] top-10 w-px"
>
<span className="absolute inset-0" style={{ background: HAIRLINE }} />
<span
className="absolute inset-x-0 top-0 transition-[height] duration-200 ease-linear"
style={{ height: `${segment * 100}%`, background: FOREST_2 }}
/>
</span>
) : null}
<motion.span
className="absolute left-0 top-0 flex h-10 w-10 items-center justify-center text-[0.82rem] font-semibold"
style={{
...display,
borderRadius: RADIUS_BTN,
background: erreicht ? FOREST : CREAM,
color: erreicht ? CREAM : "#c2c2c2",
}}
animate={{ scale: erreicht ? 1.08 : 1 }}
transition={{ duration: reduce ? 0 : 0.52, ease: [0.22, 1, 0.36, 1] }}
>
{String(i + 1).padStart(2, "0")}
</motion.span>
<motion.div
animate={{ opacity: erreicht ? 1 : 0.62, y: erreicht ? 0 : 6 }}
transition={{ duration: reduce ? 0 : 0.52, ease: EASE }}
>
<h3
className="text-[1.3rem] font-semibold tracking-[-0.01em]"
style={{ ...display, color: INK }}
>
{s.title}
</h3>
<p className="mt-2 text-[1rem] leading-[1.6]" style={{ color: MUTED }}>
{s.body}
</p>
</motion.div>
</li>
);
})}
</ol>
</div>
</div>
</Abschnitt>
);
}
/* ------------------------------------------------------------------- areas */
function Gebiete() {
return (
<Abschnitt ton="cream" randlos>
<div className="px-5 sm:px-8">
<Ueberschrift
kicker="Where we work"
grund="grey"
KickerIcon={MapPinned}
title={GEBIETE.heading}
intro={GEBIETE.intro}
/>
</div>
<div className="mt-12 grid gap-5 px-5 sm:px-8 lg:mt-14 lg:grid-cols-3 lg:px-8">
{GEBIETE.primary.map((a, i) => (
<Reveal key={a.name} delay={i * 0.12}>
{/* The card rises under the cursor and does nothing else. A colour
change here fought the photography either side of it. */}
<a
href="#contact"
className="group flex h-full flex-col p-7 transition-transform duration-500 hover:-translate-y-1.5 sm:p-8"
style={{ background: "#ffffff", borderRadius: RADIUS_CARD }}
>
<span
className="flex flex-wrap items-center gap-2 text-[1.4rem] font-semibold tracking-[-0.01em] sm:text-[1.5rem]"
style={{ ...display, color: INK }}
>
<MapPin className="h-5 w-5" strokeWidth={1.5} style={{ color: FOREST_2 }} />
{a.name}
{a.home ? (
<span
className="px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em]"
style={{
...{ fontFamily: BODY },
background: CREAM,
color: FOREST_2,
borderRadius: RADIUS_BTN,
}}
>
Home patch
</span>
) : null}
</span>
<p className="mt-4 flex-1 text-[1rem] leading-[1.6]" style={{ color: MUTED }}>
{a.body}
</p>
<span
className="mt-6 inline-flex items-center gap-2 text-[0.95rem] font-semibold"
style={{ color: FOREST_2 }}
>
Tree felling in {a.name}
<ArrowRight
className="h-4 w-4 transition-transform duration-500 group-hover:translate-x-1.5"
strokeWidth={1.6}
/>
</span>
</a>
</Reveal>
))}
</div>
<Reveal delay={0.22}>
<ul className="mt-10 flex flex-wrap gap-2.5 px-5 sm:px-8">
{GEBIETE.more.map((m) => (
<li
key={m}
className="px-4 py-2 text-[0.92rem]"
style={{ background: "#ffffff", color: MUTED, borderRadius: RADIUS_BTN }}
>
{m}
</li>
))}
</ul>
</Reveal>
</Abschnitt>
);
}
/* ------------------------------------------------------------------ ticker */
/**
* A run of cards that drifts by itself and stands aside the moment anyone points
* at it. Two identical copies, and the offset jumps back by exactly one copy's
* measured width, so the seam never shows.
*/
function Laufband({
children,
tempo = 26,
className = "",
}: {
children: ReactNode;
tempo?: number;
className?: string;
}) {
const reduce = useReducedMotion();
const kopieRef = useRef<HTMLDivElement>(null);
const [breite, setBreite] = useState(0);
const [pause, setPause] = useState(false);
const x = useMotionValue(0);
useLayoutEffect(() => {
const el = kopieRef.current;
if (!el) return;
const messen = () => setBreite(el.getBoundingClientRect().width);
messen();
const beobachter = new ResizeObserver(messen);
beobachter.observe(el);
return () => beobachter.disconnect();
}, []);
useAnimationFrame((_, delta) => {
if (reduce || pause || !breite) return;
const naechster = x.get() - (tempo * delta) / 1000;
x.set(naechster <= -breite ? naechster + breite : naechster);
});
return (
<div
className={`overflow-hidden ${className}`}
onMouseEnter={() => setPause(true)}
onMouseLeave={() => setPause(false)}
>
<motion.div className="flex w-max" style={{ x }}>
<div ref={kopieRef} className="flex shrink-0 gap-4 pr-4 sm:gap-5 sm:pr-5">
{children}
</div>
<div className="flex shrink-0 gap-4 pr-4 sm:gap-5 sm:pr-5" aria-hidden="true">
{children}
</div>
</motion.div>
</div>
);
}
/* ----------------------------------------------------------------- gallery */
function Galerie() {
const reduce = useReducedMotion();
const [gross, setGross] = useState<Bild | null>(null);
useEffect(() => {
if (!gross) return;
const zu = (e: KeyboardEvent) => e.key === "Escape" && setGross(null);
window.addEventListener("keydown", zu);
return () => window.removeEventListener("keydown", zu);
}, [gross]);
const rahmen = (bild: Bild, i: number) => (
<button
key={`${bild.src}-${i}`}
type="button"
onClick={() => setGross(bild)}
style={{ aspectRatio: `${bild.w} / ${bild.h}`, background: CREAM, borderRadius: RADIUS_CARD }}
className="group relative h-[21rem] w-auto shrink-0 overflow-hidden sm:h-[24rem] lg:h-[26rem]"
>
<Foto
bild={bild}
className="transition-transform duration-[1100ms] ease-[cubic-bezier(0.44,0,0.56,1)] group-hover:scale-[1.06]"
/>
<span className="sr-only">Enlarge: {bild.alt}</span>
</button>
);
return (
<section id="work" className="py-20 lg:py-32" style={{ background: "#ffffff" }}>
<div className="mx-auto max-w-[1240px] px-5 sm:px-8">
<Ueberschrift
kicker="Recent work"
KickerIcon={Images}
title="Jobs around the valley"
intro="Photographs from work we have done for homeowners, businesses and the council."
/>
</div>
<Laufband className="mt-12 px-5 sm:px-8 lg:mt-14" tempo={22}>
{GALERIE.map(rahmen)}
</Laufband>
<AnimatePresence>
{gross ? (
<motion.div
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
style={{ background: "rgba(6,29,19,0.85)" }}
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={reduce ? undefined : { opacity: 0 }}
transition={{ duration: 0.24, ease: EASE }}
onClick={() => setGross(null)}
role="dialog"
aria-modal="true"
aria-label="Enlarged photograph"
>
<button
type="button"
onClick={() => setGross(null)}
aria-label="Close"
className="absolute right-4 top-4 flex h-11 w-11 items-center justify-center transition-colors duration-300"
style={{ background: "rgba(6,29,19,0.7)", color: CREAM, borderRadius: RADIUS_BTN }}
>
<X className="h-5 w-5" strokeWidth={1.6} />
</button>
<motion.img
src={gross.src}
alt={gross.alt}
className="max-h-[92vh] w-auto max-w-[min(1100px,94vw)] object-contain"
style={{ borderRadius: RADIUS_CARD }}
initial={reduce ? false : { opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? undefined : { opacity: 0, scale: 0.96 }}
transition={{ duration: 0.3, ease: SETTLE }}
onClick={(e) => e.stopPropagation()}
/>
</motion.div>
) : null}
</AnimatePresence>
</section>
);
}
/* ------------------------------------------------------------------- about */
/** The final value is what stands in the markup; it counts up on arrival. */
function Zahl({
value,
suffix = "",
label,
delay,
}: {
value: number;
suffix?: string;
label: string;
delay: number;
}) {
const reduce = useReducedMotion();
const ref = useRef<HTMLLIElement>(null);
const sichtbar = useInView(ref, { once: true, amount: 0.6 });
const [stand, setStand] = useState(reduce ? value : 0);
useEffect(() => {
if (!sichtbar || reduce) return;
let bild = 0;
const dauer = 1100;
const start = performance.now() + delay * 1000;
const lauf = (jetzt: number) => {
const t = Math.max(0, Math.min(1, (jetzt - start) / dauer));
setStand(Math.round(value * (1 - Math.pow(1 - t, 3))));
if (t < 1) bild = requestAnimationFrame(lauf);
};
bild = requestAnimationFrame(lauf);
return () => cancelAnimationFrame(bild);
}, [sichtbar, reduce, value, delay]);
return (
<li ref={ref}>
<p
className="text-[1.85rem] font-semibold leading-none sm:text-[2.4rem]"
style={{ ...display, color: FOREST }}
>
{stand}
{suffix}
</p>
<p
className="mt-2 text-[0.82rem] leading-[1.35] sm:text-[0.95rem] sm:leading-[1.4]"
style={{ color: MUTED }}
>
{label}
</p>
</li>
);
}
function Ueberuns() {
return (
<Abschnitt ton="cream">
<Ueberschrift kicker="About us" KickerIcon={Users} grund="grey" title={UEBER.heading} />
<div className="mt-10 grid gap-10 lg:mt-14 lg:grid-cols-2 lg:gap-16">
<div className="grid gap-8 self-start">
<Medium bild={UEBER.bild} className="aspect-[4/3]" />
<ul className="grid grid-cols-3 gap-x-4">
{UEBER.stats.map((st, i) => (
<Zahl
key={st.label}
value={st.value}
{...(st.suffix ? { suffix: st.suffix } : {})}
label={st.label}
delay={0.2 + i * 0.11}
/>
))}
</ul>
</div>
<div className="self-start">
<div className="grid gap-5">
{UEBER.body.map((p, i) => (
<Reveal key={p.slice(0, 24)} delay={0.14 + i * 0.1}>
<p className="text-[1.05rem] leading-[1.6] lg:text-lg" style={{ color: MUTED }}>
{p}
</p>
</Reveal>
))}
</div>
<Reveal delay={0.32}>
<div className="mt-9">
<p
className="text-[0.72rem] font-semibold uppercase tracking-[0.16em]"
style={{ color: MUTED }}
>
Training and certification
</p>
<ul className="mt-3 flex flex-wrap gap-2">
{UEBER.certifications.map((c) => (
<li
key={c}
className="inline-flex items-center gap-2 px-3.5 py-2 text-[0.9rem]"
style={{ background: "#ffffff", color: INK_2, borderRadius: RADIUS_BTN }}
>
<Award
className="h-4 w-4 shrink-0"
strokeWidth={1.4}
style={{ color: FOREST_2 }}
/>
{c}
</li>
))}
</ul>
</div>
</Reveal>
<Reveal delay={0.4}>
<div className="mt-9 flex flex-wrap items-center gap-x-7 gap-y-4">
<Knopf href="#contact" ton="forest" Icon={ArrowRight}>
More about us
</Knopf>
<a
href="#work"
className="inline-flex items-center gap-2 text-[0.95rem] font-semibold transition-colors duration-300 hover:opacity-75"
style={{ color: FOREST_2 }}
>
<Images className="h-[1.1rem] w-[1.1rem]" strokeWidth={1.5} />
See recent work
</a>
<a
href="#contact"
className="inline-flex items-center gap-2 text-[0.95rem] font-semibold transition-colors duration-300 hover:opacity-75"
style={{ color: FOREST_2 }}
>
<Instagram className="h-[1.1rem] w-[1.1rem]" strokeWidth={1.5} />
Follow us
</a>
</div>
</Reveal>
</div>
</div>
</Abschnitt>
);
}
/* ----------------------------------------------------------------- reviews */
function Stimmen() {
return (
<section className="py-20 lg:py-32" style={{ background: "#ffffff" }}>
<div className="mx-auto max-w-[1240px] px-5 sm:px-8">
<div className="grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end lg:gap-16">
<Ueberschrift
kicker="Reviews"
KickerIcon={Star}
title="What customers say"
intro="Reviews from homeowners and businesses across Ashcombe, Barrowdale and Hollowmere."
/>
<Reveal delay={0.2}>
<Knopf href="#contact" ton="onLight" Icon={Star}>
Leave a review
</Knopf>
</Reveal>
</div>
</div>
<Laufband className="mt-12 px-5 sm:px-8 lg:mt-14" tempo={18}>
{STIMMEN.map((r) => (
<figure
key={r.name}
className="flex h-[15.5rem] w-[19rem] shrink-0 flex-col justify-between p-7 sm:h-[17rem] sm:w-[22rem] sm:p-8"
style={{ background: CREAM, borderRadius: RADIUS_CARD }}
>
<blockquote
className="text-[1.15rem] font-semibold leading-[1.35] tracking-[-0.01em] sm:text-[1.3rem]"
style={{ ...display, color: INK }}
>
{r.quote}
</blockquote>
<figcaption className="flex items-center gap-3.5">
{/* An initial, not a portrait: a stock face beside a real person's
words is a second invention on top of the first. */}
<span
aria-hidden="true"
className="flex h-11 w-11 shrink-0 items-center justify-center text-[1.05rem] font-semibold"
style={{
...display,
background: "#ffffff",
color: FOREST_2,
borderRadius: RADIUS_BTN,
}}
>
{r.name.trim().charAt(0)}
</span>
<span className="flex flex-col">
<span className="text-[0.9rem] font-semibold" style={{ color: INK }}>
{r.name}
</span>
<span className="text-[0.82rem]" style={{ color: MUTED }}>
{r.source}
</span>
</span>
</figcaption>
</figure>
))}
</Laufband>
</section>
);
}
/* --------------------------------------------------------------------- faq */
function Fragen() {
const reduce = useReducedMotion();
const [offen, setOffen] = useState(0);
const basis = useId();
return (
<Abschnitt ton="cream" id="faq">
<div className="grid gap-10 lg:grid-cols-[0.8fr_1.2fr] lg:gap-16">
<div className="lg:sticky lg:top-24 lg:self-start">
<Ueberschrift
kicker="FAQ"
KickerIcon={MessageSquare}
grund="grey"
title="Common questions"
/>
<Reveal delay={0.2}>
<div className="mt-2 lg:mt-9">
<Knopf href="#contact" Icon={FileText} className="w-full sm:w-fit">
Request a free quote
</Knopf>
</div>
</Reveal>
</div>
<div className="grid gap-3">
{FRAGEN.map((item, i) => {
const auf = offen === i;
return (
<Reveal key={item.q} delay={i * 0.07}>
<div style={{ background: "#ffffff", borderRadius: RADIUS_CARD }}>
<h3>
<button
type="button"
onClick={() => setOffen(auf ? -1 : i)}
aria-expanded={auf}
aria-controls={`${basis}-panel-${i}`}
className="group flex w-full items-center justify-between gap-6 px-7 py-6 text-left text-[1.12rem] font-semibold transition-colors duration-300"
style={{ ...display, color: auf ? FOREST_2 : INK }}
>
{item.q}
{/* A plus that turns into a cross. */}
<motion.span
aria-hidden="true"
className="relative h-4 w-4 shrink-0"
style={{ color: FOREST_2 }}
animate={{ rotate: auf ? 45 : 0 }}
transition={{ duration: reduce ? 0 : 0.3, ease: [0.22, 1, 0.36, 1] }}
>
<span className="absolute left-0 top-1/2 h-px w-full -translate-y-1/2 bg-current" />
<span className="absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-current" />
</motion.span>
</button>
</h3>
<AnimatePresence initial={false}>
{auf ? (
<motion.div
id={`${basis}-panel-${i}`}
role="region"
className="overflow-hidden"
initial={reduce ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? undefined : { height: 0, opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.42, ease: SETTLE }}
>
<p
className="px-7 pb-7 text-[1.02rem] leading-[1.65]"
style={{ color: MUTED }}
>
{item.a}
</p>
</motion.div>
) : null}
</AnimatePresence>
</div>
</Reveal>
);
})}
</div>
</div>
</Abschnitt>
);
}
/* ----------------------------------------------------------------- contact */
/**
* The quote request, kept to what a tree job actually needs: who you are, how to
* reach you, where the tree is. No budget bands, no invented reply time.
*
* Everything is native markup, so the browser validates it, autofills it and
* shows the right keyboard on a phone without a line of JavaScript.
*/
function Anfrage() {
const basis = useId();
const [gesendet, setGesendet] = useState(false);
const [thema, setThema] = useState(ANFRAGE_THEMEN[0]?.label ?? "");
const feld =
"w-full px-4 py-3 text-[1rem] focus:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2";
const feldStil: CSSProperties = {
background: "#ffffff",
color: INK,
borderRadius: RADIUS_BTN,
outlineColor: FOREST_2,
};
const beschriftung = "text-[0.85rem] font-semibold";
const senden = useCallback((e: React.FormEvent) => {
e.preventDefault();
setGesendet(true);
}, []);
return (
<Abschnitt ton="white" id="contact" className="pb-10 lg:pb-12">
<div className="grid gap-8 lg:grid-cols-[0.85fr_1.15fr] lg:gap-20">
<div>
<Ueberschrift
kicker="Contact"
KickerIcon={Mail}
title="Request a free quote"
intro="Tell us roughly where the tree is and what you need doing. We come out, look at it and give you a fixed price before the work starts."
/>
<Reveal delay={0.22}>
<ul className="mt-7 grid gap-3 text-[0.98rem] lg:mt-9">
<li>
<a
href={FIRMA.phoneHref}
className="inline-flex items-center gap-2.5 font-semibold transition-colors duration-300 hover:opacity-75"
style={{ color: FOREST_2 }}
>
<Phone className="h-[1.15rem] w-[1.15rem]" strokeWidth={1.5} />
{FIRMA.phone}
</a>
</li>
<li>
<a
href={FIRMA.emailHref}
className="inline-flex items-center gap-2.5 font-semibold transition-colors duration-300 hover:opacity-75"
style={{ color: FOREST_2 }}
>
<Mail className="h-[1.15rem] w-[1.15rem]" strokeWidth={1.5} />
{FIRMA.email}
</a>
</li>
<li className="inline-flex items-center gap-2.5" style={{ color: MUTED }}>
<MapPin
className="h-[1.15rem] w-[1.15rem]"
strokeWidth={1.5}
style={{ color: FOREST_2 }}
/>
{FIRMA.base}
</li>
</ul>
</Reveal>
</div>
<Reveal delay={0.12}>
<form
onSubmit={senden}
className="grid gap-4 p-6 sm:gap-5 sm:p-9"
style={{ background: CREAM, borderRadius: RADIUS_CARD }}
>
<div className="grid gap-4 sm:grid-cols-2 sm:gap-5">
<div className="grid gap-2">
<label className={beschriftung} style={{ color: INK_2 }} htmlFor={`${basis}-name`}>
Name <span style={{ color: FOREST_2 }}>*</span>
</label>
<input
id={`${basis}-name`}
name="name"
type="text"
required
autoComplete="name"
className={feld}
style={feldStil}
/>
</div>
<div className="grid gap-2">
<label className={beschriftung} style={{ color: INK_2 }} htmlFor={`${basis}-phone`}>
Phone number <span style={{ color: FOREST_2 }}>*</span>
</label>
<input
id={`${basis}-phone`}
name="phone"
type="tel"
required
autoComplete="tel"
inputMode="tel"
className={feld}
style={feldStil}
/>
</div>
</div>
<div className="grid gap-2">
<label className={beschriftung} style={{ color: INK_2 }} htmlFor={`${basis}-address`}>
Address of the tree <span style={{ color: FOREST_2 }}>*</span>{" "}
<span className="font-normal" style={{ color: MUTED }}>
(street and town is enough)
</span>
</label>
<input
id={`${basis}-address`}
name="address"
type="text"
required
autoComplete="street-address"
className={feld}
style={feldStil}
/>
</div>
<fieldset className="grid gap-3">
<legend className={`${beschriftung} mb-1`} style={{ color: INK_2 }}>
What do you need?
</legend>
<div className="flex flex-wrap gap-2">
{ANFRAGE_THEMEN.map((option) => {
const aktiv = thema === option.label;
return (
<button
key={option.label}
type="button"
onClick={() => setThema(option.label)}
aria-pressed={aktiv}
className="inline-flex items-center gap-2 px-4 py-2.5 text-[0.92rem] transition-colors duration-300"
style={{
background: aktiv ? FOREST : "#ffffff",
color: aktiv ? CREAM : INK_2,
borderRadius: RADIUS_BTN,
}}
>
<option.Icon className="h-[1.15rem] w-[1.15rem]" strokeWidth={1.5} />
{option.label}
</button>
);
})}
</div>
</fieldset>
<div className="grid gap-2">
<label className={beschriftung} style={{ color: INK_2 }} htmlFor={`${basis}-message`}>
What needs doing?{" "}
<span className="font-normal" style={{ color: MUTED }}>
(optional)
</span>
</label>
<textarea
id={`${basis}-message`}
name="message"
rows={3}
className={`${feld} resize-none`}
style={feldStil}
/>
</div>
<div className="grid gap-3">
<KnopfEcht type="submit" Icon={Send} className="w-full sm:w-fit">
Send enquiry
</KnopfEcht>
<AnimatePresence>
{gesendet ? (
<motion.p
role="status"
className="px-5 py-4 text-[0.9rem] leading-[1.55]"
style={{ background: "#ffffff", color: FOREST_2, borderRadius: RADIUS_CARD }}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.32, ease: SETTLE }}
>
This form is part of the design and does not send yet. Connect your own address
and it works as it stands.
</motion.p>
) : null}
</AnimatePresence>
<p className="text-[0.85rem]" style={{ color: MUTED }}>
Fields marked * are required. We usually come back the same day.
</p>
</div>
</form>
</Reveal>
</div>
</Abschnitt>
);
}
/* --------------------------------------------------------------- final cta */
function Abschluss() {
return (
<section className="pb-6 lg:pb-8" style={{ background: "#ffffff" }}>
<div className="mx-auto max-w-[1240px] px-5 sm:px-8">
<Reveal
className="px-7 py-14 sm:px-14 sm:py-16 lg:px-20 lg:py-20"
style={{ background: FOREST, color: CREAM, borderRadius: RADIUS_CARD }}
>
<div className="max-w-2xl">
<h2
className="text-[2rem] font-semibold leading-[1.1] tracking-[-0.02em] sm:text-[2.6rem]"
style={display}
>
Got a tree you are unsure about?
</h2>
<p className="mt-6 text-lg leading-[1.6]" style={{ color: "rgba(248,248,248,0.7)" }}>
Call us or send the form and we will come out, take a look and give you a fixed price
before anything happens.
</p>
<div className="mt-10 flex flex-col gap-3 sm:flex-row">
<Knopf href="#contact" Icon={FileText}>
Request a free quote
</Knopf>
<Knopf href={FIRMA.phoneHref} ton="onDark" Icon={Phone}>
Call {FIRMA.phone}
</Knopf>
</div>
</div>
{/* The same four promises the page opened with, restated at the point
the decision is being made. Nothing new is claimed. */}
<ul className="mt-12 flex flex-wrap gap-x-8 gap-y-3.5 lg:mt-14">
{VERSPRECHEN.map((t) => (
<li key={t.label} className="flex items-center gap-2.5 text-[0.98rem]">
<t.Icon
className="h-[1.15rem] w-[1.15rem] shrink-0"
strokeWidth={1.4}
style={{ color: LIME }}
/>
{t.label}
</li>
))}
</ul>
</Reveal>
</div>
</section>
);
}
/* ------------------------------------------------------------------ footer */
/**
* A white card floating on a full-bleed photo of a finished job. The house rules
* forbid gradients, so text cannot sit on the picture behind a scrim; a solid
* surface gives the contrast instead. The photo is masked away toward the top
* rather than cropped, so it emerges out of the section above it instead of
* starting on a hard horizontal edge. A mask is not an overlay: nothing is
* painted over the picture, part of it simply is not drawn.
*/
function Fuss() {
const maske =
"linear-gradient(180deg, transparent, rgb(0 0 0 / 0.06) 45px, rgb(0 0 0 / 0.28) 95px, rgb(0 0 0 / 0.62) 145px, rgb(0 0 0 / 0.9) 190px, #000 230px)";
const spalte = (titel: string, eintraege: string[]) => (
<div>
<h2
className="text-sm font-semibold uppercase tracking-[0.16em]"
style={{ ...display, color: MUTED }}
>
{titel}
</h2>
<ul className="mt-5 grid gap-3 text-[1rem]">
{eintraege.map((l) => (
<li key={l}>
<a
href="#contact"
className="group inline-flex items-center gap-1 transition-colors duration-300"
style={{ color: INK_2 }}
>
{l}
<ArrowRight
className="h-3.5 w-3.5 -translate-x-1 opacity-0 transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
strokeWidth={1.6}
style={{ color: FOREST_2 }}
/>
</a>
</li>
))}
</ul>
</div>
);
return (
<footer className="relative isolate overflow-hidden" style={{ background: "#ffffff" }}>
<div className="absolute inset-0 -z-10" style={{ maskImage: maske, WebkitMaskImage: maske }}>
<Foto bild={FOOTER_BILD} />
</div>
<div className="mx-auto max-w-[1240px] px-5 pb-24 pt-36 sm:px-8 sm:pb-10 lg:pb-12 lg:pt-48">
<Reveal>
<div
className="px-6 py-12 sm:px-14 sm:py-16"
style={{ background: "#ffffff", borderRadius: RADIUS_CARD }}
>
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-[1.35fr_0.8fr_0.8fr_0.9fr_1.05fr] lg:gap-10">
<div className="sm:col-span-2 lg:col-span-1">
<a
href="#top"
className="inline-block"
aria-label={`${FIRMA.name}, back to the top`}
>
<Logo />
</a>
<p className="mt-6 max-w-sm text-[1rem] leading-[1.6]" style={{ color: MUTED }}>
Tree felling, stump grinding and crown care across Ashcombe, Barrowdale and
Hollowmere. Based just outside Ashcombe.
</p>
<ul className="mt-7 flex flex-wrap gap-x-5 gap-y-2.5">
{VERSPRECHEN.map((t) => (
<li
key={t.label}
className="flex items-center gap-2 text-sm"
style={{ color: FOREST_2 }}
>
<t.Icon className="h-4 w-4" strokeWidth={1.4} />
{t.label}
</li>
))}
</ul>
</div>
{spalte("Services", ["Tree felling", "Stump grinding", "Pruning"])}
{spalte("Areas", ["Ashcombe", "Barrowdale", "Hollowmere"])}
{spalte("Company", ["About", "Recent work", "Contact"])}
<div>
<h2
className="text-sm font-semibold uppercase tracking-[0.16em]"
style={{ ...display, color: MUTED }}
>
Contact
</h2>
<ul className="mt-5 grid gap-3.5 text-[1rem]">
<li>
<a
href={FIRMA.phoneHref}
className="inline-flex items-center gap-2.5 font-semibold transition-colors duration-300"
style={{ color: INK }}
>
<Phone
className="h-[1.1rem] w-[1.1rem]"
strokeWidth={1.5}
style={{ color: FOREST_2 }}
/>
{FIRMA.phone}
</a>
</li>
<li>
<a
href={FIRMA.emailHref}
className="inline-flex items-center gap-2.5 font-semibold transition-colors duration-300"
style={{ color: INK }}
>
<Mail
className="h-[1.1rem] w-[1.1rem]"
strokeWidth={1.5}
style={{ color: FOREST_2 }}
/>
{FIRMA.email}
</a>
</li>
<li className="flex items-start gap-2.5" style={{ color: MUTED }}>
<MapPin
className="mt-0.5 h-[1.1rem] w-[1.1rem] shrink-0"
strokeWidth={1.5}
style={{ color: FOREST_2 }}
/>
{FIRMA.base}
</li>
<li>
<a
href="#contact"
className="inline-flex items-center gap-2.5 font-semibold transition-colors duration-300"
style={{ color: INK }}
>
<Instagram
className="h-[1.1rem] w-[1.1rem]"
strokeWidth={1.5}
style={{ color: FOREST_2 }}
/>
Instagram
</a>
</li>
</ul>
</div>
</div>
<div
className="mt-12 flex flex-col gap-4 text-sm sm:flex-row sm:items-center sm:justify-between"
style={{ color: MUTED }}
>
<p>© 2026 {FIRMA.name}</p>
<ul className="flex flex-wrap gap-6">
{["Privacy", "Cookies"].map((l) => (
<li key={l}>
<a href="#contact" className="transition-colors duration-300">
{l}
</a>
</li>
))}
</ul>
</div>
</div>
</Reveal>
</div>
</footer>
);
}
/* ---------------------------------------------------------- mobile actions */
/**
* The phone-only call and quote bar. Two buttons floating over the page with no
* strip behind them: a solid band took a slice of a small screen permanently and
* read as a second footer.
*
* It keeps out of the way. It stays away over the hero, where both buttons are
* already on screen, and it drops out of sight while you scroll down, because
* reading is not the moment to interrupt. It comes back as soon as you scroll up.
*/
function Handleiste() {
const reduce = useReducedMotion();
const { scrollY } = useScroll();
const [zeigen, setZeigen] = useState(false);
useEffect(() => {
let letzte = scrollY.get();
return scrollY.on("change", (jetzt) => {
const runter = jetzt > letzte;
letzte = jetzt;
setZeigen(jetzt > 700 && !runter);
});
}, [scrollY]);
return (
<AnimatePresence>
{zeigen ? (
<motion.div
className="pointer-events-none fixed inset-x-0 bottom-0 z-40 grid grid-cols-2 gap-2.5 px-3.5 pb-2.5 lg:hidden"
initial={reduce ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: 20 }}
transition={{ duration: 0.32, ease: SETTLE }}
>
<Knopf
href={FIRMA.phoneHref}
ton="forest"
Icon={Phone}
className="pointer-events-auto px-3 text-[0.92rem]"
>
Call
</Knopf>
<Knopf
href="#contact"
Icon={FileText}
className="pointer-events-auto px-3 text-[0.92rem]"
>
Free quote
</Knopf>
</motion.div>
) : null}
</AnimatePresence>
);
}
/* -------------------------------------------------------------------- page */
export function NorthmoorTreeCarePage() {
return (
<div id="top" style={{ background: CREAM, fontFamily: BODY, color: INK }}>
<Kopf />
<Hero />
<Versprechen />
<Leistungen />
<Sicherheit />
<Ablauf />
<Gebiete />
<Galerie />
<Ueberuns />
<Stimmen />
<Fragen />
<Anfrage />
<Abschluss />
<Fuss />
<Handleiste />
</div>
);
}
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