Zum Inhalt springen
Alle Templates

Templates/Personal Training zu Hause

Eine Seite ganz ohne Farbakzent. Weiß, ein sehr helles Grau und Fast-Schwarz tragen die ganze Seite; Hierarchie entsteht über Flächenwechsel, Größe und Abstand. Dagegen hält eine kursive Serifenschrift die zwei ruhigen Momente, den Haltungssatz und die Zeile über dem Filmband, so wechseln diese Sätze die Stimme ohne die Farbe zu wechseln. Zwei Heldenbilder blenden unter einem langsamen Ken Burns ineinander, ein Portrait und ein breites Band wandern gegen den Scroll, die Zahlen zählen beim Erscheinen hoch, und eine Leiste am rechten Rand zeigt, wo man unter zwölf Stationen steht.

Sport & CoachingKomplette Seite

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

Quellcode

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

import {
  useCallback,
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from "react";
import {
  AnimatePresence,
  motion,
  useInView,
  useReducedMotion,
  useScroll,
  useTransform,
} from "framer-motion";
import {
  Activity,
  ArrowRight,
  ArrowUpRight,
  Award,
  Brain,
  BrainCircuit,
  Calendar,
  CalendarDays,
  CalendarRange,
  ChevronLeft,
  ChevronRight,
  ClipboardList,
  Clock3,
  Compass,
  Dumbbell,
  Fingerprint,
  History,
  Home,
  LayoutGrid,
  Mail,
  Medal,
  Minus,
  Move,
  Phone,
  Plus,
  Repeat,
  ShieldCheck,
  Sprout,
  TrendingUp,
  Users,
  Weight,
} from "lucide-react";

/*
 * Kaleo: a full page for in-home personal training.
 *
 * The whole page is white, one very light grey and near black. There is no
 * colour accent at all; hierarchy comes from surface changes, size and space.
 * Against that, an italic serif carries the two quiet moments of the page,
 * the philosophy line and the caption over the film band, so those two
 * sentences change voice without a second colour.
 *
 * Motion is layered rather than loud. Two hero photographs cross fade under a
 * slow ken burns, blocks lift in once and hold still, a portrait and a wide
 * band drift against the scroll, the numbers count up when they arrive, and
 * the goal finder swaps its three steps in place. Everything checks
 * prefers-reduced-motion and falls back to a still page.
 */

const WEISS = "#ffffff";
const CREME = "#f5f5f4";
const SAND = "#ebebe9";
const TINTE = "#111111";
const NACHT = "#17181a";
const LEISE = "#6d6d6b";
const KANTE = "#e3e3e0";

const SANS = '"Albert Sans", ui-sans-serif, system-ui, sans-serif';
const SERIF = '"Instrument Serif", ui-serif, Georgia, serif';
const serif: CSSProperties = { fontFamily: SERIF, fontWeight: 400, fontStyle: "italic" };

/** Kicker: gesperrte Versalien, ueberall gleich. */
const kicker: CSSProperties = {
  fontFamily: SANS,
  fontSize: 11,
  letterSpacing: "0.22em",
  textTransform: "uppercase",
};

/** Knoepfe bleiben leicht gerundet, Flaechen etwas grosszuegiger. */
const KNOPF = 6;
const KARTE = 14;

const ease = [0.22, 1, 0.36, 1] as const;

/** Sehr feines Korn ueber der ganzen Seite, damit die Flaechen leben. */
const KORN =
  "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")";

const MARKE = {
  name: "Kaleo",
  claim: "In-home personal training",
  mail: "hello@example.com",
  telefon: "+00 000 000 000",
};

const NAV = [
  { id: "start", label: "Home" },
  { id: "services", label: "Training" },
  { id: "about", label: "About" },
  { id: "contact", label: "Contact" },
];

/** Reihenfolge und Beschriftung der Abschnitts-Leiste am rechten Rand. */
const LEISTE = [
  { id: "start", label: "Start" },
  { id: "philosophy", label: "Philosophy" },
  { id: "services", label: "Training" },
  { id: "about", label: "About" },
  { id: "method", label: "Method" },
  { id: "goalfinder", label: "Goal finder" },
  { id: "numbers", label: "In numbers" },
  { id: "gallery", label: "Gallery" },
  { id: "voices", label: "Voices" },
  { id: "questions", label: "Questions" },
  { id: "location", label: "Where" },
  { id: "contact", label: "Contact" },
];

/**
 * Alle Abschnitte in Reihenfolge, auch die ohne Eintrag in der Leiste, und
 * die dunklen darunter. Die Leiste braucht beides, um sich umzufaerben.
 */
const ALLE_ABSCHNITTE = [
  "start",
  "philosophy",
  "services",
  "about",
  "method",
  "band",
  "goalfinder",
  "numbers",
  "gallery",
  "voices",
  "questions",
  "location",
  "contact",
];
const DUNKEL = new Set(["start", "band", "contact"]);

const HELD_BILDER = [
  { src: "/templates/kaleo/hero-a.webp", alt: "A trainer pressing a dumbbell overhead at dusk" },
  { src: "/templates/kaleo/hero-b.webp", alt: "" },
];

const LEISTUNGEN = [
  {
    Icon: Home,
    name: "In-home sessions",
    text: "Training in your own rooms, built around your goals, your week and how your body is doing right now.",
  },
  {
    Icon: Dumbbell,
    name: "Strength and mobility",
    text: "Focused work on strength, range of motion and control, so movement gets easier outside the session too.",
  },
  {
    Icon: BrainCircuit,
    name: "Mindful performance",
    text: "Training that treats attention and consistency as skills, not as things you either have or do not.",
  },
];

const METHODE = [
  {
    Icon: ClipboardList,
    n: "01",
    name: "Analysis",
    text: "We talk through goals, your current shape, old injuries and what training has looked like so far.",
  },
  {
    Icon: LayoutGrid,
    n: "02",
    name: "Structure",
    text: "You get a plan that fits a real week, not an ideal one. Two hours you keep beat five you skip.",
  },
  {
    Icon: Activity,
    n: "03",
    name: "Training",
    text: "Every session is personal and technically clean. Load goes up only once the movement is right.",
  },
  {
    Icon: TrendingUp,
    n: "04",
    name: "Development",
    text: "We look back every few weeks, keep what worked and change what did not.",
  },
];

const HALTUNGSPUNKTE = [
  { Icon: ShieldCheck, label: "Discreet" },
  { Icon: Fingerprint, label: "Individual" },
  { Icon: Award, label: "Professional" },
];

const ZIELFINDER: {
  label: string;
  frage: string;
  optionen: { label: string; Icon: typeof Weight }[];
}[] = [
  {
    label: "Your goal",
    frage: "What should training move first?",
    optionen: [
      { label: "Strength", Icon: Weight },
      { label: "Mobility", Icon: Move },
      { label: "Mental strength", Icon: Brain },
      { label: "All of it", Icon: Compass },
    ],
  },
  {
    label: "Your level",
    frage: "Where are you starting from?",
    optionen: [
      { label: "Beginner", Icon: Sprout },
      { label: "Intermediate", Icon: TrendingUp },
      { label: "Experienced", Icon: Medal },
    ],
  },
  {
    label: "Time budget",
    frage: "How much of the week is realistic?",
    optionen: [
      { label: "Once a week", Icon: Calendar },
      { label: "Twice a week", Icon: CalendarDays },
      { label: "Three times or more", Icon: CalendarRange },
    ],
  },
];

const ZAHLEN = [
  { Icon: History, wert: 12, suffix: "+", label: "Years of experience" },
  { Icon: Users, wert: 80, suffix: "+", label: "Clients guided" },
  { Icon: Repeat, wert: 6000, suffix: "+", label: "Sessions delivered" },
  { Icon: Clock3, wert: 24, suffix: "h", label: "Reply time" },
];

const GALERIE = [
  { src: "/templates/kaleo/gallery-motion.webp", alt: "Controlled movement in low side light" },
  { src: "/templates/kaleo/gallery-barbell.webp", alt: "Hands set on a barbell before a lift" },
  { src: "/templates/kaleo/band-training.webp", alt: "Lying on grass between long tree shadows" },
  { src: "/templates/kaleo/gallery-detail.webp", alt: "A short rest between two sets" },
  { src: "/templates/kaleo/gallery-studio.webp", alt: "A quiet moment after training" },
  {
    src: "/templates/kaleo/gallery-focus.webp",
    alt: "Two people at the start of a set in low light",
  },
];

