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 Seite ist eine Datei, lädt aber 11 Bilder (1,1 MB) aus public/. Kopierst du nur den Code, fehlen sie. Das Paket enthält beides an den richtigen Pfaden.

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 WHITE = "#ffffff";
const CREME = "#f5f5f4";
const SAND = "#ebebe9";
const INK = "#111111";
const NIGHT = "#17181a";
const QUIET = "#6d6d6b";
const EDGE = "#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: tracked capitals, the same everywhere. */
const kicker: CSSProperties = {
  fontFamily: SANS,
  fontSize: 11,
  letterSpacing: "0.22em",
  textTransform: "uppercase",
};

/** Buttons stay slightly rounded, surfaces a touch more generous. */
const BUTTON = 6;
const CARD = 14;

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

// Very fine grain over the whole page, so the surfaces have some life.
const GRAIN =
  "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 MARK = {
  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" },
];

// Order and labels of the section rail at the right edge.
const BAR = [
  { 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" },
];

/* All sections in order, including those without an entry in the rail, and the dark ones
   below. The rail needs both in order to change its colour. */
const ALL_SECTIONS = [
  "start",
  "philosophy",
  "services",
  "about",
  "method",
  "band",
  "goalfinder",
  "numbers",
  "gallery",
  "voices",
  "questions",
  "location",
  "contact",
];
const DARK = new Set(["start", "band", "contact"]);

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

const SERVICES = [
  {
    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 METHOD = [
  {
    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 POSEPOINTS = [
  { Icon: ShieldCheck, label: "Discreet" },
  { Icon: Fingerprint, label: "Individual" },
  { Icon: Award, label: "Professional" },
];

const FINDER: {
  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 FIGURES = [
  { Icon: History, value: 12, suffix: "+", label: "Years of experience" },
  { Icon: Users, value: 80, suffix: "+", label: "Clients guided" },
  { Icon: Repeat, value: 6000, suffix: "+", label: "Sessions delivered" },
  { Icon: Clock3, value: 24, suffix: "h", label: "Reply time" },
];

const GALLERY = [
  { 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 VOICES = [
  {
    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 QUESTIONS = [
  {
    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 AREAS = ["Old town", "Riverside", "Hillside", "Lakeside", "Airport quarter"];

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

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

/* A single appearance on scroll: a slight offset upwards, optionally a soft blur to start
   from. Under reduced motion everything is there at once. */
function Entrance({
  children,
  delay = 0,
  weich = false,
  className = "",
  style,
}: {
  children: ReactNode;
  delay?: number;
  weich?: boolean;
  className?: string;
  style?: CSSProperties;
}) {
  const reduced = useReducedMotion();
  const ref = useRef<HTMLDivElement>(null);
  const visible = useInView(ref, { once: true, margin: "-70px" });
  const start = weich ? { opacity: 0, y: 24, filter: "blur(10px)" } : { opacity: 0, y: 22 };
  const shown = weich ? { opacity: 1, y: 0, filter: "blur(0px)" } : { opacity: 1, y: 0 };

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

// A picture that travels a little slower than the page on scroll.
function DriftImage({
  src,
  alt,
  strength = 60,
  className = "",
  radius = 0,
}: {
  src: string;
  alt: string;
  strength?: number;
  className?: string;
  radius?: number;
}) {
  const reduced = useReducedMotion();
  const ref = useRef<HTMLDivElement>(null);
  const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] });
  const y = useTransform(scrollYProgress, [0, 1], [-strength, strength]);

  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: reduced ? 0 : y,
          height: `calc(100% + ${strength * 2}px)`,
          top: -strength,
        }}
      />
    </div>
  );
}

/** A number that counts up once when it is reached. */
function CountUp({ to, suffix }: { to: number; suffix: string }) {
  const reduced = useReducedMotion();
  const ref = useRef<HTMLSpanElement>(null);
  const visible = useInView(ref, { once: true, margin: "-60px" });
  const [value, setValue] = useState(0);

  useEffect(() => {
    if (reduced) {
      setValue(to);
      return;
    }
    if (!visible) return;
    let frame = 0;
    const duration = 1500;
    const start = performance.now();
    const tick = (now: number) => {
      const p = Math.min((now - start) / duration, 1);
      setValue(Math.round((1 - Math.pow(1 - p, 3)) * to));
      if (p < 1) frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [to, reduced, visible]);

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

/** A small kicker above every heading. */
function Kicker({ children, bright = false }: { children: ReactNode; bright?: boolean }) {
  return <p style={{ ...kicker, color: bright ? "rgba(255,255,255,0.6)" : QUIET }}>{children}</p>;
}

function PrimaryButton({
  href,
  children,
  bright = false,
}: {
  href: string;
  children: ReactNode;
  bright?: 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: BUTTON,
        background: bright ? WHITE : INK,
        color: bright ? INK : WHITE,
      }}
    >
      {children}
      <ArrowRight
        className="h-3.5 w-3.5 transition-transform duration-300 group-hover:translate-x-1"
        strokeWidth={1.5}
      />
    </a>
  );
}

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

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

function Header() {
  const [scrolled, setScrolled] = useState(false);
  const [open, setOpen] = useState(false);

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

  useEffect(() => {
    if (!open) return;
    const zu = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
    document.addEventListener("keydown", zu);
    // While the full-screen menu is open, the page below must not scroll along.
    const vorher = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      document.removeEventListener("keydown", zu);
      document.body.style.overflow = vorher;
    };
  }, [open]);

  // Over the hero and in the open menu the type is light, otherwise dark.
  const bright = !scrolled || open;
  const front = bright ? WHITE : INK;

  return (
    <>
      <header
        className="sticky top-0 z-50 transition-colors duration-300"
        style={{
          background: bright ? "transparent" : "rgba(255,255,255,0.94)",
          borderBottom: `1px solid ${bright ? "transparent" : EDGE}`,
        }}
      >
        <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: front }}>
            <Glyph className="h-5 w-5" />
            <span
              style={{ fontFamily: SANS, fontSize: 19, letterSpacing: "0.26em" }}
              className="uppercase"
            >
              {MARK.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: front,
                  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: BUTTON,
                border: `1px solid ${bright ? "rgba(255,255,255,0.5)" : EDGE}`,
                color: front,
              }}
            >
              Request a session
            </a>

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

      {/* Full-screen menu on the phone. It deliberately sits outside the header: the
          header takes a background of its own on scroll, and an element inside it would
          be tied to its height. */}
      <AnimatePresence>
        {open && (
          <motion.div
            className="fixed inset-0 z-40 flex flex-col md:hidden"
            style={{ background: NIGHT, color: WHITE }}
            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={() => setOpen(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={() => setOpen(false)}
                className="mb-8 flex items-center justify-center px-5 py-4 transition-colors"
                style={{
                  ...kicker,
                  borderRadius: BUTTON,
                  border: "1px solid rgba(255,255,255,0.55)",
                  color: WHITE,
                }}
              >
                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:${MARK.mail}`}>{MARK.mail}</a>
                <a href={`tel:${MARK.telefon.replace(/\s/g, "")}`}>{MARK.telefon}</a>
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </>
  );
}

function Glyph({ 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) */

/* Section rail at the right edge. With twelve sections it is otherwise hard to see where
   you are and how much is still to come. Only from desktop on, on narrow devices the header
   menu does the job. */
function Bar() {
  const [active, setActive] = useState(BAR[0].id);
  /* Which section currently lies BEHIND the rail? That is not the same as the active entry:
     the hero, the image band and the contact part are dark, and there the rail has to write
     light, otherwise it disappears into the picture. */
  const [dark, setDark] = useState(true);

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

  useEffect(() => {
    /* An observer of zero height exactly on the middle of the window: it reports the
       section lying there, whether or not it appears in the rail. */
    const observer = new IntersectionObserver(
      (entries) => {
        for (const e of entries) {
          if (e.isIntersecting) setDark(DARK.has(e.target.id));
        }
      },
      { rootMargin: "-50% 0px -50% 0px", threshold: 0 },
    );
    const knot = ALL_SECTIONS.map((id) => document.getElementById(id)).filter(
      (n): n is HTMLElement => n !== null,
    );
    knot.forEach((n) => observer.observe(n));
    return () => observer.disconnect();
  }, []);

  const typeface = dark ? "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">
        {BAR.map((s) => {
          const an = s.id === active;
          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(${typeface})`,
                    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(${typeface},${an ? 0.75 : 0.3})`,
                  }}
                />
              </a>
            </li>
          );
        })}
      </ul>
    </nav>
  );
}

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

function Held() {
  const reduced = useReducedMotion();
  const [active, setActive] = useState(0);

  useEffect(() => {
    if (reduced) {
      setActive(0);
      return;
    }
    const clock = setInterval(() => setActive((i) => (i + 1) % HELD_IMAGES.length), 6000);
    return () => clearInterval(clock);
  }, [reduced]);

  return (
    <section id="start" className="relative -mt-20 overflow-hidden">
      <div className="absolute inset-0">
        {HELD_IMAGES.map((image, i) => (
          <motion.img
            key={image.src}
            src={image.src}
            alt={image.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={
              reduced
                ? { opacity: i === active ? 1 : 0 }
                : {
                    opacity: i === active ? 1 : 0,
                    scale: i === active ? 1.14 : 1.03,
                    x: i === active ? "-1.5%" : "0%",
                  }
            }
            transition={{
              opacity: { duration: 1.2, ease: "easeInOut" },
              scale: { duration: 18, ease: "linear" },
              x: { duration: 18, ease: "linear" },
            }}
          />
        ))}
        {/* No colour filter, only a neutral gradient, so the type carries. */}
        <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={reduced ? 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: WHITE }}
            initial={reduced ? false : { opacity: 0, y: 26, filter: "blur(12px)" }}
            animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
            transition={{ duration: 0.95, ease, delay: reduced ? 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={reduced ? false : { opacity: 0, y: 18 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.8, ease, delay: reduced ? 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={reduced ? false : { opacity: 0, y: 18 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.8, ease, delay: reduced ? 0 : 0.44 }}
          >
            <PrimaryButton href="#contact" bright>
              Request first session
            </PrimaryButton>
            <SecondButton href="#about" bright>
              More about Kaleo
            </SecondButton>
          </motion.div>
        </div>
      </div>
    </section>
  );
}

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

function Pose() {
  const reduced = useReducedMotion();

  return (
    <section id="philosophy" className="relative overflow-hidden" style={{ background: CREME }}>
      {/* Three very fine rings travelling slowly outwards. Purely decorative. Under
          reduced motion a single ring stands still; the tree stays the same so server and
          client render the same thing. */}
      <div
        className="pointer-events-none absolute inset-0 flex items-center justify-center"
        aria-hidden="true"
      >
        {[0, 2.4, 4.8].map((delay, i) => (
          <motion.span
            key={delay}
            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={
              reduced
                ? { scale: 1, opacity: i === 0 ? 0.5 : 0 }
                : { scale: [0.4, 1.6], opacity: [0, 0.55, 0] }
            }
            transition={
              reduced
                ? { duration: 0 }
                : {
                    duration: 7.2,
                    ease: "easeOut",
                    repeat: Infinity,
                    delay: delay,
                    times: [0, 1],
                  }
            }
          />
        ))}
      </div>

      <div className="relative z-10 mx-auto max-w-[54rem] px-6 py-24 text-center lg:py-36">
        <Entrance>
          <Kicker>Philosophy</Kicker>
        </Entrance>
        <Entrance delay={120} weich>
          <blockquote
            className="mt-10 text-[28px] leading-[1.28] sm:text-[36px] lg:text-[42px]"
            style={{ ...serif, color: INK }}
          >
            &ldquo;Training begins where the mind wants to stop. That is where the strength you keep
            gets built.&rdquo;
          </blockquote>
        </Entrance>
        <Entrance delay={240}>
          <p
            className="mx-auto mt-10 max-w-[36rem] text-[15px] leading-relaxed"
            style={{ fontFamily: SANS, color: QUIET }}
          >
            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>
        </Entrance>
      </div>
    </section>
  );
}

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

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

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

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

function Ueber() {
  return (
    <section id="about" className="scroll-mt-20" style={{ background: WHITE }}>
      {/* The picture runs to the left edge of the screen, the text keeps the column width
          of the other sections. */}
      <div className="grid grid-cols-1 items-stretch lg:grid-cols-2">
        <DriftImage
          src="/templates/kaleo/studio-portrait.webp"
          alt="A trainer standing at a tall window before a session"
          strength={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">
          <Entrance>
            <Kicker>About Kaleo</Kicker>
          </Entrance>
          <Entrance delay={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: INK }}
            >
              Training with personal ambition.
            </h2>
          </Entrance>
          <Entrance delay={200}>
            <p
              className="mt-8 max-w-[34rem] text-[15px] leading-relaxed"
              style={{ fontFamily: SANS, color: QUIET }}
            >
              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>
          </Entrance>
          <Entrance delay={300}>
            <div className="mt-10 flex flex-wrap gap-x-9 gap-y-5">
              {POSEPOINTS.map((p) => (
                <div key={p.label} className="flex items-center gap-3">
                  <p.Icon
                    className="h-[18px] w-[18px]"
                    strokeWidth={1.3}
                    style={{ color: QUIET }}
                  />
                  <span style={{ ...kicker, fontSize: 11, color: INK }}>{p.label}</span>
                </div>
              ))}
            </div>
          </Entrance>
        </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]">
          <Entrance>
            <Kicker>Method</Kicker>
          </Entrance>
          <Entrance delay={100} weich>
            <h2
              className="mt-6 text-[32px] leading-[1.08] sm:text-[40px]"
              style={{ fontFamily: SANS, fontWeight: 400, letterSpacing: "-0.03em", color: INK }}
            >
              Four steps, then it repeats.
            </h2>
          </Entrance>
        </div>

        <div className="mt-14 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:mt-20 lg:grid-cols-4">
          {METHOD.map((s, i) => (
            <Entrance key={s.name} delay={170 + i * 110} className="h-full">
              <div
                className="group flex h-full flex-col p-8 transition-colors duration-500"
                style={{ background: WHITE, borderRadius: CARD }}
              >
                <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: INK }}
                  />
                  <span style={{ ...kicker, fontSize: 10, color: "rgba(17,17,17,0.6)" }}>
                    {s.n}
                  </span>
                </div>
                <h3
                  className="mt-8 text-[21px] leading-tight"
                  style={{
                    fontFamily: SANS,
                    fontWeight: 400,
                    letterSpacing: "-0.02em",
                    color: INK,
                  }}
                >
                  {s.name}
                </h3>
                <p
                  className="mt-3.5 text-[14px] leading-relaxed"
                  style={{ fontFamily: SANS, color: QUIET }}
                >
                  {s.text}
                </p>
              </div>
            </Entrance>
          ))}
        </div>
      </div>
    </section>
  );
}

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

/* A wide picture across the page that travels slower than the scroll, and on it the only
   second sentence in the serif. The gradient is a neutral grey, no colour filter, so the
   green of the picture stays. */
function Band() {
  return (
    <section id="band" className="relative h-[70vh] min-h-[25rem] w-full overflow-hidden">
      {/* The picture stands in the flow and fills the section, scrim and sentence lie
          absolutely above it. */}
      <DriftImage
        src="/templates/kaleo/gallery-partner.webp"
        alt="Two people holding a plank, hands meeting in the middle"
        strength={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">
          <Entrance weich>
            <p
              className="text-[28px] leading-[1.24] sm:text-[38px] lg:text-[46px]"
              style={{ ...serif, color: WHITE }}
            >
              Training that adapts to your life, not the other way around.
            </p>
          </Entrance>
        </div>
      </div>
    </section>
  );
}

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

function Finder() {
  const reduced = useReducedMotion();
  const [step, setStep] = useState(0);
  const [choice, setChoice] = useState<string[]>([]);
  const done = choice.length === FINDER.length;

  const select = (option: string) => {
    const fresh = [...choice.slice(0, step), option];
    setChoice(fresh);
    if (step < FINDER.length - 1) setStep(step + 1);
  };

  const freshStarten = () => {
    setChoice([]);
    setStep(0);
  };

  const two = (n: number) => `0${n}`.slice(-2);
  const change = { duration: reduced ? 0 : 0.42, ease };

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

        <Entrance delay={220}>
          <motion.div
            layout={!reduced}
            transition={{ layout: change }}
            className="mt-12 p-8 sm:p-12 lg:mt-16 lg:p-14"
            style={{ background: CREME, borderRadius: CARD }}
          >
            <AnimatePresence mode="wait" initial={false}>
              {!done ? (
                <motion.div
                  key={`schritt-${step}`}
                  initial={reduced ? false : { opacity: 0, y: 14 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduced ? undefined : { opacity: 0, y: -12 }}
                  transition={change}
                >
                  <div className="flex flex-wrap items-baseline justify-between gap-4">
                    <p style={{ ...kicker, color: QUIET }}>
                      Step {two(step + 1)}
                      <span style={{ opacity: 0.5 }}> / {two(FINDER.length)}</span>
                    </p>
                    {choice.length > 0 && (
                      <div className="flex flex-wrap justify-end gap-2">
                        {choice.map((w) => (
                          <span
                            key={w}
                            className="px-3 py-1"
                            style={{
                              ...kicker,
                              fontSize: 10,
                              borderRadius: BUTTON,
                              border: `1px solid ${EDGE}`,
                              color: QUIET,
                            }}
                          >
                            {w}
                          </span>
                        ))}
                      </div>
                    )}
                  </div>

                  <div className="mt-5 flex gap-1.5">
                    {FINDER.map((_, i) => (
                      <motion.span
                        key={i}
                        className="h-1.5 rounded-full"
                        animate={{
                          width: i < choice.length || i === step ? 24 : 6,
                          backgroundColor:
                            i < choice.length
                              ? "rgba(17,17,17,1)"
                              : i === step
                                ? "rgba(17,17,17,0.4)"
                                : "rgba(17,17,17,0.15)",
                        }}
                        transition={{ duration: reduced ? 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: INK,
                    }}
                  >
                    {FINDER[step].frage}
                  </h3>

                  <div className="mt-9 grid grid-cols-1 gap-3 sm:grid-cols-2">
                    {FINDER[step].optionen.map((o, oi) => (
                      <motion.button
                        key={o.label}
                        type="button"
                        onClick={() => select(o.label)}
                        className="group flex items-center justify-between gap-4 px-6 py-5 text-left transition-colors duration-300"
                        style={{ background: WHITE, borderRadius: CARD }}
                        initial={reduced ? false : { opacity: 0, y: 12 }}
                        animate={{ opacity: 1, y: 0 }}
                        transition={{ duration: 0.45, ease, delay: reduced ? 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: INK }}
                            />
                          </span>
                          <span style={{ fontFamily: SANS, fontSize: 16, color: INK }}>
                            {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: QUIET }}
                        />
                      </motion.button>
                    ))}
                  </div>
                </motion.div>
              ) : (
                <motion.div
                  key="ergebnis"
                  initial={reduced ? false : { opacity: 0, y: 14 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduced ? undefined : { opacity: 0, y: -12 }}
                  transition={change}
                >
                  <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: INK,
                    }}
                  >
                    In-home sessions, built around{" "}
                    <span style={{ ...serif, fontSize: "1.06em" }}>{choice[0].toLowerCase()}</span>.
                  </p>

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

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

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

function Figures() {
  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]">
          <Entrance>
            <Kicker>In numbers</Kicker>
          </Entrance>
          <Entrance delay={100} weich>
            <h2
              className="mt-6 text-[32px] leading-[1.08] sm:text-[40px]"
              style={{ fontFamily: SANS, fontWeight: 400, letterSpacing: "-0.03em", color: INK }}
            >
              Experience you can measure.
            </h2>
          </Entrance>
        </div>

        <div className="mt-14 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:mt-20 lg:grid-cols-4">
          {FIGURES.map((z, i) => (
            <Entrance key={z.label} delay={150 + i * 100} className="h-full">
              <div
                className="group h-full p-8 transition-colors duration-500 sm:p-10"
                style={{ background: WHITE, borderRadius: CARD }}
              >
                <z.Icon
                  className="h-[22px] w-[22px] transition-transform duration-500 group-hover:-translate-y-0.5"
                  strokeWidth={1.2}
                  style={{ color: QUIET }}
                />
                <p
                  className="mt-9 text-[44px] leading-none"
                  style={{
                    fontFamily: SANS,
                    fontWeight: 400,
                    letterSpacing: "-0.045em",
                    color: INK,
                  }}
                >
                  <CountUp to={z.value} suffix={z.suffix} />
                </p>
                <p className="mt-4" style={{ ...kicker, color: QUIET }}>
                  {z.label}
                </p>
              </div>
            </Entrance>
          ))}
        </div>
      </div>
    </section>
  );
}

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

function Gallery() {
  const reduced = useReducedMotion();
  const [proImage, setProImage] = useState(1);
  const [index, setIndex] = useState(0);

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

  // The last possible index, so there is never a gap on the right.
  const max = Math.max(0, GALLERY.length - proImage);
  useEffect(() => {
    setIndex((i) => Math.min(i, Math.max(0, GALLERY.length - proImage)));
  }, [proImage]);

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

  return (
    <section id="gallery" className="scroll-mt-20" style={{ background: WHITE }}>
      <div
        role="group"
        aria-roledescription="carousel"
        aria-label="Image gallery"
        tabIndex={0}
        onKeyDown={(e) => {
          if (e.key === "ArrowLeft") {
            e.preventDefault();
            back();
          } else if (e.key === "ArrowRight") {
            e.preventDefault();
            next();
          }
        }}
        className="relative w-full overflow-hidden"
      >
        <motion.div
          className="flex"
          animate={{ x: `-${index * (100 / proImage)}%` }}
          transition={{ duration: reduced ? 0 : 0.78, ease }}
        >
          {GALLERY.map((image, i) => (
            <div
              key={image.src}
              className="aspect-[4/5] shrink-0 px-[2px] md:aspect-[3/4]"
              style={{ width: `${100 / proImage}%` }}
            >
              <div className="h-full w-full overflow-hidden">
                <img
                  src={image.src}
                  alt={image.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: back, label: "Previous images", seite: "left-4" },
          { Icon: ChevronRight, tun: next, 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: WHITE, color: INK }}
          >
            <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 ? WHITE : "rgba(255,255,255,0.5)",
              }}
            />
          ))}
        </div>
      </div>
    </section>
  );
}

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

function Stimmen() {
  const reduced = useReducedMotion();
  const [active, setActive] = useState(0);
  const startX = useRef<number | null>(null);
  const now = VOICES[active];

  const go = (heading: number) => setActive((v) => (v + heading + VOICES.length) % VOICES.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">
        <Entrance>
          <Kicker>Voices</Kicker>
        </Entrance>
        <Entrance delay={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: INK }}
          >
            What clients say afterwards.
          </h2>
        </Entrance>

        <Entrance delay={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) go(dx < 0 ? 1 : -1);
              startX.current = null;
            }}
          >
            <div className="min-h-[11rem] sm:min-h-[10rem]">
              <AnimatePresence mode="wait" initial={false}>
                <motion.p
                  key={active}
                  className="text-[22px] leading-[1.4] sm:text-[28px]"
                  style={{ ...serif, color: INK }}
                  initial={reduced ? false : { opacity: 0, y: 14 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduced ? undefined : { opacity: 0, y: -12 }}
                  transition={{ duration: reduced ? 0 : 0.45, ease }}
                >
                  <span style={{ color: "rgba(17,17,17,0.45)" }}>&ldquo;</span>
                  {now.zitat}
                  <span style={{ color: "rgba(17,17,17,0.45)" }}>&rdquo;</span>
                </motion.p>
              </AnimatePresence>
            </div>

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

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

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

function Fragen() {
  const reduced = useReducedMotion();
  const [open, setOpen] = useState<number | null>(0);

  return (
    <section id="questions" className="scroll-mt-20" style={{ background: WHITE }}>
      <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">
          {/* The headline stays put while the list runs, otherwise the left column stands
              empty for half the section. */}
          <div className="lg:sticky lg:top-28 lg:self-start">
            <Entrance>
              <Kicker>Questions</Kicker>
            </Entrance>
            <Entrance delay={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: INK,
                }}
              >
                Asked before every first session.
              </h2>
            </Entrance>
          </div>

          <Entrance delay={180}>
            <div>
              {QUESTIONS.map((f, i) => {
                const auf = open === i;
                return (
                  <div key={f.q} style={{ borderTop: `1px solid ${EDGE}` }}>
                    <button
                      type="button"
                      onClick={() => setOpen(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: INK,
                        }}
                      >
                        {f.q}
                      </span>
                      <span className="mt-1.5 shrink-0" style={{ color: QUIET }}>
                        {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={reduced ? false : { height: 0, opacity: 0 }}
                          animate={{ height: "auto", opacity: 1 }}
                          exit={reduced ? undefined : { height: 0, opacity: 0 }}
                          transition={{ duration: reduced ? 0 : 0.42, ease }}
                          className="overflow-hidden"
                        >
                          <p
                            className="max-w-[46rem] pb-7 text-[15px] leading-relaxed"
                            style={{ fontFamily: SANS, color: QUIET }}
                          >
                            {f.a}
                          </p>
                        </motion.div>
                      )}
                    </AnimatePresence>
                  </div>
                );
              })}
              <div style={{ borderTop: `1px solid ${EDGE}` }} />
            </div>
          </Entrance>
        </div>
      </div>
    </section>
  );
}

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

function Place() {
  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">
          <Entrance>
            <Kicker>Where</Kicker>
          </Entrance>
          <Entrance delay={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: INK }}
            >
              Training where it already fits your day.
            </h2>
          </Entrance>
          <Entrance delay={240}>
            <p
              className="mt-8 max-w-[34rem] text-[15px] leading-relaxed"
              style={{ fontFamily: SANS, color: QUIET }}
            >
              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>
          </Entrance>
          <Entrance delay={340}>
            <div className="mt-10 flex flex-wrap gap-2.5">
              {AREAS.map((g) => (
                <span
                  key={g}
                  className="px-4 py-2"
                  style={{
                    ...kicker,
                    fontSize: 10,
                    borderRadius: BUTTON,
                    background: WHITE,
                    color: QUIET,
                  }}
                >
                  {g}
                </span>
              ))}
            </div>
          </Entrance>
        </div>

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

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

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

  return (
    <section id="contact" className="scroll-mt-20" style={{ background: NIGHT, color: WHITE }}>
      <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>
            <Entrance>
              <Kicker bright>Contact</Kicker>
            </Entrance>
            <Entrance delay={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: WHITE,
                }}
              >
                Ready for training with more behind it?
              </h2>
            </Entrance>
            <Entrance delay={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>
            </Entrance>
          </div>

          <Entrance delay={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:${MARK.mail}`}
                  className="inline-flex w-fit items-center gap-3 text-[15px] transition-opacity hover:opacity-75"
                  style={{ fontFamily: SANS, color: WHITE }}
                >
                  <Mail className="h-4 w-4" strokeWidth={1.3} />
                  {MARK.mail}
                </a>
                <a
                  href={`tel:${MARK.telefon.replace(/\s/g, "")}`}
                  className="inline-flex w-fit items-center gap-3 text-[15px] transition-opacity hover:opacity-75"
                  style={{ fontFamily: SANS, color: WHITE }}
                >
                  <Phone className="h-4 w-4" strokeWidth={1.3} />
                  {MARK.telefon}
                </a>
              </div>
              <span
                className="mt-7 inline-flex items-center gap-3 px-4 py-2"
                style={{
                  ...kicker,
                  fontSize: 10,
                  borderRadius: BUTTON,
                  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: WHITE }}
                  />
                </span>
                Limited spots this season
              </span>
            </div>
          </Entrance>
        </div>

        <Entrance delay={200}>
          <form
            className="flex flex-col gap-5"
            onSubmit={(e) => {
              e.preventDefault();
            }}
          >
            {fields.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.kind}
                  placeholder={f.place}
                  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: BUTTON,
                    background: "rgba(255,255,255,0.05)",
                    border: "1px solid rgba(255,255,255,0.16)",
                    color: WHITE,
                  }}
                />
              </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: BUTTON,
                  background: "rgba(255,255,255,0.05)",
                  border: "1px solid rgba(255,255,255,0.16)",
                  color: WHITE,
                }}
              />
            </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: BUTTON, background: WHITE, color: INK }}
            >
              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.6)" }}
            >
              This form is a demo and does not send anything. All contact details on this page are
              placeholders.
            </p>
          </form>
        </Entrance>
      </div>
    </section>
  );
}

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

