Templates/Tierarztpraxis
Eine ruhige Full-Page-Vorlage für Tierarztpraxen, Zahnärzte, Physiotherapie und jede Praxis, die einen Termin verkauft statt eines Produkts. Der Hero trägt zwei Ausschnitte derselben Szene, breit auf dem Desktop, hoch auf dem Telefon, und zieht etwas langsamer als die Seite. Jede Überschrift wechselt mitten im Satz die Stimme, von der Grotesk in eine kursive Serife, und trägt damit die ganze Seite ohne zweite Farbe. Die Leistungen stehen in einem vierteiligen Raster mit Haarlinien, das Praxisbild zieht gegen seinen Rahmen, und die Bildstrecke setzt ihre Kacheln auf verschiedene Höhen, wird auf dem Telefon aber zum schnappenden Streifen. Durchgehend neutrale Grautöne mit einem blauen Akzent auf Kickern und Icons.
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,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import {
AnimatePresence,
motion,
useInView,
useReducedMotion,
useScroll,
useTransform,
} from "framer-motion";
import {
Activity,
ArrowUp,
CalendarCheck,
CalendarClock,
ChevronLeft,
ChevronRight,
Clock,
Info,
Mail,
MapPin,
Menu,
Microscope,
PawPrint,
Phone,
Rabbit,
Scissors,
Sparkles,
X,
} from "lucide-react";
/*
* Marlowe Animal Clinic: a full page for a small animal veterinary practice.
*
* A quiet, editorial page. Neutral greys carry everything, one blue accent
* marks the section kickers and the icons, and an italic serif runs against the
* sans in every heading so a two word phrase can change voice mid sentence.
*
* House rules here: no shadows, no decorative rules, no coloured overlays on
* the photographs. Sections separate by a change of surface. The one dark scrim
* on the hero is there to make white type legible, nothing else.
*/
const ACCENT = "#2f5ea8";
const INK = "#18181b";
const INK_2 = "#3f3f46";
const MUTED = "#52525b";
const FAINT = "#a1a1aa";
const LINE = "#e4e4e7";
const SURFACE = "#f8f8f8";
const SURFACE_2 = "#f4f4f4";
const SANS = '"Albert Sans", ui-sans-serif, system-ui, sans-serif';
const SERIF = '"Instrument Serif", ui-serif, Georgia, serif';
const serif: CSSProperties = { fontFamily: SERIF, fontStyle: "italic" };
const SETTLE = [0.16, 1, 0.3, 1] as const;
const RADIUS = 8;
const RADIUS_CARD = 12;
/* ------------------------------------------------------------------ content */
const KLINIK = {
name: "Marlowe Animal Clinic",
wordmark: { top: "Marlowe", bottom: "Animal Clinic" },
street: "18 Linden Row",
town: "Marlowe",
postcode: "MR4 2QE",
phone: "01632 770 214",
phoneHref: "tel:+441632770214",
email: "hello@marloweanimalclinic.com",
emailHref: "mailto:hello@marloweanimalclinic.com",
};
const NAV = [
{ label: "Services", href: "#services" },
{ label: "The clinic", href: "#clinic" },
{ label: "Impressions", href: "#impressions" },
{ label: "Contact", href: "#contact" },
];
const HERO = {
chip: "Small animal clinic in Marlowe",
line1: "Veterinary care with time",
line2: "and a careful eye",
intro:
"We look after dogs and cats above all, with modern diagnostics, appointments that hold, and someone who knows your animal by name.",
};
const LEISTUNGEN = [
{
title: "Diagnostics",
body: "Our own laboratory, ultrasound and digital radiography, so most questions are answered in the building rather than next week.",
Icon: Microscope,
},
{
title: "Dentistry",
body: "Scaling, extractions and digital dental radiography, which is the only way to judge what is happening below the visible surface of a tooth.",
Icon: Sparkles,
},
{
title: "Surgery",
body: "Soft tissue surgery under inhalation anaesthesia, monitored before, during and after, with a nurse on the animal the whole time.",
Icon: Scissors,
},
{
title: "Small pets and wildlife",
body: "Beyond dogs and cats we see rabbits, rodents, birds and reptiles, and we take in injured wildlife brought to the door.",
Icon: Rabbit,
},
];
const FAKTEN = [
{ Icon: CalendarClock, label: "Appointments", value: "By arrangement" },
{ Icon: PawPrint, label: "Focus", value: "Dogs and cats" },
{ Icon: Activity, label: "Diagnostics", value: "Lab, ultrasound, digital X-ray" },
{ Icon: Rabbit, label: "Also seen", value: "Small pets and wildlife" },
];
type Kachel = {
src: string;
alt: string;
caption: string;
spalten: string;
format: string;
versatz: string;
};
const KACHELN: Kachel[] = [
{
src: "/templates/vet-clinic/care-dog.webp",
alt: "A vet resting both hands on a calm grey dog on the examination table.",
caption: "Hands that settle an animal",
spalten: "md:col-span-5",
format: "aspect-[3/4]",
versatz: "",
},
{
src: "/templates/vet-clinic/room-cat.webp",
alt: "A ginger cat sitting upright on a table in a bright consulting room.",
caption: "Rooms with daylight",
spalten: "md:col-span-7",
format: "aspect-[4/3]",
versatz: "md:mt-20",
},
{
src: "/templates/vet-clinic/waiting-owner.webp",
alt: "An owner sitting with her dog in a quiet waiting area.",
caption: "Arriving without a queue",
spalten: "md:col-span-7",
format: "aspect-[4/3]",
versatz: "",
},
{
src: "/templates/vet-clinic/duo-puppy-kitten.webp",
alt: "A puppy and a kitten held together in a nurse's arms.",
caption: "Small patients, long appointments",
spalten: "md:col-span-5",
format: "aspect-[3/4]",
versatz: "md:-mt-16",
},
];
const ZEITEN = [
{ days: "Mon, Tue, Fri", time: "9:00 to 12:00 and 15:00 to 18:00" },
{ days: "Wed, Thu", time: "9:00 to 12:00 and 17:00 to 19:00" },
{ days: "Sat", time: "10:00 to 12:00" },
];
/* --------------------------------------------------------------- primitives */
/** Everything arrives the same way: a short lift, a long settle, once. */
function Reveal({
children,
delay = 0,
as = "div",
className = "",
style,
}: {
children: ReactNode;
delay?: number;
as?: "div" | "li" | "figure" | "p";
className?: string;
style?: CSSProperties;
}) {
const reduce = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const sichtbar = useInView(ref, { once: true, amount: 0.2 });
const Tag = motion[as];
return (
<Tag
ref={ref as never}
className={className}
style={style}
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={sichtbar || reduce ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
transition={{ duration: reduce ? 0 : 0.8, ease: "easeOut", delay: reduce ? 0 : delay }}
>
{children}
</Tag>
);
}
/** The paw wordmark, one path per toe. Takes its colour from the parent. */
function Logo({ className = "" }: { className?: string }) {
return (
<svg
viewBox="0 0 64 64"
className={className}
fill="currentColor"
aria-hidden="true"
focusable="false"
>
<ellipse cx="16" cy="30" rx="6" ry="8.5" transform="rotate(-22 16 30)" />
<ellipse cx="48" cy="30" rx="6" ry="8.5" transform="rotate(22 48 30)" />
<ellipse cx="27" cy="18" rx="5.5" ry="8" transform="rotate(-8 27 18)" />
<ellipse cx="37" cy="18" rx="5.5" ry="8" transform="rotate(8 37 18)" />
<path d="M32 33c7.7 0 14 5.6 14 12.4 0 4.8-3.9 7.8-8.4 7.8-2.5 0-4-1.3-5.6-1.3s-3.1 1.3-5.6 1.3C21.9 53.2 18 50.2 18 45.4 18 38.6 24.3 33 32 33Z" />
</svg>
);
}
function Wortmarke({ ton = "dark", gross = false }: { ton?: "dark" | "light"; gross?: boolean }) {
return (
<span className="flex items-center gap-3">
<Logo className={gross ? "h-12 w-12 md:h-14 md:w-14" : "h-9 w-9 md:h-10 md:w-10"} />
<span className="flex flex-col items-start text-left">
<span
className={`block font-medium leading-[1.15] tracking-wide ${gross ? "text-xl md:text-2xl" : "text-[17px] md:text-[18px]"}`}
style={{ fontFamily: SANS, color: ton === "light" ? "#ffffff" : INK }}
>
{KLINIK.wordmark.top}
</span>
<span
className={`block leading-[1.15] ${gross ? "text-lg md:text-xl" : "text-[15px]"}`}
style={{ ...serif, color: ton === "light" ? "rgba(255,255,255,0.85)" : MUTED }}
>
{KLINIK.wordmark.bottom}
</span>
</span>
</span>
);
}
/**
* Section opener. A small italic kicker in the accent, then a heading whose
* closing phrase switches from the sans to the serif: the sentence changes
* voice rather than changing size.
*/
function Titel({
kicker,
sans,
kursiv,
intro,
zentriert = false,
kickerFarbe = ACCENT,
}: {
kicker: string;
sans: string;
kursiv: string;
intro?: string;
zentriert?: boolean;
kickerFarbe?: string;
}) {
return (
<div className={zentriert ? "mx-auto max-w-2xl text-center" : "max-w-2xl"}>
<Reveal
as="p"
className="text-[15px] tracking-widest md:text-[13px]"
style={{ ...serif, color: kickerFarbe }}
>
{kicker}
</Reveal>
<Reveal delay={0.12}>
<h2
className="mt-4 text-4xl font-normal leading-[1.1] md:text-5xl"
style={{ fontFamily: SANS, color: INK }}
>
{sans} <span style={serif}>{kursiv}</span>
</h2>
</Reveal>
{intro ? (
<Reveal delay={0.22}>
<p
className={`mt-6 text-[17px] leading-relaxed md:text-lg ${zentriert ? "mx-auto max-w-xl" : ""}`}
style={{ fontFamily: SANS, color: MUTED }}
>
{intro}
</p>
</Reveal>
) : null}
</div>
);
}
/* ------------------------------------------------------------------ header */
function Kopf() {
const [offen, setOffen] = useState(false);
const [hell, setHell] = useState(false);
const reduce = useReducedMotion();
const { scrollY } = useScroll();
useEffect(() => scrollY.on("change", (y) => setHell(y > 40)), [scrollY]);
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="fixed inset-x-0 top-0 z-50 transition-colors duration-300"
style={{
background: hell ? "rgba(255,255,255,0.85)" : "transparent",
backdropFilter: hell ? "blur(12px)" : "none",
borderBottom: `1px solid ${hell ? "rgba(228,228,231,0.7)" : "transparent"}`,
}}
>
<div className="flex w-full items-center justify-between px-6 py-4 md:px-10 md:py-5 lg:px-14">
{/* The paw takes currentColor, so the tone has to be set here and
not inside the lockup, otherwise the mark stays dark on the hero
while the type beside it turns white. */}
<a
href="#top"
aria-label={`${KLINIK.name}, back to the top`}
style={{ color: hell ? INK : "#ffffff" }}
>
<Wortmarke ton={hell ? "dark" : "light"} />
</a>
<nav className="hidden items-center gap-1 lg:flex">
{NAV.map((item) => (
<a
key={item.href}
href={item.href}
className="rounded-full px-3 py-2 text-[15px] font-normal transition-colors"
style={{ fontFamily: SANS, color: hell ? INK_2 : "rgba(255,255,255,0.9)" }}
>
{item.label}
</a>
))}
</nav>
<a
href="#contact"
className="hidden px-5 py-2.5 text-[15px] font-medium transition-colors lg:inline-flex"
style={{
fontFamily: SANS,
borderRadius: RADIUS,
background: hell ? INK : SURFACE,
color: hell ? "#ffffff" : INK,
}}
>
Book an appointment
</a>
<button
type="button"
onClick={() => setOffen(true)}
className="inline-flex items-center justify-center rounded-full p-2 transition-colors lg:hidden"
style={{ color: hell ? INK : "#ffffff" }}
aria-label="Open menu"
>
<Menu className="h-6 w-6" strokeWidth={1.75} />
</button>
</div>
</header>
<AnimatePresence>
{offen ? (
<motion.div
className="fixed inset-0 z-[60] lg:hidden"
style={{ background: "#09090b" }}
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={reduce ? undefined : { opacity: 0 }}
transition={{ duration: 0.32, ease: "easeOut" }}
>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between px-6 py-4">
<Wortmarke ton="light" />
<button
type="button"
onClick={() => setOffen(false)}
aria-label="Close menu"
className="inline-flex items-center justify-center rounded-full p-2"
style={{ color: "#ffffff" }}
>
<X className="h-6 w-6" strokeWidth={1.75} />
</button>
</div>
<nav className="flex flex-1 flex-col justify-center gap-1 px-6">
{NAV.map((item, i) => (
<motion.a
key={item.href}
href={item.href}
onClick={() => setOffen(false)}
className="border-b py-5 text-3xl font-normal"
style={{
fontFamily: SANS,
color: "#ffffff",
borderColor: "rgba(255,255,255,0.12)",
}}
initial={reduce ? false : { opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.5,
ease: SETTLE,
delay: reduce ? 0 : 0.08 + i * 0.06,
}}
>
{item.label}
</motion.a>
))}
</nav>
<div className="px-6 pb-10">
<a
href={KLINIK.phoneHref}
className="flex items-center justify-center gap-2 py-4 text-[15px] font-medium"
style={{
fontFamily: SANS,
background: "#ffffff",
color: INK,
borderRadius: RADIUS,
}}
>
<Phone className="h-4 w-4" strokeWidth={1.75} />
{KLINIK.phone}
</a>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
</>
);
}
/* -------------------------------------------------------------------- hero */
/**
* Two crops of the same scene rather than one picture squeezed into both
* shapes: a wide frame on a desktop, a tall one on a phone. Both drift a little
* slower than the page, which is what keeps the type from feeling stuck to the
* photograph.
*/
function Hero() {
const reduce = useReducedMotion();
const ref = useRef<HTMLElement>(null);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end start"] });
const drift = useTransform(scrollYProgress, [0, 1], reduce ? ["0%", "0%"] : ["0%", "12%"]);
const auftritt = (verzug: number) =>
reduce
? {}
: {
initial: { opacity: 0, y: 14 },
animate: { opacity: 1, y: 0 },
transition: { duration: 1, ease: SETTLE, delay: verzug },
};
return (
<section ref={ref} className="relative min-h-[100svh] w-full overflow-hidden md:min-h-screen">
<motion.div className="absolute inset-0 h-[115%]" style={{ y: drift }}>
<img
src="/templates/vet-clinic/hero-tall.webp"
alt=""
className="h-full w-full object-cover object-center md:hidden"
fetchPriority="high"
decoding="async"
/>
<img
src="/templates/vet-clinic/hero-wide.webp"
alt=""
className="hidden h-full w-full object-cover object-right md:block"
decoding="async"
/>
</motion.div>
{/* One scrim, directional, black. Enough for white type to hold and no
more: the photograph keeps its own contrast and colour. */}
<div
className="absolute inset-0 md:hidden"
style={{
background:
"linear-gradient(180deg, rgba(0,0,0,0.42) 0%, rgba(0,0,0,0.30) 45%, rgba(0,0,0,0.58) 100%)",
}}
aria-hidden
/>
<div
className="absolute inset-0 hidden md:block"
style={{
background:
"linear-gradient(90deg, rgba(0,0,0,0.52) 0%, rgba(0,0,0,0.24) 45%, rgba(0,0,0,0) 78%)",
}}
aria-hidden
/>
{/* The bar sits on whatever the photograph happens to be bright with, so
it gets a shallow ground of its own. The side scrim runs out well
before the right hand nav and cannot carry it. */}
<div
className="absolute inset-x-0 top-0 h-32"
style={{ background: "linear-gradient(180deg, rgba(0,0,0,0.42) 0%, rgba(0,0,0,0) 100%)" }}
aria-hidden
/>
<div className="relative z-10 flex min-h-[100svh] items-end justify-center px-5 pb-20 pt-28 md:min-h-screen md:items-center md:justify-start md:px-10 md:pb-32 md:pl-16 md:pt-32 lg:pl-24">
<div className="w-full max-w-4xl text-center md:max-w-xl md:text-left lg:max-w-2xl">
<motion.div
className="mx-auto inline-flex p-1 md:mx-0"
style={{ background: "rgba(255,255,255,0.12)", borderRadius: 999 }}
{...auftritt(0)}
>
<span
className="px-3.5 py-1.5 text-[13px] font-medium uppercase tracking-[0.18em] md:text-[11px]"
style={{
fontFamily: SANS,
background: "rgba(255,255,255,0.92)",
color: INK,
borderRadius: 999,
}}
>
{HERO.chip}
</span>
</motion.div>
<h1 className="mt-6 md:mt-8" style={{ color: "#ffffff" }}>
<motion.span
className="block text-[40px] font-normal leading-[1.05] tracking-tight md:text-[60px] lg:text-[72px]"
style={{ fontFamily: SANS }}
{...auftritt(0.15)}
>
{HERO.line1}
</motion.span>
<motion.span
className="mt-1 block text-[38px] leading-[1.05] tracking-tight md:text-[56px] lg:text-[68px]"
style={serif}
{...auftritt(0.3)}
>
{HERO.line2}
</motion.span>
</h1>
<motion.p
className="mx-auto mt-6 max-w-2xl text-[16px] leading-relaxed md:mx-0 md:mt-8 md:text-xl"
style={{ fontFamily: SANS, color: "rgba(255,255,255,0.85)" }}
{...auftritt(0.45)}
>
{HERO.intro}
</motion.p>
<motion.div
className="mt-8 flex flex-col items-stretch justify-center gap-3 sm:flex-row sm:items-center sm:gap-4 md:mt-10 md:justify-start"
{...auftritt(0.6)}
>
<a
href="#contact"
className="px-6 py-3 text-center text-[15px] font-medium transition-colors sm:w-auto"
style={{ fontFamily: SANS, background: SURFACE, color: INK, borderRadius: RADIUS }}
>
Book an appointment
</a>
<a
href={KLINIK.phoneHref}
className="px-6 py-3 text-center text-[15px] font-medium transition-colors sm:w-auto"
style={{
fontFamily: SANS,
background: "rgba(255,255,255,0.12)",
color: "#ffffff",
borderRadius: RADIUS,
}}
>
Out of hours
</a>
</motion.div>
</div>
</div>
</section>
);
}
/* ---------------------------------------------------------------- services */
function Leistungen() {
return (
<section id="services" className="relative py-20 md:py-36" style={{ background: "#ffffff" }}>
<div className="mx-auto max-w-6xl px-5 md:px-10">
<Titel
kicker="Services"
sans="Medicine that takes"
kursiv="its time"
intro="Every visit is a booked appointment, so there is room to look properly at what is actually needed."
/>
<div className="mt-12 grid md:mt-20 md:grid-cols-2">
{LEISTUNGEN.map(({ title, body, Icon }, i) => (
<Reveal
key={title}
delay={0.32 + i * 0.12}
// The hairlines belong to the two column grid. On a phone the
// tiles are a single stack, so a right hand rule would hang off
// the side of the screen: below md only the divider between two
// stacked tiles survives.
className="group border-b p-6 transition-colors duration-500 last:border-b-0 sm:p-8 md:border-b-0 md:p-12 md:[&:nth-child(-n+2)]:border-b md:[&:nth-child(odd)]:border-r"
style={{ borderColor: LINE }}
>
<Icon
className="h-7 w-7 transition-all duration-500 group-hover:-translate-y-0.5"
strokeWidth={1.5}
style={{ color: FAINT }}
/>
<h3 className="mt-6 text-xl font-normal" style={{ fontFamily: SANS, color: INK }}>
{title}
</h3>
<p
className="mt-4 text-[15px] leading-relaxed md:text-base"
style={{ fontFamily: SANS, color: MUTED }}
>
{body}
</p>
</Reveal>
))}
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ clinic */
function Praxis() {
const reduce = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] });
const drift = useTransform(scrollYProgress, [0, 1], reduce ? ["0%", "0%"] : ["-5%", "5%"]);
return (
<section id="clinic" className="relative py-20 md:py-36" style={{ background: SURFACE }}>
<div className="mx-auto grid max-w-6xl gap-12 px-5 md:grid-cols-2 md:items-center md:gap-20 md:px-10">
<Reveal>
<div
ref={ref}
className="relative aspect-square w-full overflow-hidden md:aspect-[5/6]"
style={{ background: LINE, borderRadius: RADIUS_CARD }}
>
<motion.img
src="/templates/vet-clinic/practice-kitten.webp"
alt="A kitten steady on the table under a nurse's hand."
className="absolute inset-0 h-[112%] w-full object-cover"
loading="lazy"
decoding="async"
style={{ y: drift }}
/>
</div>
</Reveal>
<div className="flex flex-col justify-center">
<Titel
kicker="The clinic"
sans="A quiet setting"
kursiv="for your visit"
intro="We work by appointment only, so waiting is short and your animal walks in calmer than it otherwise would. The clinic sits on a side street in the centre of town with parking directly outside."
kickerFarbe={MUTED}
/>
<dl
className="mt-8 grid grid-cols-2 gap-6 pt-8 md:mt-10 md:grid-cols-4 md:pt-10"
style={{ borderTop: `1px solid ${LINE}` }}
>
{FAKTEN.map(({ Icon, label, value }, i) => (
<Reveal key={label} delay={0.42 + i * 0.1}>
<Icon className="h-5 w-5" strokeWidth={1.5} style={{ color: ACCENT }} />
<dt
className="mt-3 text-[14px] tracking-widest md:text-[13px]"
style={{ ...serif, color: MUTED }}
>
{label}
</dt>
<dd
className="mt-2 text-base font-light md:text-lg"
style={{ fontFamily: SANS, color: INK }}
>
{value}
</dd>
</Reveal>
))}
</dl>
</div>
</div>
</section>
);
}
/* ----------------------------------------------------------------- gallery */
/**
* An editorial grid on a wide screen, where the tiles sit at different heights
* and the row reads as a spread rather than a row of equal boxes. On a phone
* the same four become a snapping strip: the dots and arrows below say where
* you are, and the strip itself is a real scroll container, so a thumb, a
* trackpad and the keyboard all move it.
*/
function Eindruecke() {
const streifenRef = useRef<HTMLDivElement>(null);
const [aktiv, setAktiv] = useState(0);
const blaettern = useCallback((richtung: number) => {
const el = streifenRef.current;
if (!el) return;
el.scrollBy({ left: richtung * el.clientWidth * 0.85, behavior: "smooth" });
}, []);
const springe = useCallback((i: number) => {
const el = streifenRef.current;
if (!el) return;
el.scrollTo({ left: i * el.clientWidth * 0.85, behavior: "smooth" });
}, []);
const beobachte = useCallback(() => {
const el = streifenRef.current;
if (!el) return;
setAktiv(Math.round(el.scrollLeft / (el.clientWidth * 0.85)));
}, []);
return (
<section id="impressions" className="relative py-20 md:py-40" style={{ background: "#ffffff" }}>
<div className="mx-auto max-w-6xl md:px-10">
<div className="px-5 md:px-0">
<Titel kicker="Impressions" sans="Calm you can" kursiv="feel" />
<Reveal delay={0.3}>
<p
className="mt-6 max-w-xl text-[16px] leading-relaxed md:text-[17px]"
style={{ ...serif, color: MUTED }}
>
Moments from an ordinary week. Quiet, bright, attentive.
</p>
</Reveal>
</div>
{/* Phone: a snapping strip */}
<div className="mt-10 md:hidden">
<div
ref={streifenRef}
onScroll={beobachte}
className="flex snap-x snap-mandatory gap-4 overflow-x-auto px-5 pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{KACHELN.map((k, i) => (
<Reveal
as="figure"
key={k.src}
delay={i * 0.08}
className="w-[85%] shrink-0 snap-start"
>
<div
className="aspect-[4/5] w-full overflow-hidden"
style={{ background: LINE, borderRadius: RADIUS_CARD }}
>
<img
src={k.src}
alt={k.alt}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
</div>
<figcaption className="mt-4 text-[15px]" style={{ ...serif, color: MUTED }}>
{k.caption}
</figcaption>
</Reveal>
))}
</div>
<div className="mt-6 flex items-center justify-between px-5">
<div className="flex gap-1.5">
{KACHELN.map((k, i) => (
<button
key={k.src}
type="button"
onClick={() => springe(i)}
aria-label={`Photograph ${i + 1}`}
className="h-1.5 rounded-full transition-all duration-300"
style={{ width: aktiv === i ? 24 : 6, background: aktiv === i ? INK : LINE }}
/>
))}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => blaettern(-1)}
aria-label="Previous photograph"
className="inline-flex h-10 w-10 items-center justify-center rounded-full transition-colors"
style={{ border: `1px solid ${LINE}`, color: INK_2 }}
>
<ChevronLeft className="h-4 w-4" strokeWidth={1.75} />
</button>
<button
type="button"
onClick={() => blaettern(1)}
aria-label="Next photograph"
className="inline-flex h-10 w-10 items-center justify-center rounded-full transition-colors"
style={{ border: `1px solid ${LINE}`, color: INK_2 }}
>
<ChevronRight className="h-4 w-4" strokeWidth={1.75} />
</button>
</div>
</div>
</div>
{/* Wide screen: the spread */}
<div className="mt-12 hidden gap-5 md:mt-24 md:grid md:grid-cols-12 md:gap-8">
{KACHELN.map((k, i) => (
<Reveal
as="figure"
key={k.src}
delay={i * 0.12}
className={`${k.spalten} ${k.versatz}`}
>
<div
className={`group block w-full overflow-hidden ${k.format}`}
style={{ background: LINE, borderRadius: RADIUS_CARD }}
>
<img
src={k.src}
alt={k.alt}
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
loading="lazy"
decoding="async"
/>
</div>
<figcaption className="mt-5 text-[15px]" style={{ ...serif, color: MUTED }}>
{k.caption}
</figcaption>
</Reveal>
))}
</div>
</div>
</section>
);
}
/* --------------------------------------------------------------------- cta */
function Abschluss() {
return (
<section id="contact" className="relative py-20 md:py-36" style={{ background: SURFACE_2 }}>
<div className="mx-auto max-w-4xl px-5 md:px-10">
<Titel
kicker="Book an appointment"
sans="We plan your visit"
kursiv="without a rush"
intro="Write a line about what is going on. We come back with a time that suits and leave enough room in the diary before it starts."
zentriert
/>
<Reveal delay={0.32}>
<div className="mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row sm:gap-4">
<a
href={KLINIK.emailHref}
className="inline-flex w-full items-center justify-center gap-2 px-7 py-3.5 text-sm font-medium transition-transform duration-500 hover:-translate-y-0.5 sm:w-auto"
style={{ fontFamily: SANS, background: INK, color: "#ffffff", borderRadius: RADIUS }}
>
<CalendarCheck className="h-4 w-4" strokeWidth={1.75} />
Book an appointment
</a>
<a
href={KLINIK.phoneHref}
className="inline-flex w-full items-center justify-center gap-2 px-7 py-3.5 text-sm font-medium transition-transform duration-500 hover:-translate-y-0.5 sm:w-auto"
style={{
fontFamily: SANS,
background: "#ffffff",
color: INK,
border: `1px solid ${LINE}`,
borderRadius: RADIUS,
}}
>
<Phone className="h-4 w-4" strokeWidth={1.75} />
Out of hours
</a>
</div>
</Reveal>
</div>
</section>
);
}
/* ------------------------------------------------------------------ footer */
function Fuss() {
return (
<footer className="pb-24 pt-16 md:pb-12 md:pt-24" style={{ background: "#ffffff", color: INK }}>
<div className="mx-auto max-w-6xl px-5 md:px-10">
<div
className="grid gap-10 pt-12 md:grid-cols-12 md:gap-12 md:pt-16"
style={{ borderTop: `1px solid ${LINE}` }}
>
<div className="md:col-span-4">
<a href="#top" aria-label={`${KLINIK.name}, back to the top`} style={{ color: ACCENT }}>
<Wortmarke gross />
</a>
<address
className="mt-6 flex items-start gap-2 not-italic text-sm leading-relaxed"
style={{ fontFamily: SANS, color: MUTED }}
>
<MapPin
className="mt-0.5 h-4 w-4 shrink-0"
strokeWidth={1.5}
style={{ color: FAINT }}
/>
<span>
{KLINIK.street}
<br />
{KLINIK.postcode} {KLINIK.town}
</span>
</address>
</div>
<div className="md:col-span-2">
<p className="text-[13px] tracking-widest" style={{ ...serif, color: MUTED }}>
Navigation
</p>
<ul className="mt-5 space-y-3">
{NAV.map((item) => (
<li key={item.href}>
<a
href={item.href}
className="text-[15px] transition-colors"
style={{ fontFamily: SANS, color: INK_2 }}
>
{item.label}
</a>
</li>
))}
</ul>
</div>
<div className="md:col-span-3">
<p className="text-[13px] tracking-widest" style={{ ...serif, color: MUTED }}>
Contact
</p>
<ul className="mt-5 space-y-3 text-sm" style={{ fontFamily: SANS, color: INK_2 }}>
<li className="flex items-start gap-2">
<Mail
className="mt-0.5 h-4 w-4 shrink-0"
strokeWidth={1.5}
style={{ color: FAINT }}
/>
<a href={KLINIK.emailHref} className="transition-colors">
{KLINIK.email}
</a>
</li>
<li className="flex items-start gap-2">
<Phone
className="mt-0.5 h-4 w-4 shrink-0"
strokeWidth={1.5}
style={{ color: FAINT }}
/>
<a href={KLINIK.phoneHref} className="transition-colors">
{KLINIK.phone}
</a>
</li>
</ul>
</div>
<div className="md:col-span-3">
<p className="text-[13px] tracking-widest" style={{ ...serif, color: MUTED }}>
Opening hours
</p>
<ul className="mt-5 space-y-3 text-sm" style={{ fontFamily: SANS, color: INK_2 }}>
{ZEITEN.map((row) => (
<li key={row.days} className="flex items-start gap-2">
<Clock
className="mt-0.5 h-4 w-4 shrink-0"
strokeWidth={1.5}
style={{ color: FAINT }}
/>
<span>
<span style={{ color: INK }}>{row.days}</span>
<br />
<span style={{ color: MUTED }}>{row.time}</span>
</span>
</li>
))}
<li className="flex items-start gap-2 pt-2" style={{ color: MUTED }}>
<Info
className="mt-0.5 h-4 w-4 shrink-0"
strokeWidth={1.5}
style={{ color: FAINT }}
/>
<span style={serif}>Appointments by arrangement within opening hours.</span>
</li>
</ul>
</div>
</div>
<div
className="mt-16 flex flex-col items-start justify-between gap-4 pt-8 text-[13px] md:flex-row md:items-center"
style={{ borderTop: `1px solid ${LINE}`, color: MUTED, fontFamily: SANS }}
>
<p>© 2026 {KLINIK.name}</p>
<nav className="flex flex-wrap items-center gap-x-6 gap-y-2">
<a href="#contact" className="transition-colors">
Legal notice
</a>
<a href="#contact" className="transition-colors">
Privacy
</a>
</nav>
</div>
</div>
</footer>
);
}
/* ---------------------------------------------------------- mobile actions */
/**
* Two small circles on a phone, nothing behind them. The call button appears
* once the hero has passed, the back to top once there is something to go back
* up through, and neither takes a permanent strip off a small screen.
*/
function Handleiste() {
const [zeigeOben, setZeigeOben] = useState(false);
const [zeigeAnruf, setZeigeAnruf] = useState(false);
const { scrollY } = useScroll();
const reduce = useReducedMotion();
useEffect(
() =>
scrollY.on("change", (y) => {
setZeigeOben(y > 600);
setZeigeAnruf(y > window.innerHeight * 0.6);
}),
[scrollY],
);
const nachOben = () => window.scrollTo({ top: 0, behavior: reduce ? "auto" : "smooth" });
const knopf: CSSProperties = {
background: "rgba(255,255,255,0.95)",
border: `1px solid ${LINE}`,
color: INK,
backdropFilter: "blur(12px)",
};
return (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex items-end justify-between gap-3 px-4 pb-4 lg:hidden">
<motion.a
href={KLINIK.phoneHref}
aria-label="Call the clinic"
className="pointer-events-auto inline-flex h-11 w-11 items-center justify-center rounded-full"
style={knopf}
animate={{ opacity: zeigeAnruf ? 1 : 0, y: zeigeAnruf ? 0 : 8 }}
transition={{ duration: reduce ? 0 : 0.3, ease: "easeOut" }}
>
<Phone className="h-[18px] w-[18px]" strokeWidth={1.75} />
</motion.a>
<motion.button
type="button"
onClick={nachOben}
aria-label="Back to top"
className="pointer-events-auto inline-flex h-11 w-11 items-center justify-center rounded-full"
style={knopf}
animate={{ opacity: zeigeOben ? 1 : 0, y: zeigeOben ? 0 : 8 }}
transition={{ duration: reduce ? 0 : 0.3, ease: "easeOut" }}
>
<ArrowUp className="h-[18px] w-[18px]" strokeWidth={1.75} />
</motion.button>
</div>
);
}
/* -------------------------------------------------------------------- page */
export function MarloweAnimalClinicPage() {
return (
<div id="top" style={{ background: "#ffffff", fontFamily: SANS, color: INK }}>
<Kopf />
<Hero />
<Leistungen />
<Praxis />
<Eindruecke />
<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