const STIMMEN = [
  {
    zitat:
      "Every session is thought through and precisely aimed at me. Six months in I am stronger, and more to the point I am clearer about what I am doing and why.",
    name: "Sarah M.",
    rolle: "Founder",
  },
  {
    zitat:
      "What makes this different is the calm. No queue for a rack, no music I did not choose. Fifty five minutes of full attention, then back to my day.",
    name: "Thomas R.",
    rolle: "Managing director",
  },
  {
    zitat:
      "Finally training that fits my week instead of fighting it. Discreet, professional, and genuinely interested in how I am progressing.",
    name: "Nadine K.",
    rolle: "Physician",
  },
];

const FRAGEN = [
  {
    q: "How does a first session work?",
    a: "The first appointment is mostly a conversation. We go through your goals, your current situation and anything that limits you, then train lightly so I can see how you move. You get an approach in writing afterwards.",
  },
  {
    q: "Where do the sessions take place?",
    a: "In your home, or at any private space that works for you. No studio atmosphere, no waiting for equipment, no audience.",
  },
  {
    q: "What does it cost?",
    a: "Pricing depends on scope and frequency, so there is no list price. After the first conversation you get a written offer with everything named.",
  },
  {
    q: "Do I need my own equipment?",
    a: "No. Sessions are built to work with very little, and I bring what is needed. If you already own equipment we use it.",
  },
];

const GEBIETE = ["Old town", "Riverside", "Hillside", "Lakeside", "Airport quarter"];

const FUSS_NAV = [
  { titel: "Navigation", links: ["Home", "Training", "About", "Contact"] },
  { titel: "Legal", links: ["Imprint", "Privacy", "Cookie settings"] },
];

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

/**
 * Einmaliges Auftauchen beim Scrollen: leichter Versatz nach oben, optional
 * ein weicher Unschaerfe-Start. Bei reduzierter Bewegung steht alles sofort.
 */
function Auftritt({
  children,
  verzug = 0,
  weich = false,
  className = "",
  style,
}: {
  children: ReactNode;
  verzug?: number;
  weich?: boolean;
  className?: string;
  style?: CSSProperties;
}) {
  const ruhig = useReducedMotion();
  const ref = useRef<HTMLDivElement>(null);
  const sichtbar = useInView(ref, { once: true, margin: "-70px" });
  const start = weich ? { opacity: 0, y: 24, filter: "blur(10px)" } : { opacity: 0, y: 22 };
  const da = weich ? { opacity: 1, y: 0, filter: "blur(0px)" } : { opacity: 1, y: 0 };

  return (
    <motion.div
      ref={ref}
      className={className}
      style={style}
      initial={ruhig ? false : start}
      animate={ruhig || sichtbar ? da : start}
      transition={{ duration: 0.72, ease, delay: ruhig ? 0 : verzug / 1000 }}
    >
      {children}
    </motion.div>
  );
}

/** Bild, das beim Scrollen etwas langsamer laeuft als die Seite. */
function DriftBild({
  src,
  alt,
  staerke = 60,
  className = "",
  radius = 0,
}: {
  src: string;
  alt: string;
  staerke?: number;
  className?: string;
  radius?: number;
}) {
  const ruhig = useReducedMotion();
  const ref = useRef<HTMLDivElement>(null);
  const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] });
  const y = useTransform(scrollYProgress, [0, 1], [-staerke, staerke]);

  return (
    <div
      ref={ref}
      className={`relative overflow-hidden ${className}`}
      style={{ borderRadius: radius }}
    >
      <motion.img
        src={src}
        alt={alt}
        loading="lazy"
        decoding="async"
        className="absolute inset-0 h-full w-full object-cover"
        style={{
          y: ruhig ? 0 : y,
          height: `calc(100% + ${staerke * 2}px)`,
          top: -staerke,
        }}
      />
    </div>
  );
}

/** Zahl, die beim Erreichen einmal hochzaehlt. */
function Hochzaehlen({ bis, suffix }: { bis: number; suffix: string }) {
  const ruhig = useReducedMotion();
  const ref = useRef<HTMLSpanElement>(null);
  const sichtbar = useInView(ref, { once: true, margin: "-60px" });
  const [wert, setWert] = useState(0);

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

  return (
    <span ref={ref} className="tabular-nums">
      {wert.toLocaleString("en-US")}
      {suffix}
    </span>
  );
}

/** Kleiner Kicker ueber jeder Ueberschrift. */
function Kicker({ children, hell = false }: { children: ReactNode; hell?: boolean }) {
  return <p style={{ ...kicker, color: hell ? "rgba(255,255,255,0.6)" : LEISE }}>{children}</p>;
}

function PrimaerKnopf({
  href,
  children,
  hell = false,
}: {
  href: string;
  children: ReactNode;
  hell?: boolean;
}) {
  return (
    <a
      href={href}
      className="group inline-flex items-center justify-center gap-2.5 px-8 py-4 transition-opacity hover:opacity-85"
      style={{
        ...kicker,
        borderRadius: KNOPF,
        background: hell ? WEISS : TINTE,
        color: hell ? TINTE : WEISS,
      }}
    >
      {children}
      <ArrowRight
        className="h-3.5 w-3.5 transition-transform duration-300 group-hover:translate-x-1"
        strokeWidth={1.5}
      />
    </a>
  );
}

function ZweitKnopf({
  href,
  children,
  hell = false,
}: {
  href: string;
  children: ReactNode;
  hell?: boolean;
}) {
  return (
    <a
      href={href}
      className="inline-flex items-center justify-center px-8 py-4 transition-colors"
      style={{
        ...kicker,
        borderRadius: KNOPF,
        border: `1px solid ${hell ? "rgba(255,255,255,0.45)" : KANTE}`,
        color: hell ? WEISS : TINTE,
      }}
    >
      {children}
    </a>
  );
}

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

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

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

  useEffect(() => {
    if (!offen) return;
    const zu = (e: KeyboardEvent) => e.key === "Escape" && setOffen(false);
    document.addEventListener("keydown", zu);
    // Solange das Vollbild-Menue offen ist, darf die Seite darunter nicht
    // mitscrollen.
    const vorher = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      document.removeEventListener("keydown", zu);
      document.body.style.overflow = vorher;
    };
  }, [offen]);

  // Ueber dem Hero und im offenen Menue steht die Schrift hell, sonst dunkel.
  const hell = !gescrollt || offen;
  const vorne = hell ? WEISS : TINTE;

  return (
    <>
      <header
        className="sticky top-0 z-50 transition-colors duration-300"
        style={{
          background: hell ? "transparent" : "rgba(255,255,255,0.94)",
          borderBottom: `1px solid ${hell ? "transparent" : KANTE}`,
        }}
      >
        <div className="mx-auto flex h-20 max-w-[1240px] items-center justify-between px-6 lg:px-10">
          <a href="#start" className="flex items-center gap-3" style={{ color: vorne }}>
            <Zeichen className="h-5 w-5" />
            <span
              style={{ fontFamily: SANS, fontSize: 19, letterSpacing: "0.26em" }}
              className="uppercase"
            >
              {MARKE.name}
            </span>
          </a>

          <nav className="hidden items-center gap-9 md:flex">
            {NAV.map((n) => (
              <a
                key={n.id}
                href={`#${n.id}`}
                className="text-[13px] transition-opacity hover:opacity-100"
                style={{
                  fontFamily: SANS,
                  color: vorne,
                  opacity: 0.72,
                }}
              >
                {n.label}
              </a>
            ))}
          </nav>

          <div className="flex items-center gap-4">
            <a
              href="#contact"
              className="hidden px-5 py-2.5 transition-colors sm:inline-block"
              style={{
                ...kicker,
                borderRadius: KNOPF,
                border: `1px solid ${hell ? "rgba(255,255,255,0.5)" : KANTE}`,
                color: vorne,
              }}
            >
              Request a session
            </a>

            <button
              type="button"
              onClick={() => setOffen((v) => !v)}
              aria-label={offen ? "Close menu" : "Open menu"}
              aria-expanded={offen}
              className="flex h-9 w-9 items-center justify-center md:hidden"
              style={{ color: vorne }}
            >
              <span className="relative block h-3 w-5">
                <span
                  className="absolute left-0 block h-px w-full transition-all duration-300"
                  style={{
                    background: vorne,
                    top: offen ? 6 : 0,
                    transform: offen ? "rotate(45deg)" : "none",
                  }}
                />
                <span
                  className="absolute left-0 top-1.5 block h-px w-full transition-opacity duration-300"
                  style={{ background: vorne, opacity: offen ? 0 : 1 }}
                />
                <span
                  className="absolute left-0 block h-px w-full transition-all duration-300"
                  style={{
                    background: vorne,
                    top: offen ? 6 : 12,
                    transform: offen ? "rotate(-45deg)" : "none",
                  }}
                />
              </span>
            </button>
          </div>
        </div>
      </header>

      {/* Vollbild-Menue auf dem Telefon. Liegt bewusst ausserhalb des Headers:
          der Header traegt beim Scrollen einen eigenen Hintergrund, und ein
          Element darin waere an dessen Hoehe gebunden. */}
      <AnimatePresence>
        {offen && (
          <motion.div
            className="fixed inset-0 z-40 flex flex-col md:hidden"
            style={{ background: NACHT, color: WEISS }}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.32, ease }}
          >
            <nav className="flex flex-1 flex-col justify-center px-8 pt-20">
              {NAV.map((n, i) => (
                <motion.a
                  key={n.id}
                  href={`#${n.id}`}
                  onClick={() => setOffen(false)}
                  className="group flex items-baseline gap-4 py-5"
                  style={{ borderBottom: "1px solid rgba(255,255,255,0.14)" }}
                  initial={{ opacity: 0, y: 18 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{ duration: 0.5, ease, delay: 0.12 + i * 0.07 }}
                >
                  <span
                    className="tabular-nums"
                    style={{ ...kicker, color: "rgba(255,255,255,0.4)" }}
                  >
                    0{i + 1}
                  </span>
                  <span
                    className="transition-transform duration-300 group-hover:translate-x-2"
                    style={{ fontFamily: SANS, fontSize: 34, letterSpacing: "-0.02em" }}
                  >
                    {n.label}
                  </span>
                </motion.a>
              ))}
            </nav>
            <motion.div
              className="px-8 pb-12"
              initial={{ opacity: 0, y: 18 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: 0.5, ease, delay: 0.12 + NAV.length * 0.07 }}
            >
              <a
                href="#contact"
                onClick={() => setOffen(false)}
                className="mb-8 flex items-center justify-center px-5 py-4 transition-colors"
                style={{
                  ...kicker,
                  borderRadius: KNOPF,
                  border: "1px solid rgba(255,255,255,0.55)",
                  color: WEISS,
                }}
              >
                Request a session
              </a>
              <div
                className="flex flex-col gap-1 text-sm"
                style={{ fontFamily: SANS, color: "rgba(255,255,255,0.6)" }}
              >
                <a href={`mailto:${MARKE.mail}`}>{MARKE.mail}</a>
                <a href={`tel:${MARKE.telefon.replace(/\s/g, "")}`}>{MARKE.telefon}</a>
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </>
  );
}