function Footer() {
  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">
          <Entrance>
            <div>
              <div className="flex items-center gap-3" style={{ color: INK }}>
                <Glyph className="h-5 w-5" />
                <span
                  className="uppercase"
                  style={{ fontFamily: SANS, fontSize: 19, letterSpacing: "0.26em" }}
                >
                  {MARK.name}
                </span>
              </div>
              <p
                className="mt-5 max-w-[22rem] text-[14px] leading-relaxed"
                style={{ fontFamily: SANS, color: QUIET }}
              >
                {MARK.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, value: MARK.mail, href: `mailto:${MARK.mail}` },
                  {
                    Icon: Phone,
                    value: MARK.telefon,
                    href: `tel:${MARK.telefon.replace(/\s/g, "")}`,
                  },
                ].map((k) => (
                  <a
                    key={k.value}
                    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: BUTTON,
                      background: WHITE,
                      color: QUIET,
                    }}
                  >
                    <k.Icon className="h-3.5 w-3.5" strokeWidth={1.3} />
                    {k.value}
                  </a>
                ))}
              </div>
            </div>
          </Entrance>

          {FOOT_NAV.map((column, si) => (
            <Entrance key={column.title} delay={90 + si * 70}>
              <div>
                <p style={{ ...kicker, color: QUIET }}>{column.title}</p>
                <ul className="mt-5 flex flex-col gap-3.5">
                  {column.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: QUIET }}
                      >
                        <span
                          className="mr-0 h-px w-0 transition-all duration-300 group-hover:mr-2 group-hover:w-5"
                          style={{ background: INK }}
                        />
                        {link}
                      </a>
                    </li>
                  ))}
                </ul>
              </div>
            </Entrance>
          ))}
        </div>

        <Entrance delay={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 ${EDGE}` }}
          >
            <p className="text-[12px]" style={{ fontFamily: SANS, color: QUIET }}>
              © 2026 {MARK.name}. All rights reserved.
            </p>
            <p className="text-[12px]" style={{ fontFamily: SANS, color: QUIET }}>
              Concept study. Brand, people, quotes and figures are invented.
            </p>
          </div>
        </Entrance>
      </div>

      {/* Oversized lettering, faded out towards the bottom. */}
      <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%)",
          }}
        >
          {MARK.name}
        </span>
      </div>
    </footer>
  );
}

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

export function KaleoTrainingPage() {
  return (
    <div style={{ background: WHITE, color: INK, fontFamily: SANS }}>
      {/* Very fine grain over the whole page. */}
      <div
        aria-hidden="true"
        className="pointer-events-none fixed inset-0 z-[60] opacity-[0.035] mix-blend-multiply"
        style={{ backgroundImage: GRAIN }}
      />
      <Header />
      <Bar />
      <main>
        <Held />
        <Pose />
        <Services />
        <Ueber />
        <Methode />
        <Band />
        <Finder />
        <Figures />
        <Gallery />
        <Stimmen />
        <Fragen />
        <Place />
        <Contact />
      </main>
      <Footer />
    </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