function Zeichen({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 32 32" fill="none" className={className} aria-hidden="true">
      <circle cx="16" cy="16" r="14" stroke="currentColor" strokeWidth="1" opacity="0.5" />
      <path d="M16 5 V27 M5 16 H27" stroke="currentColor" strokeWidth="1" />
    </svg>
  );
}

/* -------------------------------------------------------------- rail (right) */

/**
 * Abschnitts-Leiste am rechten Rand. Bei zwoelf Abschnitten ist sonst schwer
 * zu sehen, wo man ist und wie viel noch kommt. Nur ab Desktop, auf schmalen
 * Geraeten uebernimmt das Kopfmenue.
 */
function Leiste() {
  const [aktiv, setAktiv] = useState(LEISTE[0].id);
  // Welcher Abschnitt liegt gerade HINTER der Leiste? Das ist nicht dasselbe
  // wie der aktive Eintrag: der Hero, das Bildband und der Kontakt sind dunkel,
  // dort muss die Leiste hell schreiben, sonst verschwindet sie im Bild.
  const [dunkel, setDunkel] = useState(true);

  useEffect(() => {
    const beobachter = new IntersectionObserver(
      (eintraege) => {
        const sichtbar = eintraege
          .filter((e) => e.isIntersecting)
          .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
        if (sichtbar[0]) setAktiv(sichtbar[0].target.id);
      },
      { rootMargin: "-25% 0px -60% 0px", threshold: 0 },
    );
    const knoten = LEISTE.map((s) => document.getElementById(s.id)).filter(
      (n): n is HTMLElement => n !== null,
    );
    knoten.forEach((n) => beobachter.observe(n));
    return () => beobachter.disconnect();
  }, []);

  useEffect(() => {
    // Ein Beobachter mit Null-Hoehe genau auf der Mitte des Fensters: er meldet
    // den Abschnitt, der dort liegt, egal ob er in der Leiste steht.
    const beobachter = new IntersectionObserver(
      (eintraege) => {
        for (const e of eintraege) {
          if (e.isIntersecting) setDunkel(DUNKEL.has(e.target.id));
        }
      },
      { rootMargin: "-50% 0px -50% 0px", threshold: 0 },
    );
    const knoten = ALLE_ABSCHNITTE.map((id) => document.getElementById(id)).filter(
      (n): n is HTMLElement => n !== null,
    );
    knoten.forEach((n) => beobachter.observe(n));
    return () => beobachter.disconnect();
  }, []);

  const schrift = dunkel ? "255,255,255" : "17,17,17";

  return (
    <nav
      aria-label="Sections"
      className="pointer-events-none fixed top-1/2 right-6 z-40 hidden -translate-y-1/2 lg:block"
    >
      <ul className="flex flex-col items-end gap-1">
        {LEISTE.map((s) => {
          const an = s.id === aktiv;
          return (
            <li key={s.id} className="pointer-events-auto">
              <a
                href={`#${s.id}`}
                aria-current={an ? "true" : undefined}
                className="group flex items-center justify-end gap-3 py-1.5"
              >
                <span
                  className="transition-opacity duration-300 group-hover:opacity-100"
                  style={{
                    ...kicker,
                    fontSize: 10,
                    color: `rgb(${schrift})`,
                    opacity: an ? 0.75 : 0,
                  }}
                >
                  {s.label}
                </span>
                <span
                  aria-hidden="true"
                  className="block h-px transition-all duration-300 group-hover:w-5"
                  style={{
                    width: an ? 24 : 12,
                    background: `rgba(${schrift},${an ? 0.75 : 0.3})`,
                  }}
                />
              </a>
            </li>
          );
        })}
      </ul>
    </nav>
  );
}

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

function Held() {
  const ruhig = useReducedMotion();
  const [aktiv, setAktiv] = useState(0);

  useEffect(() => {
    if (ruhig) {
      setAktiv(0);
      return;
    }
    const uhr = setInterval(() => setAktiv((i) => (i + 1) % HELD_BILDER.length), 6000);
    return () => clearInterval(uhr);
  }, [ruhig]);

  return (
    <section id="start" className="relative -mt-20 overflow-hidden">
      <div className="absolute inset-0">
        {HELD_BILDER.map((bild, i) => (
          <motion.img
            key={bild.src}
            src={bild.src}
            alt={bild.alt}
            aria-hidden={i === 0 ? undefined : true}
            loading={i === 0 ? "eager" : "lazy"}
            fetchPriority={i === 0 ? "high" : undefined}
            className="absolute inset-0 h-full w-full object-cover"
            animate={
              ruhig
                ? { opacity: i === aktiv ? 1 : 0 }
                : {
                    opacity: i === aktiv ? 1 : 0,
                    scale: i === aktiv ? 1.14 : 1.03,
                    x: i === aktiv ? "-1.5%" : "0%",
                  }
            }
            transition={{
              opacity: { duration: 1.2, ease: "easeInOut" },
              scale: { duration: 18, ease: "linear" },
              x: { duration: 18, ease: "linear" },
            }}
          />
        ))}
        {/* Kein Farbfilter, nur ein neutraler Verlauf, damit die Schrift traegt. */}
        <div
          className="absolute inset-0"
          style={{
            background:
              "linear-gradient(105deg, rgba(10,10,10,0.86) 0%, rgba(10,10,10,0.5) 46%, rgba(10,10,10,0.14) 100%)",
          }}
        />
        <div
          className="absolute inset-0"
          style={{
            background:
              "linear-gradient(to top, rgba(10,10,10,0.62) 0%, rgba(10,10,10,0) 42%, rgba(10,10,10,0.22) 100%)",
          }}
        />
      </div>

      <div className="relative z-10 mx-auto flex min-h-[100svh] max-w-[1240px] flex-col justify-center px-6 py-28 lg:px-10">
        <div className="max-w-[46rem]">
          <motion.p
            className="mb-7 flex items-center gap-3"
            style={{ ...kicker, color: "rgba(255,255,255,0.72)" }}
            initial={ruhig ? false : { opacity: 0, y: 14 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.7, ease }}
          >
            <Dumbbell className="h-3.5 w-3.5" strokeWidth={1.4} />
            In-home personal training
          </motion.p>

          <motion.h1
            className="max-w-[16ch] text-[3.1rem] leading-[1.02] sm:text-[3.9rem] lg:text-[5.2rem] lg:leading-[0.98]"
            style={{ fontFamily: SANS, fontWeight: 400, letterSpacing: "-0.035em", color: WEISS }}
            initial={ruhig ? false : { opacity: 0, y: 26, filter: "blur(12px)" }}
            animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
            transition={{ duration: 0.95, ease, delay: ruhig ? 0 : 0.14 }}
          >
            Personal training for body and mind.
          </motion.h1>

          <motion.p
            className="mt-8 max-w-[34rem] text-[15px] leading-relaxed"
            style={{ fontFamily: SANS, color: "rgba(255,255,255,0.82)" }}
            initial={ruhig ? false : { opacity: 0, y: 18 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.8, ease, delay: ruhig ? 0 : 0.3 }}
          >
            One trainer, your rooms, and a plan that survives a normal week. Precise, private, and
            aimed further than fitness alone.
          </motion.p>

          <motion.div
            className="mt-11 flex flex-col gap-3 sm:flex-row"
            initial={ruhig ? false : { opacity: 0, y: 18 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.8, ease, delay: ruhig ? 0 : 0.44 }}
          >
            <PrimaerKnopf href="#contact" hell>
              Request first session
            </PrimaerKnopf>
            <ZweitKnopf href="#about" hell>
              More about Kaleo
            </ZweitKnopf>
          </motion.div>
        </div>
      </div>
    </section>
  );
}

/* --------------------------------------------------------------- philosophy */

function Haltung() {
  const ruhig = useReducedMotion();

  return (
    <section id="philosophy" className="relative overflow-hidden" style={{ background: CREME }}>
      {/* Drei sehr feine Ringe, die langsam nach aussen laufen. Rein dekorativ.
          Bei reduzierter Bewegung steht ein einzelner Ring still; der Baum
          bleibt derselbe, damit Server und Client dasselbe rendern. */}
      <div
        className="pointer-events-none absolute inset-0 flex items-center justify-center"
        aria-hidden="true"
      >
        {[0, 2.4, 4.8].map((verzug, i) => (
          <motion.span
            key={verzug}
            className="absolute rounded-full"
            style={{
              width: "min(42vw, 440px)",
              height: "min(42vw, 440px)",
              border: "1px solid rgba(17,17,17,0.13)",
            }}
            initial={{ scale: 0.4, opacity: 0 }}
            animate={
              ruhig
                ? { scale: 1, opacity: i === 0 ? 0.5 : 0 }
                : { scale: [0.4, 1.6], opacity: [0, 0.55, 0] }
            }
            transition={
              ruhig
                ? { duration: 0 }
                : {
                    duration: 7.2,
                    ease: "easeOut",
                    repeat: Infinity,
                    delay: verzug,
                    times: [0, 1],
                  }
            }
          />
        ))}
      </div>

      <div className="relative z-10 mx-auto max-w-[54rem] px-6 py-24 text-center lg:py-36">
        <Auftritt>
          <Kicker>Philosophy</Kicker>
        </Auftritt>
        <Auftritt verzug={120} weich>
          <blockquote
            className="mt-10 text-[28px] leading-[1.28] sm:text-[36px] lg:text-[42px]"
            style={{ ...serif, color: TINTE }}
          >
            &ldquo;Training begins where the mind wants to stop. That is where the strength you keep
            gets built.&rdquo;
          </blockquote>
        </Auftritt>
        <Auftritt verzug={240}>
          <p
            className="mx-auto mt-10 max-w-[36rem] text-[15px] leading-relaxed"
            style={{ fontFamily: SANS, color: LEISE }}
          >
            Kaleo puts physical work and mental discipline in the same session. Progress shows up in
            the numbers, and it also shows up in how you sit, how you sleep and what you say yes to.
          </p>
        </Auftritt>
      </div>
    </section>
  );
}

/* ----------------------------------------------------------------- services */

function Leistungen() {
  return (
    <section id="services" className="scroll-mt-20" style={{ background: WEISS }}>
      <div className="mx-auto max-w-[1240px] px-6 py-24 lg:px-10 lg:py-32">
        <div className="max-w-[38rem]">
          <Auftritt>
            <Kicker>Training</Kicker>
          </Auftritt>
          <Auftritt verzug={100} weich>
            <h2
              className="mt-6 text-[32px] leading-[1.08] sm:text-[40px]"
              style={{ fontFamily: SANS, fontWeight: 400, letterSpacing: "-0.03em", color: TINTE }}
            >
              Training with a clear direction.
            </h2>
          </Auftritt>
        </div>

        <div className="mt-14 grid grid-cols-1 gap-4 md:grid-cols-3 lg:mt-20">
          {LEISTUNGEN.map((l, i) => (
            <Auftritt key={l.name} verzug={160 + i * 110} className="h-full">
              <div
                className="group h-full p-8 transition-colors duration-500 lg:p-10"
                style={{ background: CREME, borderRadius: KARTE }}
              >
                <l.Icon
                  className="h-5 w-5 transition-transform duration-500 group-hover:-translate-y-0.5"
                  strokeWidth={1.3}
                  style={{ color: TINTE }}
                />
                <h3
                  className="mt-8 text-[21px] leading-tight"
                  style={{
                    fontFamily: SANS,
                    fontWeight: 400,
                    letterSpacing: "-0.02em",
                    color: TINTE,
                  }}
                >
                  {l.name}
                </h3>
                <p
                  className="mt-3.5 text-[14px] leading-relaxed"
                  style={{ fontFamily: SANS, color: LEISE }}
                >
                  {l.text}
                </p>
              </div>
            </Auftritt>
          ))}
        </div>
      </div>
    </section>
  );
}

/* -------------------------------------------------------------------- about */

function Ueber() {
  return (
    <section id="about" className="scroll-mt-20" style={{ background: WEISS }}>
      {/* Das Bild laeuft bis an den linken Bildschirmrand, der Text behaelt die
          Spaltenbreite der uebrigen Abschnitte. */}
      <div className="grid grid-cols-1 items-stretch lg:grid-cols-2">
        <DriftBild
          src="/templates/kaleo/studio-portrait.webp"
          alt="A trainer standing at a tall window before a session"
          staerke={54}
          className="min-h-[62vh] lg:min-h-[40rem]"
        />

        <div className="flex flex-col justify-center px-6 py-20 lg:pr-10 lg:pl-14 lg:py-28 xl:pl-20">
          <Auftritt>
            <Kicker>About Kaleo</Kicker>
          </Auftritt>
          <Auftritt verzug={100} weich>
            <h2
              className="mt-6 max-w-[20ch] text-[32px] leading-[1.08] sm:text-[40px]"
              style={{ fontFamily: SANS, fontWeight: 400, letterSpacing: "-0.03em", color: TINTE }}
            >
              Training with personal ambition.
            </h2>
          </Auftritt>
          <Auftritt verzug={200}>
            <p
              className="mt-8 max-w-[34rem] text-[15px] leading-relaxed"
              style={{ fontFamily: SANS, color: LEISE }}
            >
              Kaleo is one trainer, not an agency. Twelve years of coaching, a background in
              rehabilitation, and a strong preference for depth over volume. No short term promises,
              no plans copied from someone else&rsquo;s body.
            </p>
          </Auftritt>
          <Auftritt verzug={300}>
            <div className="mt-10 flex flex-wrap gap-x-9 gap-y-5">
              {HALTUNGSPUNKTE.map((p) => (
                <div key={p.label} className="flex items-center gap-3">
                  <p.Icon
                    className="h-[18px] w-[18px]"
                    strokeWidth={1.3}
                    style={{ color: LEISE }}
                  />
                  <span style={{ ...kicker, fontSize: 11, color: TINTE }}>{p.label}</span>
                </div>
              ))}
            </div>
          </Auftritt>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------- method */

function Methode() {
  return (
    <section id="method" className="scroll-mt-20" style={{ background: CREME }}>
      <div className="mx-auto max-w-[1240px] px-6 py-24 lg:px-10 lg:py-32">
        <div className="max-w-[38rem]">
          <Auftritt>
            <Kicker>Method</Kicker>
          </Auftritt>
          <Auftritt verzug={100} weich>
            <h2
              className="mt-6 text-[32px] leading-[1.08] sm:text-[40px]"
              style={{ fontFamily: SANS, fontWeight: 400, letterSpacing: "-0.03em", color: TINTE }}
            >
              Four steps, then it repeats.
            </h2>
          </Auftritt>
        </div>

        <div className="mt-14 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:mt-20 lg:grid-cols-4">
          {METHODE.map((s, i) => (
            <Auftritt key={s.name} verzug={170 + i * 110} className="h-full">
              <div
                className="group flex h-full flex-col p-8 transition-colors duration-500"
                style={{ background: WEISS, borderRadius: KARTE }}
              >
                <div className="flex items-center justify-between">
                  <s.Icon
                    className="h-5 w-5 transition-transform duration-500 group-hover:-translate-y-0.5"
                    strokeWidth={1.3}
                    style={{ color: TINTE }}
                  />
                  <span style={{ ...kicker, fontSize: 10, color: "rgba(17,17,17,0.28)" }}>
                    {s.n}
                  </span>
                </div>
                <h3
                  className="mt-8 text-[21px] leading-tight"
                  style={{
                    fontFamily: SANS,
                    fontWeight: 400,
                    letterSpacing: "-0.02em",
                    color: TINTE,
                  }}
                >
                  {s.name}
                </h3>
                <p
                  className="mt-3.5 text-[14px] leading-relaxed"
                  style={{ fontFamily: SANS, color: LEISE }}
                >
                  {s.text}
                </p>
              </div>
            </Auftritt>
          ))}
        </div>
      </div>
    </section>
  );
}

/* --------------------------------------------------------------------- band */

/**
 * Ein breites Bild quer ueber die Seite, das langsamer laeuft als der Scroll,
 * und darauf der einzige zweite Satz in der Serifenschrift. Der Verlauf ist
 * neutral grau, kein Farbfilter, damit das Gruen des Bildes bleibt.
 */
function Band() {
  return (
    <section id="band" className="relative h-[70vh] min-h-[25rem] w-full overflow-hidden">
      {/* Das Bild steht im Fluss und fuellt den Abschnitt, Schleier und Satz
          liegen absolut darueber. */}
      <DriftBild
        src="/templates/kaleo/gallery-partner.webp"
        alt="Two people holding a plank, hands meeting in the middle"
        staerke={70}
        className="h-full w-full"
      />
      <div
        className="absolute inset-0"
        style={{
          background:
            "linear-gradient(to bottom, rgba(10,10,10,0.2) 0%, rgba(10,10,10,0.58) 52%, rgba(10,10,10,0.22) 100%)",
        }}
      />
      <div className="absolute inset-0 flex items-center justify-center">
        <div className="mx-auto w-full max-w-[58rem] px-6 text-center">
          <Auftritt weich>
            <p
              className="text-[28px] leading-[1.24] sm:text-[38px] lg:text-[46px]"
              style={{ ...serif, color: WEISS }}
            >
              Training that adapts to your life, not the other way around.
            </p>
          </Auftritt>
        </div>
      </div>
    </section>
  );
}

/* -------------------------------------------------------------- goal finder */

function Zielfinder() {
  const ruhig = useReducedMotion();
  const [schritt, setSchritt] = useState(0);
  const [wahl, setWahl] = useState<string[]>([]);
  const fertig = wahl.length === ZIELFINDER.length;

  const waehlen = (option: string) => {
    const neu = [...wahl.slice(0, schritt), option];
    setWahl(neu);
    if (schritt < ZIELFINDER.length - 1) setSchritt(schritt + 1);
  };

  const neuStarten = () => {
    setWahl([]);
    setSchritt(0);
  };

  const zwei = (n: number) => `0${n}`.slice(-2);
  const wechsel = { duration: ruhig ? 0 : 0.42, ease };

  return (
    <section id="goalfinder" className="scroll-mt-20" style={{ background: WEISS }}>
      <div className="mx-auto max-w-[62rem] px-6 py-24 lg:py-32">
        <div className="max-w-[38rem]">
          <Auftritt>
            <Kicker>Goal finder</Kicker>
          </Auftritt>
          <Auftritt verzug={100} weich>
            <h2
              className="mt-6 text-[32px] leading-[1.08] sm:text-[40px]"
              style={{ fontFamily: SANS, fontWeight: 400, letterSpacing: "-0.03em", color: TINTE }}
            >
              Find your starting point.
            </h2>
          </Auftritt>
          <Auftritt verzug={180}>
            <p
              className="mt-6 text-[15px] leading-relaxed"
              style={{ fontFamily: SANS, color: LEISE }}
            >
              Three short questions, then a recommendation you can send straight over.
            </p>
          </Auftritt>
        </div>

        <Auftritt verzug={220}>
          <motion.div
            layout={!ruhig}
            transition={{ layout: wechsel }}
            className="mt-12 p-8 sm:p-12 lg:mt-16 lg:p-14"
            style={{ background: CREME, borderRadius: KARTE }}
          >
            <AnimatePresence mode="wait" initial={false}>
              {!fertig ? (
                <motion.div
                  key={`schritt-${schritt}`}
                  initial={ruhig ? false : { opacity: 0, y: 14 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={ruhig ? undefined : { opacity: 0, y: -12 }}
                  transition={wechsel}
                >
                  <div className="flex flex-wrap items-baseline justify-between gap-4">
                    <p style={{ ...kicker, color: LEISE }}>
                      Step {zwei(schritt + 1)}
                      <span style={{ opacity: 0.5 }}> / {zwei(ZIELFINDER.length)}</span>
                    </p>
                    {wahl.length > 0 && (
                      <div className="flex flex-wrap justify-end gap-2">
                        {wahl.map((w) => (
                          <span
                            key={w}
                            className="px-3 py-1"
                            style={{
                              ...kicker,
                              fontSize: 10,
                              borderRadius: KNOPF,
                              border: `1px solid ${KANTE}`,
                              color: LEISE,
                            }}
                          >
                            {w}
                          </span>
                        ))}
                      </div>
                    )}
                  </div>

                  <div className="mt-5 flex gap-1.5">
                    {ZIELFINDER.map((_, i) => (
                      <motion.span
                        key={i}
                        className="h-1.5 rounded-full"
                        animate={{
                          width: i < wahl.length || i === schritt ? 24 : 6,
                          backgroundColor:
                            i < wahl.length
                              ? "rgba(17,17,17,1)"
                              : i === schritt
                                ? "rgba(17,17,17,0.4)"
                                : "rgba(17,17,17,0.15)",
                        }}
                        transition={{ duration: ruhig ? 0 : 0.45, ease }}
                      />
                    ))}
                  </div>

                  <h3
                    className="mt-7 text-[24px] leading-tight sm:text-[28px]"
                    style={{
                      fontFamily: SANS,
                      fontWeight: 400,
                      letterSpacing: "-0.025em",
                      color: TINTE,
                    }}
                  >
                    {ZIELFINDER[schritt].frage}
                  </h3>

                  <div className="mt-9 grid grid-cols-1 gap-3 sm:grid-cols-2">
                    {ZIELFINDER[schritt].optionen.map((o, oi) => (
                      <motion.button
                        key={o.label}
                        type="button"
                        onClick={() => waehlen(o.label)}
                        className="group flex items-center justify-between gap-4 px-6 py-5 text-left transition-colors duration-300"
                        style={{ background: WEISS, borderRadius: KARTE }}
                        initial={ruhig ? false : { opacity: 0, y: 12 }}
                        animate={{ opacity: 1, y: 0 }}
                        transition={{ duration: 0.45, ease, delay: ruhig ? 0 : 0.06 + oi * 0.05 }}
                      >
                        <span className="flex items-center gap-4">
                          <span
                            className="inline-flex h-10 w-10 items-center justify-center rounded-full"
                            style={{ background: SAND }}
                          >
                            <o.Icon
                              className="h-[17px] w-[17px]"
                              strokeWidth={1.3}
                              style={{ color: TINTE }}
                            />
                          </span>
                          <span style={{ fontFamily: SANS, fontSize: 16, color: TINTE }}>
                            {o.label}
                          </span>
                        </span>
                        <ArrowUpRight
                          className="h-[18px] w-[18px] transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:translate-x-0.5"
                          strokeWidth={1.4}
                          style={{ color: LEISE }}
                        />
                      </motion.button>
                    ))}
                  </div>
                </motion.div>
              ) : (
                <motion.div
                  key="ergebnis"
                  initial={ruhig ? false : { opacity: 0, y: 14 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={ruhig ? undefined : { opacity: 0, y: -12 }}
                  transition={wechsel}
                >
                  <Kicker>Your recommendation</Kicker>
                  <p
                    className="mt-5 max-w-[32ch] text-[24px] leading-[1.2] sm:text-[30px]"
                    style={{
                      fontFamily: SANS,
                      fontWeight: 400,
                      letterSpacing: "-0.025em",
                      color: TINTE,
                    }}
                  >
                    In-home sessions, built around{" "}
                    <span style={{ ...serif, fontSize: "1.06em" }}>{wahl[0].toLowerCase()}</span>.
                  </p>

                  <dl className="mt-9">
                    {ZIELFINDER.map((s, i) => (
                      <motion.div
                        key={s.label}
                        className="flex items-center justify-between gap-6 py-4"
                        style={{ borderBottom: `1px solid ${KANTE}` }}
                        initial={ruhig ? false : { opacity: 0, y: 10 }}
                        animate={{ opacity: 1, y: 0 }}
                        transition={{ duration: 0.45, ease, delay: ruhig ? 0 : 0.08 + i * 0.07 }}
                      >
                        <dt style={{ ...kicker, color: LEISE }}>{s.label}</dt>
                        <dd style={{ fontFamily: SANS, fontSize: 16, color: TINTE }}>{wahl[i]}</dd>
                      </motion.div>
                    ))}
                  </dl>

                  <div className="mt-9 flex flex-wrap items-center gap-x-8 gap-y-4">
                    <PrimaerKnopf href="#contact">Send this selection</PrimaerKnopf>
                    <button
                      type="button"
                      onClick={neuStarten}
                      style={{ ...kicker, color: LEISE }}
                      className="transition-opacity hover:opacity-70"
                    >
                      Start over
                    </button>
                  </div>
                </motion.div>
              )}
            </AnimatePresence>
          </motion.div>
        </Auftritt>
      </div>
    </section>
  );
}

/* -------------------------------------------------------------------- stats */

function Zahlen() {
  return (
    <section id="numbers" className="scroll-mt-20" style={{ background: CREME }}>
      <div className="mx-auto max-w-[1240px] px-6 py-24 lg:px-10 lg:py-32">
        <div className="max-w-[38rem]">
          <Auftritt>
            <Kicker>In numbers</Kicker>
          </Auftritt>
          <Auftritt verzug={100} weich>
            <h2
              className="mt-6 text-[32px] leading-[1.08] sm:text-[40px]"
              style={{ fontFamily: SANS, fontWeight: 400, letterSpacing: "-0.03em", color: TINTE }}
            >
              Experience you can measure.
            </h2>
          </Auftritt>
        </div>

        <div className="mt-14 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:mt-20 lg:grid-cols-4">
          {ZAHLEN.map((z, i) => (
            <Auftritt key={z.label} verzug={150 + i * 100} className="h-full">
              <div
                className="group h-full p-8 transition-colors duration-500 sm:p-10"
                style={{ background: WEISS, borderRadius: KARTE }}
              >
                <z.Icon
                  className="h-[22px] w-[22px] transition-transform duration-500 group-hover:-translate-y-0.5"
                  strokeWidth={1.2}
                  style={{ color: LEISE }}
                />
                <p
                  className="mt-9 text-[44px] leading-none"
                  style={{
                    fontFamily: SANS,
                    fontWeight: 400,
                    letterSpacing: "-0.045em",
                    color: TINTE,
                  }}
                >
                  <Hochzaehlen bis={z.wert} suffix={z.suffix} />
                </p>
                <p className="mt-4" style={{ ...kicker, color: LEISE }}>
                  {z.label}
                </p>
              </div>
            </Auftritt>
          ))}
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ gallery */

function Galerie() {
  const ruhig = useReducedMotion();
  const [proBild, setProBild] = useState(1);
  const [index, setIndex] = useState(0);

  useEffect(() => {
    const messen = () =>
      setProBild(window.innerWidth >= 1024 ? 3 : window.innerWidth >= 640 ? 2 : 1);
    messen();
    window.addEventListener("resize", messen);
    return () => window.removeEventListener("resize", messen);
  }, []);

  // Der letzte moegliche Index, damit rechts nie eine Luecke steht.
  const max = Math.max(0, GALERIE.length - proBild);
  useEffect(() => {
    setIndex((i) => Math.min(i, Math.max(0, GALERIE.length - proBild)));
  }, [proBild]);

  const zurueck = useCallback(() => setIndex((i) => (i <= 0 ? max : i - 1)), [max]);
  const weiter = useCallback(() => setIndex((i) => (i >= max ? 0 : i + 1)), [max]);

  return (
    <section id="gallery" className="scroll-mt-20" style={{ background: WEISS }}>
      <div
        role="group"
        aria-roledescription="carousel"
        aria-label="Image gallery"
        tabIndex={0}
        onKeyDown={(e) => {
          if (e.key === "ArrowLeft") {
            e.preventDefault();
            zurueck();
          } else if (e.key === "ArrowRight") {
            e.preventDefault();
            weiter();
          }
        }}
        className="relative w-full overflow-hidden"
      >
        <motion.div
          className="flex"
          animate={{ x: `-${index * (100 / proBild)}%` }}
          transition={{ duration: ruhig ? 0 : 0.78, ease }}
        >
          {GALERIE.map((bild, i) => (
            <div
              key={bild.src}
              className="aspect-[4/5] shrink-0 px-[2px] md:aspect-[3/4]"
              style={{ width: `${100 / proBild}%` }}
            >
              <div className="h-full w-full overflow-hidden">
                <img
                  src={bild.src}
                  alt={bild.alt}
                  loading={i < 3 ? "eager" : "lazy"}
                  decoding="async"
                  className="h-full w-full object-cover transition-transform duration-[1400ms] ease-out hover:scale-[1.04]"
                />
              </div>
            </div>
          ))}
        </motion.div>

        {[
          { Icon: ChevronLeft, tun: zurueck, label: "Previous images", seite: "left-4" },
          { Icon: ChevronRight, tun: weiter, label: "Next images", seite: "right-4" },
        ].map((p) => (
          <button
            key={p.label}
            type="button"
            onClick={p.tun}
            aria-label={p.label}
            className={`absolute top-1/2 ${p.seite} z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full transition-colors hover:opacity-85`}
            style={{ background: WEISS, color: TINTE }}
          >
            <p.Icon className="h-[18px] w-[18px]" strokeWidth={1.4} />
          </button>
        ))}

        <div className="absolute bottom-5 left-1/2 z-10 flex -translate-x-1/2 gap-2">
          {Array.from({ length: max + 1 }).map((_, i) => (
            <button
              key={i}
              type="button"
              onClick={() => setIndex(i)}
              aria-label={`Show position ${i + 1}`}
              className="h-1.5 rounded-full transition-all duration-300"
              style={{
                width: i === index ? 24 : 6,
                background: i === index ? WEISS : "rgba(255,255,255,0.5)",
              }}
            />
          ))}
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------- testimonials */

function Stimmen() {
  const ruhig = useReducedMotion();
  const [aktiv, setAktiv] = useState(0);
  const startX = useRef<number | null>(null);
  const jetzt = STIMMEN[aktiv];

  const gehen = (richtung: number) =>
    setAktiv((v) => (v + richtung + STIMMEN.length) % STIMMEN.length);

  return (
    <section id="voices" className="scroll-mt-20" style={{ background: CREME }}>
      <div className="mx-auto max-w-[1240px] px-6 py-24 lg:px-10 lg:py-32">
        <Auftritt>
          <Kicker>Voices</Kicker>
        </Auftritt>
        <Auftritt verzug={100} weich>
          <h2
            className="mt-6 max-w-[24ch] text-[32px] leading-[1.08] sm:text-[40px]"
            style={{ fontFamily: SANS, fontWeight: 400, letterSpacing: "-0.03em", color: TINTE }}
          >
            What clients say afterwards.
          </h2>
        </Auftritt>

        <Auftritt verzug={200}>
          <div
            className="mt-14 max-w-[48rem] touch-pan-y select-none lg:mt-18"
            onPointerDown={(e) => {
              startX.current = e.clientX;
            }}
            onPointerUp={(e) => {
              if (startX.current === null) return;
              const dx = e.clientX - startX.current;
              if (Math.abs(dx) > 50) gehen(dx < 0 ? 1 : -1);
              startX.current = null;
            }}
          >
            <div className="min-h-[11rem] sm:min-h-[10rem]">
              <AnimatePresence mode="wait" initial={false}>
                <motion.p
                  key={aktiv}
                  className="text-[22px] leading-[1.4] sm:text-[28px]"
                  style={{ ...serif, color: TINTE }}
                  initial={ruhig ? false : { opacity: 0, y: 14 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={ruhig ? undefined : { opacity: 0, y: -12 }}
                  transition={{ duration: ruhig ? 0 : 0.45, ease }}
                >
                  <span style={{ color: "rgba(17,17,17,0.3)" }}>&ldquo;</span>
                  {jetzt.zitat}
                  <span style={{ color: "rgba(17,17,17,0.3)" }}>&rdquo;</span>
                </motion.p>
              </AnimatePresence>
            </div>

            <div className="mt-10">
              <AnimatePresence mode="wait" initial={false}>
                <motion.div
                  key={`name-${aktiv}`}
                  initial={ruhig ? false : { opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={ruhig ? undefined : { opacity: 0 }}
                  transition={{ duration: ruhig ? 0 : 0.3, ease }}
                >
                  <p style={{ ...kicker, color: TINTE }}>{jetzt.name}</p>
                  <p className="mt-1.5 text-[14px]" style={{ fontFamily: SANS, color: LEISE }}>
                    {jetzt.rolle}
                  </p>
                </motion.div>
              </AnimatePresence>
            </div>

            <div className="mt-11 flex items-center gap-2.5">
              {STIMMEN.map((s, i) => (
                <button
                  key={s.name}
                  type="button"
                  onClick={() => setAktiv(i)}
                  aria-label={`Show quote ${i + 1}`}
                  className="h-1 rounded-full transition-all duration-300"
                  style={{
                    width: i === aktiv ? 40 : 24,
                    background: i === aktiv ? TINTE : "rgba(17,17,17,0.2)",
                  }}
                />
              ))}
            </div>
          </div>
        </Auftritt>
      </div>
    </section>
  );
}

/* ---------------------------------------------------------------------- faq */

function Fragen() {
  const ruhig = useReducedMotion();
  const [offen, setOffen] = useState<number | null>(0);

  return (
    <section id="questions" className="scroll-mt-20" style={{ background: WEISS }}>
      <div className="mx-auto max-w-[1240px] px-6 py-24 lg:px-10 lg:py-32">
        <div className="grid grid-cols-1 gap-14 lg:grid-cols-[1fr_1.5fr] lg:gap-20">
          {/* Die Ueberschrift bleibt stehen, solange die Liste laeuft, sonst
              steht die linke Spalte die halbe Section lang leer. */}
          <div className="lg:sticky lg:top-28 lg:self-start">
            <Auftritt>
              <Kicker>Questions</Kicker>
            </Auftritt>
            <Auftritt verzug={100} weich>
              <h2
                className="mt-6 max-w-[14ch] text-[32px] leading-[1.08] sm:text-[40px]"
                style={{
                  fontFamily: SANS,
                  fontWeight: 400,
                  letterSpacing: "-0.03em",
                  color: TINTE,
                }}
              >
                Asked before every first session.
              </h2>
            </Auftritt>
          </div>

          <Auftritt verzug={180}>
            <div>
              {FRAGEN.map((f, i) => {
                const auf = offen === i;
                return (
                  <div key={f.q} style={{ borderTop: `1px solid ${KANTE}` }}>
                    <button
                      type="button"
                      onClick={() => setOffen(auf ? null : i)}
                      aria-expanded={auf}
                      className="flex w-full items-start justify-between gap-6 py-6 text-left"
                    >
                      <span
                        className="text-[19px] leading-snug sm:text-[22px]"
                        style={{
                          fontFamily: SANS,
                          fontWeight: 400,
                          letterSpacing: "-0.02em",
                          color: TINTE,
                        }}
                      >
                        {f.q}
                      </span>
                      <span className="mt-1.5 shrink-0" style={{ color: LEISE }}>
                        {auf ? (
                          <Minus className="h-[18px] w-[18px]" strokeWidth={1.3} />
                        ) : (
                          <Plus className="h-[18px] w-[18px]" strokeWidth={1.3} />
                        )}
                      </span>
                    </button>
                    <AnimatePresence initial={false}>
                      {auf && (
                        <motion.div
                          initial={ruhig ? false : { height: 0, opacity: 0 }}
                          animate={{ height: "auto", opacity: 1 }}
                          exit={ruhig ? undefined : { height: 0, opacity: 0 }}
                          transition={{ duration: ruhig ? 0 : 0.42, ease }}
                          className="overflow-hidden"
                        >
                          <p
                            className="max-w-[46rem] pb-7 text-[15px] leading-relaxed"
                            style={{ fontFamily: SANS, color: LEISE }}
                          >
                            {f.a}
                          </p>
                        </motion.div>
                      )}
                    </AnimatePresence>
                  </div>
                );
              })}
              <div style={{ borderTop: `1px solid ${KANTE}` }} />
            </div>
          </Auftritt>
        </div>
      </div>
    </section>
  );
}

/* ----------------------------------------------------------------- location */

function Ort() {
  return (
    <section id="location" className="scroll-mt-20" style={{ background: CREME }}>
      <div className="mx-auto grid max-w-[1240px] grid-cols-1 items-stretch gap-12 px-6 py-24 lg:grid-cols-2 lg:gap-16 lg:px-10 lg:py-32">
        <div className="order-2 flex flex-col justify-center lg:order-1">
          <Auftritt>
            <Kicker>Where</Kicker>
          </Auftritt>
          <Auftritt verzug={120} weich>
            <h2
              className="mt-6 max-w-[18ch] text-[32px] leading-[1.08] sm:text-[40px]"
              style={{ fontFamily: SANS, fontWeight: 400, letterSpacing: "-0.03em", color: TINTE }}
            >
              Training where it already fits your day.
            </h2>
          </Auftritt>
          <Auftritt verzug={240}>
            <p
              className="mt-8 max-w-[34rem] text-[15px] leading-relaxed"
              style={{ fontFamily: SANS, color: LEISE }}
            >
              Sessions happen in your home or at any private space that suits you. No commute either
              side, no queue for a rack, no audience. The equipment that is needed comes along.
            </p>
          </Auftritt>
          <Auftritt verzug={340}>
            <div className="mt-10 flex flex-wrap gap-2.5">
              {GEBIETE.map((g) => (
                <span
                  key={g}
                  className="px-4 py-2"
                  style={{
                    ...kicker,
                    fontSize: 10,
                    borderRadius: KNOPF,
                    background: WEISS,
                    color: LEISE,
                  }}
                >
                  {g}
                </span>
              ))}
            </div>
          </Auftritt>
        </div>

        <Auftritt verzug={160} className="order-1 h-full lg:order-2">
          <DriftBild
            src="/templates/kaleo/gallery-outdoor.webp"
            alt="Single leg work outdoors, mat and kettlebell on the path"
            staerke={48}
            className="h-full min-h-[24rem] lg:min-h-[32rem]"
            radius={KARTE}
          />
        </Auftritt>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ contact */

function Kontakt() {
  const felder = [
    { id: "kaleo-name", label: "Name", platz: "First and last name", art: "text" },
    { id: "kaleo-mail", label: "Email", platz: "name@example.com", art: "email" },
    { id: "kaleo-tel", label: "Phone", platz: "Optional", art: "tel" },
  ];

  return (
    <section id="contact" className="scroll-mt-20" style={{ background: NACHT, color: WEISS }}>
      <div className="mx-auto grid max-w-[1240px] grid-cols-1 gap-14 px-6 py-24 lg:grid-cols-2 lg:gap-20 lg:px-10 lg:py-32">
        <div className="flex flex-col justify-between gap-14">
          <div>
            <Auftritt>
              <Kicker hell>Contact</Kicker>
            </Auftritt>
            <Auftritt verzug={100} weich>
              <h2
                className="mt-6 max-w-[16ch] text-[34px] leading-[1.06] sm:text-[42px]"
                style={{
                  fontFamily: SANS,
                  fontWeight: 400,
                  letterSpacing: "-0.03em",
                  color: WEISS,
                }}
              >
                Ready for training with more behind it?
              </h2>
            </Auftritt>
            <Auftritt verzug={200}>
              <p
                className="mt-8 max-w-[32rem] text-[15px] leading-relaxed"
                style={{ fontFamily: SANS, color: "rgba(255,255,255,0.66)" }}
              >
                Tell me roughly what you are after. You get an answer within a day, and a first
                session costs nothing but an hour.
              </p>
            </Auftritt>
          </div>

          <Auftritt verzug={280}>
            <div>
              <p style={{ ...kicker, color: "rgba(255,255,255,0.45)" }}>Direct contact</p>
              <div className="mt-5 flex flex-col gap-3">
                <a
                  href={`mailto:${MARKE.mail}`}
                  className="inline-flex w-fit items-center gap-3 text-[15px] transition-opacity hover:opacity-75"
                  style={{ fontFamily: SANS, color: WEISS }}
                >
                  <Mail className="h-4 w-4" strokeWidth={1.3} />
                  {MARKE.mail}
                </a>
                <a
                  href={`tel:${MARKE.telefon.replace(/\s/g, "")}`}
                  className="inline-flex w-fit items-center gap-3 text-[15px] transition-opacity hover:opacity-75"
                  style={{ fontFamily: SANS, color: WEISS }}
                >
                  <Phone className="h-4 w-4" strokeWidth={1.3} />
                  {MARKE.telefon}
                </a>
              </div>
              <span
                className="mt-7 inline-flex items-center gap-3 px-4 py-2"
                style={{
                  ...kicker,
                  fontSize: 10,
                  borderRadius: KNOPF,
                  border: "1px solid rgba(255,255,255,0.22)",
                  color: "rgba(255,255,255,0.7)",
                }}
              >
                <span className="relative flex h-1.5 w-1.5">
                  <span
                    className="absolute inline-flex h-full w-full animate-ping rounded-full"
                    style={{ background: "rgba(255,255,255,0.5)" }}
                  />
                  <span
                    className="relative inline-flex h-1.5 w-1.5 rounded-full"
                    style={{ background: WEISS }}
                  />
                </span>
                Limited spots this season
              </span>
            </div>
          </Auftritt>
        </div>

        <Auftritt verzug={200}>
          <form
            className="flex flex-col gap-5"
            onSubmit={(e) => {
              e.preventDefault();
            }}
          >
            {felder.map((f) => (
              <div key={f.id}>
                <label
                  htmlFor={f.id}
                  className="mb-2.5 block"
                  style={{ ...kicker, fontSize: 10, color: "rgba(255,255,255,0.5)" }}
                >
                  {f.label}
                </label>
                <input
                  id={f.id}
                  type={f.art}
                  placeholder={f.platz}
                  className="w-full px-4 py-3.5 text-[15px] outline-none transition-colors placeholder:opacity-45 focus:border-white/45"
                  style={{
                    fontFamily: SANS,
                    borderRadius: KNOPF,
                    background: "rgba(255,255,255,0.05)",
                    border: "1px solid rgba(255,255,255,0.16)",
                    color: WEISS,
                  }}
                />
              </div>
            ))}
            <div>
              <label
                htmlFor="kaleo-text"
                className="mb-2.5 block"
                style={{ ...kicker, fontSize: 10, color: "rgba(255,255,255,0.5)" }}
              >
                Message
              </label>
              <textarea
                id="kaleo-text"
                rows={4}
                placeholder="A sentence or two about your goals"
                className="w-full resize-none px-4 py-3.5 text-[15px] outline-none transition-colors placeholder:opacity-45 focus:border-white/45"
                style={{
                  fontFamily: SANS,
                  borderRadius: KNOPF,
                  background: "rgba(255,255,255,0.05)",
                  border: "1px solid rgba(255,255,255,0.16)",
                  color: WEISS,
                }}
              />
            </div>
            <button
              type="submit"
              className="group mt-2 inline-flex w-fit items-center gap-2.5 px-8 py-4 transition-opacity hover:opacity-85"
              style={{ ...kicker, borderRadius: KNOPF, background: WEISS, color: TINTE }}
            >
              Send request
              <ArrowRight
                className="h-3.5 w-3.5 transition-transform duration-300 group-hover:translate-x-1"
                strokeWidth={1.5}
              />
            </button>
            <p
              className="text-[12px] leading-relaxed"
              style={{ fontFamily: SANS, color: "rgba(255,255,255,0.42)" }}
            >
              This form is a demo and does not send anything. All contact details on this page are
              placeholders.
            </p>
          </form>
        </Auftritt>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------- footer */

function Fusszeile() {
  return (
    <footer className="relative overflow-hidden" style={{ background: CREME }}>
      <div className="relative z-10 mx-auto max-w-[1240px] px-6 pt-20 lg:px-10">
        <div className="grid grid-cols-1 gap-12 lg:grid-cols-[1.7fr_1fr_1fr] lg:gap-16">
          <Auftritt>
            <div>
              <div className="flex items-center gap-3" style={{ color: TINTE }}>
                <Zeichen className="h-5 w-5" />
                <span
                  className="uppercase"
                  style={{ fontFamily: SANS, fontSize: 19, letterSpacing: "0.26em" }}
                >
                  {MARKE.name}
                </span>
              </div>
              <p
                className="mt-5 max-w-[22rem] text-[14px] leading-relaxed"
                style={{ fontFamily: SANS, color: LEISE }}
              >
                {MARKE.claim}. One trainer, your rooms, a plan that keeps going.
              </p>
              <div className="mt-7 flex flex-col gap-2.5 sm:flex-row sm:flex-wrap">
                {[
                  { Icon: Mail, wert: MARKE.mail, href: `mailto:${MARKE.mail}` },
                  {
                    Icon: Phone,
                    wert: MARKE.telefon,
                    href: `tel:${MARKE.telefon.replace(/\s/g, "")}`,
                  },
                ].map((k) => (
                  <a
                    key={k.wert}
                    href={k.href}
                    className="inline-flex w-fit items-center gap-2.5 px-4 py-2.5 text-[14px] transition-colors hover:opacity-75"
                    style={{
                      fontFamily: SANS,
                      borderRadius: KNOPF,
                      background: WEISS,
                      color: LEISE,
                    }}
                  >
                    <k.Icon className="h-3.5 w-3.5" strokeWidth={1.3} />
                    {k.wert}
                  </a>
                ))}
              </div>
            </div>
          </Auftritt>

          {FUSS_NAV.map((spalte, si) => (
            <Auftritt key={spalte.titel} verzug={90 + si * 70}>
              <div>
                <p style={{ ...kicker, color: LEISE }}>{spalte.titel}</p>
                <ul className="mt-5 flex flex-col gap-3.5">
                  {spalte.links.map((link) => (
                    <li key={link}>
                      <a
                        href="#start"
                        className="group inline-flex w-fit items-center text-[14px] transition-colors hover:opacity-70"
                        style={{ fontFamily: SANS, color: LEISE }}
                      >
                        <span
                          className="mr-0 h-px w-0 transition-all duration-300 group-hover:mr-2 group-hover:w-5"
                          style={{ background: TINTE }}
                        />
                        {link}
                      </a>
                    </li>
                  ))}
                </ul>
              </div>
            </Auftritt>
          ))}
        </div>

        <Auftritt verzug={200}>
          <div
            className="mt-14 flex flex-col gap-2 pt-6 sm:flex-row sm:items-center sm:justify-between"
            style={{ borderTop: `1px solid ${KANTE}` }}
          >
            <p className="text-[12px]" style={{ fontFamily: SANS, color: LEISE }}>
              © 2026 {MARKE.name}. All rights reserved.
            </p>
            <p className="text-[12px]" style={{ fontFamily: SANS, color: LEISE }}>
              Concept study. Brand, people, quotes and figures are invented.
            </p>
          </div>
        </Auftritt>
      </div>

      {/* Ueberdimensionierter Schriftzug, nach unten hin ausgeblendet. */}
      <div className="pointer-events-none relative z-0 -mt-4 select-none">
        <span
          aria-hidden="true"
          className="block translate-y-[38%] text-center leading-none"
          style={{
            fontFamily: SANS,
            fontSize: "26vw",
            fontWeight: 500,
            letterSpacing: "-0.05em",
            color: "rgba(17,17,17,0.05)",
            maskImage: "linear-gradient(to bottom, #000 12%, transparent 66%)",
            WebkitMaskImage: "linear-gradient(to bottom, #000 12%, transparent 66%)",
          }}
        >
          {MARKE.name}
        </span>
      </div>
    </footer>
  );
}

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

export function KaleoTrainingPage() {
  return (
    <div style={{ background: WEISS, color: TINTE, fontFamily: SANS }}>
      {/* Sehr feines Korn ueber der ganzen Seite. */}
      <div
        aria-hidden="true"
        className="pointer-events-none fixed inset-0 z-[60] opacity-[0.035] mix-blend-multiply"
        style={{ backgroundImage: KORN }}
      />
      <Kopfzeile />
      <Leiste />
      <main>
        <Held />
        <Haltung />
        <Leistungen />
        <Ueber />
        <Methode />
        <Band />
        <Zielfinder />
        <Zahlen />
        <Galerie />
        <Stimmen />
        <Fragen />
        <Ort />
        <Kontakt />
      </main>
      <Fusszeile />
    </div>
  );
}

export default KaleoTrainingPage;
FAQ

Häufige Fragen

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

Deine Frage war nicht dabei? Wir helfen gern weiter.

Kontakt aufnehmen