Zum Inhalt springen
Alle Templates

Templates/Torwart-Coaching

Eine ausdrucksstarke Full-Page-Vorlage für Torwart-Coaching, Akademien und Camps. Ein geschichteter Hero mit freigestelltem Torwart, ein scrollgesteuertes Statement und ein interaktiver Fünf-Stufen-Methoden-Explorer tragen den oberen Teil der Seite. Ein Produkt-Orbit, eine Portal-Vorschau mit wechselnden Trainingseinheiten und eine großflächige Abschlussszene runden sie ab. Ein Grün-Akzent, echte Fotografie, ruhige Bewegung durchgehend.

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 { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import {
  AnimatePresence,
  motion,
  useReducedMotion,
  useScroll,
  useTransform,
  type Variants,
} from "framer-motion";
import {
  ArrowRight,
  ArrowUpRight,
  BookOpen,
  CalendarDays,
  ChevronDown,
  Compass,
  Crosshair,
  Dumbbell,
  Footprints,
  Goal,
  Hand,
  Instagram,
  LayoutDashboard,
  Linkedin,
  Mail,
  Menu,
  Newspaper,
  Play,
  Shield,
  Smartphone,
  Sparkles,
  Target,
  Trophy,
  Users,
  X,
  Youtube,
  type LucideIcon,
} from "lucide-react";

// A neutral, invented brand: "Cleansheet", a goalkeeper coaching platform for
// clubs, academies and camps. No real people, clubs or licences. Built from a
// method (five coaching stages), a small product ecosystem and a planning
// portal. One pitch green accent, a near black complement for the two dark
// scenes, and the five approved photos from the brand's own shoot, reused
// across sections where a second or third image would otherwise repeat a face
// or a sponsor mark. English only, no language toggle.

const ease = [0.22, 1, 0.36, 1] as const;
const BRAND = "Cleansheet";

/* May-green palette: a warm, yellowish grass green rather than the cooler
   pitch tone this started with. Still grass, not petrol, but closer to
   fresh spring turf than a TV-broadcast lawn. */
const ACCENT = "#84af2e"; // Primary (buttons, active states, icons)
const ACCENT_DARK = "#587821"; // Deep olive green (small text, links, hover)
const ACCENT_BRIGHT = "#d8ec6e"; // Fresh yellow-lime, used sparingly on dark grounds
const TINT = "#f1f5e4"; // Pale yellow-green surface tint
const INK = "#10160f"; // Near black
const MUTED = "#5c6a60"; // Muted grey green text
const SURFACE = "#f3f6f3"; // Light grey green surface
const CHARCOAL = "#0c110d"; // Near black, dark sections

/* Images (URL strings only, no imports). Five approved photos, reused where
   a section needs more visuals than the shoot provided. */
// Every slot on this page gets its own photograph. Nothing is used twice,
// which is why the five method stages each carry their own frame instead of
// sharing one.
const IMG = {
  heroScene: "/templates/goalsquare/hero-scene.webp",
  keeper: "/templates/goalsquare/keeper-layer.webp",
  athlete: "/templates/goalsquare/athlete-focus.webp",
  coach: "/templates/goalsquare/coach-portrait.webp",
  academy: "/templates/goalsquare/case-academy.webp",
  camps: "/templates/goalsquare/case-camps.webp",
  fieldWide: "/templates/goalsquare/field-wide.webp",
  hands: "/templates/goalsquare/stage-hands.webp",
  footwork: "/templates/goalsquare/stage-footwork.webp",
  ground: "/templates/goalsquare/stage-ground.webp",
  turfLight: "/templates/goalsquare/turf-light.webp",
  orbitGreen: "/templates/goalsquare/orbit-green.webp",
  ctaGoal: "/templates/goalsquare/cta-goal.webp",
};

/* ------------------------------------------------------------------ *
 * Scoped styles
 * ------------------------------------------------------------------ */

const scopedStyles = `
@import url('https://fonts.googleapis.com/css2?family=Figtree:wght@400;500;600&family=Sora:wght@600;700;800&display=swap');

.cs-root {
  font-family: "Figtree", ui-sans-serif, system-ui, sans-serif;
  font-weight: 400;
  color: ${INK};
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}
.cs-root h1, .cs-root h2, .cs-root h3, .cs-root h4 {
  font-family: "Sora", ui-sans-serif, system-ui, sans-serif;
  font-weight: 700;
  letter-spacing: -0.026em;
  line-height: 1.05;
}
.cs-root .font-display { font-family: "Sora", ui-sans-serif, system-ui, sans-serif; }
.cs-root .mono-label {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}

.cs-container {
  width: 100%;
  margin-inline: auto;
  padding-inline: 1.25rem;
  max-width: 1360px;
}
@media (min-width: 480px) { .cs-container { padding-inline: 1.75rem; } }
@media (min-width: 768px) { .cs-container { padding-inline: 2.75rem; } }
@media (min-width: 1280px) { .cs-container { padding-inline: 3.5rem; } }

.cs-float { animation: csFloat 4.4s ease-in-out infinite; }
@keyframes csFloat {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-8px); }
}

@media (prefers-reduced-motion: reduce) {
  .cs-root *, .cs-root *::before, .cs-root *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  .cs-float { animation: none !important; }
}
`;

/* ------------------------------------------------------------------ *
 * Helpers
 * ------------------------------------------------------------------ */

function Reveal({
  children,
  delay = 0,
  y = 22,
  className,
  style,
  as = "div",
}: {
  children: ReactNode;
  delay?: number;
  y?: number;
  className?: string;
  style?: CSSProperties;
  as?: "div" | "section" | "span" | "li" | "article" | "ul";
}) {
  const reduce = useReducedMotion();
  const MotionTag = motion[as] as typeof motion.div;
  const variants: Variants = {
    hidden: { opacity: 0, y: reduce ? 0 : y },
    show: { opacity: 1, y: 0, transition: { duration: 0.8, ease, delay } },
  };
  return (
    <MotionTag
      className={className}
      style={style}
      initial="hidden"
      whileInView="show"
      viewport={{ once: true, margin: "-80px" }}
      variants={variants}
    >
      {children}
    </MotionTag>
  );
}

function useIsMobile() {
  const [isMobile, setIsMobile] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(max-width: 1023px)");
    const onChange = () => setIsMobile(mq.matches);
    onChange();
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);
  return isMobile;
}

/** Circular photo that grows a little as it scrolls into view, then settles. */
function ScaleCircle({
  src,
  alt,
  imgClassName = "object-center",
  className = "",
}: {
  src: string;
  alt: string;
  imgClassName?: string;
  className?: string;
}) {
  const ref = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();
  const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "center center"] });
  // The photo starts already a little larger than its circle and pushes in
  // further while scrolling. Anything below 1 would leave the round frame
  // unfilled, which read as a broken crop.
  const scale = useTransform(scrollYProgress, [0, 1], reduce ? [1.12, 1.12] : [1.08, 1.24]);
  return (
    // clip-path rather than overflow-hidden + border-radius: the child is a
    // transformed (scaling) image, which Chromium promotes to its own
    // compositor layer, and the rounded overflow clip then gets rasterised
    // against it as a coarse polygon, so the circles came out octagonal.
    // clip-path is applied in the compositor and stays a true circle.
    <div
      ref={ref}
      className={`overflow-hidden ${className}`}
      style={{ clipPath: "circle(50% at 50% 50%)" }}
    >
      <motion.img
        src={src}
        alt={alt}
        loading="lazy"
        style={{ scale }}
        className={`aspect-square w-full object-cover ${imgClassName}`}
      />
    </div>
  );
}

/** Secondary link where an icon grows out of zero width between two words. */
function InjectAction({
  href,
  first,
  second,
  icon: Icon,
  tone = "onDark",
  className = "",
}: {
  href: string;
  first: string;
  second: string;
  icon: LucideIcon;
  tone?: "onDark" | "onLight";
  className?: string;
}) {
  const reduce = useReducedMotion();
  const skin =
    tone === "onDark"
      ? "border-white/30 text-white hover:border-white/60"
      : "border-[#d8ded9] hover:border-current";
  return (
    <a
      href={href}
      className={`group inline-flex items-center rounded-lg border px-5 py-3.5 text-sm font-semibold transition-colors duration-300 sm:py-3 ${skin} ${className}`}
      style={{ color: tone === "onLight" ? INK : undefined }}
    >
      {/* The gap between the two words lives on the text spans themselves,
          not on the flex container: a parent `gap` does not collapse to
          zero just because the icon's own width animates to zero, which
          left a visible double space at rest. */}
      <span className="mr-1">{first}</span>
      <motion.span
        initial="rest"
        animate="rest"
        whileHover="open"
        whileFocus="open"
        variants={{
          rest: { width: 0, opacity: 0, scale: reduce ? 1 : 0.4, marginRight: 0 },
          open: { width: 22, opacity: 1, scale: 1, marginRight: 6 },
        }}
        transition={{ duration: 0.4, ease }}
        className="inline-flex items-center justify-center overflow-hidden"
      >
        <Icon className="h-4 w-4 shrink-0" strokeWidth={2} />
      </motion.span>
      <span>{second}</span>
    </a>
  );
}

/* ------------------------------------------------------------------ *
 * Logo mark (own wordmark, invented for this template)
 * ------------------------------------------------------------------ */

function Logomark({
  tone = "ink",
  className = "",
}: {
  tone?: "ink" | "white";
  className?: string;
}) {
  const fg = tone === "white" ? "#ffffff" : INK;
  return (
    <a
      href="#top"
      aria-label={`${BRAND} home`}
      className={`flex items-center gap-2.5 ${className}`}
    >
      <span
        className="grid h-8 w-8 shrink-0 place-items-center rounded-[8px]"
        style={{ background: ACCENT }}
        aria-hidden
      >
        <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
          <path d="M1 1h6v6H1z" stroke="#fff" strokeWidth="1.3" strokeLinejoin="round" />
          <path d="M4 4l6 6" stroke="#fff" strokeWidth="1.3" strokeLinecap="round" />
          <circle cx="12" cy="12" r="3" stroke="#fff" strokeWidth="1.3" />
        </svg>
      </span>
      <span className="font-display text-[19px] tracking-tight" style={{ color: fg }}>
        {BRAND}
      </span>
    </a>
  );
}

/* ------------------------------------------------------------------ *
 * Data
 * ------------------------------------------------------------------ */

const navLinks = [
  { label: "Method", href: "#method" },
  { label: "Toolkit", href: "#toolkit" },
  { label: "Portal", href: "#portal" },
  { label: "Journal", href: "#journal" },
];

type Stage = {
  id: string;
  no: string;
  icon: LucideIcon;
  title: string;
  sub: string;
  summary: string;
  image: string;
  imageAlt: string;
  imagePosition: string;
  drill: {
    name: string;
    text: string;
    points: string[];
    duration: string;
    players: string;
    intensity: string;
  };
};

const stages: Stage[] = [
  {
    id: "s1",
    no: "01",
    icon: Hand,
    title: "Foundations",
    sub: "Stance, coordination",
    summary: "The technical base: ready position, catching and clean movement to the ball.",
    image: IMG.hands,
    imageAlt: "Open hand catching the light, the shape every catch starts from",
    imagePosition: "object-[50%_45%]",
    drill: {
      name: "Base Position & Movement",
      text: "Hand eye coordination, balance and controlled footwork under simulated shot pressure.",
      points: ["Hold body tension", "Slight forward weight", "Precise hand shape"],
      duration: "15 min",
      players: "2 to 4 keepers",
      intensity: "Medium",
    },
  },
  {
    id: "s2",
    no: "02",
    icon: Footprints,
    title: "Footwork",
    sub: "Mobility, positioning",
    summary: "Fast, stable feet: shuffling, step frequency and body control over short distances.",
    image: IMG.footwork,
    imageAlt: "Feet mid stride, dust rising off the ground",
    imagePosition: "object-[50%_60%]",
    drill: {
      name: "Fast Feet & Reaction",
      text: "Quick footwork and a stable body position while shifting across short distances.",
      points: ["Short ground contact", "Hips face play", "Hands lead the movement"],
      duration: "12 min",
      players: "2 to 3 keepers",
      intensity: "High",
    },
  },
  {
    id: "s3",
    no: "03",
    icon: Shield,
    title: "Handling & Falling",
    sub: "Catching, shot stopping",
    summary:
      "Securing the ball under pressure: catching technique, controlled falling and shot stopping.",
    image: IMG.ground,
    imageAlt: "Close turf at ground level, where every landing ends",
    imagePosition: "object-[50%_55%]",
    drill: {
      name: "Catch, Fall & Recover",
      text: "Secure ball control under pressure and a landing pattern that keeps keepers injury free.",
      points: ["Ball caught early", "Land through the side", "Eyes stay on the ball"],
      duration: "18 min",
      players: "2 to 4 keepers",
      intensity: "Medium",
    },
  },
  {
    id: "s4",
    no: "04",
    icon: Crosshair,
    title: "Reading the Game",
    sub: "Angles, anticipation",
    summary: "Reading space: angles, positioning and early adjustment as the ball moves.",
    image: IMG.academy,
    imageAlt: "Pitch markings and the centre circle in low sun",
    imagePosition: "object-[50%_45%]",
    drill: {
      name: "Angles, Crosses & Corners",
      text: "Correct angle play and early adjustment as the ball is switched across the pitch.",
      points: ["Set position before the action", "Small correcting steps", "Talk to the back line"],
      duration: "20 min",
      players: "4 to 6 players",
      intensity: "Medium",
    },
  },
  {
    id: "s5",
    no: "05",
    icon: Trophy,
    title: "Match Intensity",
    sub: "Competitive scenarios",
    summary: "Everything in competition: game forms with real decisions and finishing pressure.",
    image: IMG.camps,
    imageAlt: "Training pitch from above with the goal in frame",
    imagePosition: "object-[50%_72%]",
    drill: {
      name: "Keeper vs. Team Scenarios",
      text: "Every earlier stage transferred into match realistic decision making.",
      points: [
        "Speed of decision over perfection",
        "Constant communication",
        "Manage the workload",
      ],
      duration: "25 min",
      players: "6+ players",
      intensity: "High",
    },
  },
];

type Step = { no: string; icon: LucideIcon; title: string; product: string; text: string };

const howSteps: Step[] = [
  {
    no: "01",
    icon: BookOpen,
    title: "Learn the method",
    product: "Method",
    text: "Understand the structure and coaching principles behind Cleansheet.",
  },
  {
    no: "02",
    icon: Dumbbell,
    title: "Pick your drills",
    product: "Drills",
    text: "Choose the right exercises for today's focus from the library.",
  },
  {
    no: "03",
    icon: CalendarDays,
    title: "Plan the week",
    product: "Portal",
    text: "Build sessions, weeks and a full season inside the planning portal.",
  },
  {
    no: "04",
    icon: Smartphone,
    title: "Coach on the pitch",
    product: "App",
    text: "Bring the finished session to training, right there on the phone.",
  },
];

const useCases = [
  {
    no: "01",
    title: "Club coaches",
    text: "Plan a session in minutes and take it straight to the pitch.",
    image: IMG.coach,
    alt: "Coach watching a drill from the edge of the box",
    position: "object-[54%_30%]",
  },
  {
    no: "02",
    title: "Academies & performance centres",
    text: "Structure development across squads and age groups.",
    image: IMG.athlete,
    alt: "Athlete under competitive load during a session",
    position: "object-[52%_32%]",
  },
  {
    no: "03",
    title: "Camps & goalkeeping schools",
    text: "Keep drills organised and coaching consistent across every instructor.",
    image: IMG.turfLight,
    alt: "Open training ground in bright morning light",
    position: "object-center",
  },
];

type Tool = { step: string; product: string; icon: LucideIcon; short: string; text: string };

const tools: Tool[] = [
  {
    step: "Method",
    product: "Basic",
    icon: BookOpen,
    short: "Understand the method.",
    text: "The structure and principles of the Cleansheet method as a self paced course.",
  },
  {
    step: "Drills",
    product: "Drills",
    icon: Dumbbell,
    short: "Pick your exercises.",
    text: "A drill library with setup, execution and coaching points for every session.",
  },
  {
    step: "Plan",
    product: "Portal",
    icon: CalendarDays,
    short: "Plan the season.",
    text: "Structure sessions, weeks and a full season inside the online portal.",
  },
  {
    step: "Perform",
    product: "App",
    icon: Smartphone,
    short: "Train on the pitch.",
    text: "Take the finished session out to training, right there on your phone.",
  },
];

const sidebarNav = [
  { label: "Dashboard", icon: LayoutDashboard },
  { label: "Sessions", icon: Play },
  { label: "Drills", icon: Dumbbell },
  { label: "Goalkeepers", icon: Users },
  { label: "Calendar", icon: CalendarDays },
];

const sessions = [
  {
    id: "tue",
    title: "Tuesday Training",
    day: "Tu",
    duration: "75 min",
    focus: "Positioning",
    keepers: "4",
    accent: 1,
    modules: [
      { name: "Warm-up", min: 15, load: 35 },
      { name: "Positioning", min: 35, load: 80 },
      { name: "Reaction", min: 25, load: 62 },
    ],
  },
  {
    id: "thu",
    title: "Thursday Training",
    day: "Th",
    duration: "60 min",
    focus: "Shot Stopping",
    keepers: "3",
    accent: 1,
    modules: [
      { name: "Activation", min: 12, load: 28 },
      { name: "Shot Stopping", min: 30, load: 92 },
      { name: "Game Forms", min: 18, load: 55 },
    ],
  },
  {
    id: "sat",
    title: "Saturday Matchday",
    day: "Sa",
    duration: "45 min",
    focus: "Match Prep",
    keepers: "2",
    accent: 2,
    modules: [
      { name: "Mobility", min: 10, load: 30 },
      { name: "Handling", min: 15, load: 58 },
      { name: "Match Prep", min: 20, load: 74 },
    ],
  },
];

const week = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];

const journalEntries: {
  date: string;
  category: string;
  icon: LucideIcon;
  title: string;
  text: string;
}[] = [
  {
    date: "Aug 12, 2026",
    category: "Portal",
    icon: CalendarDays,
    title: "Season Planning 26/27: the new planning cycle is live",
    text: "Set up periodisation, weekly load and sessions for the new season right inside the portal.",
  },
  {
    date: "Jul 28, 2026",
    category: "Drills",
    icon: Dumbbell,
    title: "New drill series: crosses and box control",
    text: "Six new exercises for timing, decision making and communication inside the box.",
  },
  {
    date: "Jul 9, 2026",
    category: "Method",
    icon: BookOpen,
    title: "Cleansheet Basics: a new chapter on playing out from the back",
    text: "The modern goalkeeper as the first outfield player, now part of the base module.",
  },
];

/* ------------------------------------------------------------------ *
 * Header
 * ------------------------------------------------------------------ */

function MobileMenu({ open, onClose }: { open: boolean; onClose: () => void }) {
  return (
    <AnimatePresence>
      {open && (
        <motion.div
          className="fixed inset-0 z-[60] bg-white"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.3, ease }}
        >
          <div className="flex h-[100svh] flex-col">
            <div className="cs-container flex h-16 shrink-0 items-center justify-between">
              <Logomark />
              <button
                onClick={onClose}
                aria-label="Close menu"
                className="inline-flex h-11 w-11 items-center justify-center rounded-lg transition-colors"
                style={{ background: TINT, color: INK }}
              >
                <X strokeWidth={1.6} className="h-5 w-5" />
              </button>
            </div>

            <div className="cs-container flex-1 overflow-y-auto pb-6 pt-4">
              <ul className="flex flex-col gap-1">
                {navLinks.map((l, i) => (
                  <motion.li
                    key={l.href}
                    initial={{ opacity: 0, y: 12 }}
                    animate={{ opacity: 1, y: 0 }}
                    transition={{ duration: 0.4, ease, delay: 0.06 + i * 0.05 }}
                  >
                    <a
                      href={l.href}
                      onClick={onClose}
                      className="flex items-center justify-between rounded-xl py-4 font-display text-[22px]"
                      style={{ color: INK }}
                    >
                      {l.label}
                      <ArrowRight strokeWidth={1.6} className="h-4 w-4" style={{ color: MUTED }} />
                    </a>
                  </motion.li>
                ))}
              </ul>

              <motion.div
                initial={{ opacity: 0, y: 12 }}
                animate={{ opacity: 1, y: 0 }}
                transition={{ duration: 0.5, ease, delay: 0.32 }}
                className="mt-8 rounded-2xl p-5"
                style={{ background: TINT }}
              >
                <p className="text-[11px] uppercase tracking-[0.2em]" style={{ color: MUTED }}>
                  Get in touch
                </p>
                <a
                  href="mailto:hello@cleansheet-app.com"
                  className="mt-4 flex items-center gap-3 text-[14px]"
                  style={{ color: INK }}
                >
                  <Mail
                    strokeWidth={1.6}
                    className="h-4 w-4 shrink-0"
                    style={{ color: ACCENT_DARK }}
                  />
                  hello@cleansheet-app.com
                </a>
              </motion.div>
            </div>

            <div
              className="cs-container shrink-0 bg-white py-4"
              style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
            >
              <a
                href="#portal"
                onClick={onClose}
                className="flex w-full items-center justify-center gap-2 rounded-lg px-5 py-3.5 text-[14px] font-semibold text-white transition-colors"
                style={{ background: ACCENT }}
              >
                <CalendarDays strokeWidth={2} className="h-4 w-4" />
                Plan a session
              </a>
            </div>
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

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

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

  useEffect(() => {
    document.body.style.overflow = open ? "hidden" : "";
    return () => {
      document.body.style.overflow = "";
    };
  }, [open]);

  const capsule = scrolled || open;
  const light = !capsule;

  return (
    <>
      <header className="fixed inset-x-0 top-0 z-50 px-3">
        <div
          className={`mx-auto w-full rounded-xl transition-[max-width,margin-top,background-color] duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] ${
            capsule
              ? "mt-3 max-w-4xl bg-white/90 backdrop-blur-md"
              : "mt-0 max-w-[1440px] bg-transparent"
          }`}
        >
          <div
            className={`flex items-center transition-[height,padding] duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] ${
              capsule ? "h-14 px-4 md:px-6" : "h-20 px-3 md:px-7"
            }`}
          >
            <Logomark tone={light ? "white" : "ink"} />

            <nav
              className="absolute left-1/2 hidden -translate-x-1/2 items-center gap-8 lg:flex"
              aria-label="Main navigation"
            >
              {navLinks.map((l) => (
                <a
                  key={l.href}
                  href={l.href}
                  className="text-[14px] font-medium transition-colors"
                  style={{ color: light ? "rgba(255,255,255,0.78)" : MUTED }}
                >
                  {l.label}
                </a>
              ))}
            </nav>

            <div className="ml-auto flex items-center gap-3">
              <a
                href="#portal"
                className="hidden items-center gap-2 rounded-lg px-4 py-2.5 text-[14px] font-semibold transition-colors lg:inline-flex"
                style={{
                  background: light ? "#ffffff" : ACCENT,
                  color: light ? INK : "#ffffff",
                }}
              >
                <CalendarDays strokeWidth={2} className="h-3.5 w-3.5" />
                Plan a session
              </a>
              <button
                onClick={() => setOpen(true)}
                aria-label="Open menu"
                className="inline-flex h-11 w-11 items-center justify-center rounded-lg transition-colors lg:hidden"
                style={{
                  background: light ? "rgba(255,255,255,0.14)" : TINT,
                  color: light ? "#fff" : INK,
                }}
              >
                <Menu strokeWidth={1.6} className="h-5 w-5" />
              </button>
            </div>
          </div>
        </div>
      </header>

      {isMobile && <MobileMenu open={open} onClose={() => setOpen(false)} />}
    </>
  );
}

/* ------------------------------------------------------------------ *
 * Hero
 * ------------------------------------------------------------------ */

function Hero() {
  const ref = useRef<HTMLElement>(null);
  const reduce = useReducedMotion();
  const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end start"] });
  // Three depths, the reason the scene separates: the background drifts
  // down with the scroll, the type lifts against it, and the figure lifts
  // hardest, so it detaches from the words as the page moves.
  const bgScale = useTransform(scrollYProgress, [0, 1], reduce ? [1, 1] : [1, 1.08]);
  const bgY = useTransform(scrollYProgress, [0, 1], reduce ? ["0%", "0%"] : ["0%", "14%"]);
  const textY = useTransform(scrollYProgress, [0, 1], reduce ? ["0%", "0%"] : ["0%", "-22%"]);
  const keeperY = useTransform(scrollYProgress, [0, 1], reduce ? ["0%", "0%"] : ["0%", "-38%"]);

  return (
    <section
      id="top"
      ref={ref}
      className="relative flex min-h-[100svh] flex-col overflow-hidden md:min-h-[118svh]"
      style={{ background: CHARCOAL }}
    >
      <motion.img
        src={IMG.heroScene}
        alt=""
        aria-hidden
        loading="eager"
        style={{ scale: bgScale, y: bgY }}
        className="absolute inset-0 h-full w-full object-cover object-[58%_42%]"
      />
      <div
        aria-hidden
        className="absolute inset-x-0 top-0 h-40"
        style={{ background: "linear-gradient(to bottom, rgba(12,17,13,0.4), transparent)" }}
      />
      <div
        aria-hidden
        className="absolute inset-x-0 bottom-0 h-[68%] md:h-[54%]"
        style={{
          background:
            "linear-gradient(to top, rgba(12,17,13,0.82) 0%, rgba(12,17,13,0.4) 46%, rgba(12,17,13,0) 100%)",
        }}
      />

      {/* Oversized headline first, the keeper cut-out layered in front of it
          on wide screens: the figure reads as standing in the words rather
          than beside them, the same overlap the original brand used. */}
      <motion.div
        style={{ y: textY }}
        className="relative z-10 flex flex-1 flex-col items-center justify-center px-6 pt-24 text-center md:absolute md:inset-x-0 md:top-[15vh] md:items-start md:px-10 md:pt-0 md:text-left"
      >
        <motion.p
          initial={reduce ? false : { opacity: 0, y: 14 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.8, ease }}
          className="mono-label text-white/65"
        >
          Goalkeeper coaching, structured
        </motion.p>
        <h1
          className="font-display mt-5 text-white"
          style={{ fontSize: "clamp(2.75rem, 10.5vw, 9.5rem)" }}
        >
          <motion.span
            initial={reduce ? false : { opacity: 0, y: 24 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.9, ease, delay: 0.1 }}
            className="block"
          >
            The last line.
          </motion.span>
          <motion.span
            initial={reduce ? false : { opacity: 0, y: 24 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.9, ease, delay: 0.22 }}
            className="block"
            style={{ color: ACCENT_BRIGHT }}
          >
            Coached like one.
          </motion.span>
        </h1>
      </motion.div>

      {/* The cut-out keeper, layered ABOVE the headline (z-20 over z-10) so
          the figure stands in front of the words rather than beside them.
          Crop, scale and offset are the source composition's own values. */}
      <motion.div
        aria-hidden
        style={{ y: keeperY }}
        className="pointer-events-none absolute inset-0 z-20 hidden md:block"
      >
        <motion.img
          src={IMG.keeper}
          alt=""
          loading="eager"
          initial={reduce ? false : { opacity: 0 }}
          animate={{ opacity: 1 }}
          transition={{ duration: 0.9, ease, delay: 0.2 }}
          className="absolute right-[-13%] top-[30%] w-[66%] max-w-none select-none"
        />
      </motion.div>

      {/* Statement, action and media card, the second typographic moment
          the top headline hands off to. */}
      <div className="relative z-30 mt-auto w-full md:absolute md:inset-x-0 md:bottom-0">
        <div className="flex flex-col items-center gap-8 px-6 pb-28 pt-12 text-center md:flex-row md:items-end md:justify-between md:gap-8 md:px-10 md:pb-32 md:pt-0 md:text-left">
          <div className="max-w-xl">
            <p className="text-[1.75rem] font-bold leading-[1.12] tracking-tight text-white md:text-[2rem]">
              {["One method.", "Four tools.", "Every session tracked."].map((line, i) => (
                <span key={line} className="block overflow-hidden">
                  <motion.span
                    initial={reduce ? false : { y: "110%" }}
                    animate={{ y: "0%" }}
                    transition={{ duration: 0.7, ease, delay: 0.5 + i * 0.1 }}
                    className="block"
                  >
                    {line}
                  </motion.span>
                </span>
              ))}
            </p>
            <motion.p
              initial={reduce ? false : { opacity: 0, y: 14 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: 0.8, ease, delay: 0.85 }}
              className="mx-auto mt-5 max-w-md text-sm leading-relaxed text-white/75 md:mx-0 md:mt-4"
            >
              Cleansheet pairs a proven coaching method with a drill library, session planning and
              an app for the pitch, built for club coaches, academies and camps.
            </motion.p>
            <motion.div
              initial={reduce ? false : { opacity: 0, y: 14 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: 0.8, ease, delay: 0.95 }}
              className="mt-7 flex flex-col items-center gap-3 sm:flex-row md:mt-7"
            >
              <a
                href="#method"
                className="group inline-flex items-center justify-center gap-2 rounded-lg bg-white px-6 py-3.5 text-[15px] font-semibold transition-colors"
                style={{ color: INK }}
              >
                Explore the method
                <ArrowRight
                  strokeWidth={1.6}
                  className="h-4 w-4 transition-transform group-hover:translate-x-0.5"
                />
              </a>
              <InjectAction href="#portal" first="Plan" second="a session" icon={CalendarDays} />
            </motion.div>
          </div>

          {/* Media card: a still from the drill library standing in for a
              video, since no session footage exists for this brand. */}
          <motion.a
            href="#method"
            initial={reduce ? false : { opacity: 0, y: 18 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.8, ease, delay: 0.55 }}
            className="group relative hidden w-full shrink-0 overflow-hidden sm:block sm:w-[300px] md:w-[320px]"
            style={{ clipPath: "inset(0 round 16px)" }}
            aria-label="See the method explained"
          >
            <img
              src={IMG.fieldWide}
              alt=""
              className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]"
              loading="eager"
            />
            {/* The card sits inside a dark hero, so the frame gets a scrim.
                Without it the bright field pulls harder than the headline. */}
            <span
              aria-hidden
              className="absolute inset-0"
              style={{ background: "rgba(12,17,13,0.34)" }}
            />
            <span className="absolute inset-0 grid place-items-center" aria-hidden>
              <span className="grid size-12 place-items-center rounded-full bg-white/90 transition-transform duration-300 group-hover:scale-110">
                <Play className="size-4 translate-x-[1px] fill-current" style={{ color: INK }} />
              </span>
            </span>
            <span
              className="absolute inset-x-0 bottom-0 flex items-center gap-2 p-4"
              style={{ background: "linear-gradient(to top, rgba(12,17,13,0.8), transparent)" }}
            >
              <span className="mono-label text-white/85">The method in 90 seconds</span>
            </span>
          </motion.a>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * Statement (scroll colour reveal, word by word)
 * ------------------------------------------------------------------ */

const STATEMENT_TEXT =
  "Cleansheet turns goalkeeper coaching from scattered notes and gut feeling into a method every coach can follow, track and repeat.";

function Statement() {
  const ref = useRef<HTMLElement>(null);
  const [progress, setProgress] = useState(0);
  const words = STATEMENT_TEXT.split(" ");

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setProgress(1);
      return;
    }
    let raf = 0;
    const compute = () => {
      const rect = el.getBoundingClientRect();
      const span = Math.max(rect.height - window.innerHeight, 1);
      setProgress(Math.min(Math.max(-rect.top / span, 0), 1));
      raf = 0;
    };
    const onScroll = () => {
      if (!raf) raf = requestAnimationFrame(compute);
    };
    compute();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      cancelAnimationFrame(raf);
    };
  }, []);

  const from = [199, 206, 200];
  const to = [16, 22, 15];

  return (
    <section
      className="relative h-[130vh] bg-white md:h-[190vh]"
      ref={ref}
      aria-labelledby="statement-title"
    >
      <div className="sticky top-0 flex h-screen items-center">
        <div className="cs-container">
          <div className="mono-label inline-flex items-center gap-2" style={{ color: ACCENT_DARK }}>
            <Target strokeWidth={1.6} className="h-3.5 w-3.5" />
            Our standard
          </div>
          <h2
            id="statement-title"
            className="font-display mt-6 max-w-[24ch]"
            style={{ fontSize: "clamp(1.85rem, 4.6vw, 3.6rem)", lineHeight: 1.22 }}
            aria-label={STATEMENT_TEXT}
          >
            {words.map((word, n) => {
              const r = n / words.length;
              const raw = Math.min(Math.max((progress - r * 0.62) * 4, 0), 1);
              const t = raw * raw * (3 - 2 * raw);
              const color = from.map((c, k) => Math.round(c + (to[k] - c) * t));
              return (
                <span
                  aria-hidden="true"
                  style={{ color: `rgb(${color[0]}, ${color[1]}, ${color[2]})` }}
                  key={n}
                >
                  {word}{" "}
                </span>
              );
            })}
          </h2>
        </div>
      </div>
    </section>
  );
}

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

function Concept() {
  const [active, setActive] = useState("s1");
  const current = stages.find((s) => s.id === active)!;
  const reduce = useReducedMotion();

  return (
    <section
      id="method"
      className="scroll-mt-24 bg-white py-16 md:py-28"
      aria-labelledby="method-title"
    >
      <div className="cs-container">
        <Reveal className="max-w-2xl">
          <div className="mono-label inline-flex items-center gap-2" style={{ color: ACCENT_DARK }}>
            <Sparkles strokeWidth={1.6} className="h-3.5 w-3.5" />
            The Cleansheet Method
          </div>
          <h2
            id="method-title"
            className="font-display mt-5"
            style={{ fontSize: "clamp(1.8rem, 3.6vw, 2.9rem)" }}
          >
            Five stages, one clear
            <br />
            coaching path.
          </h2>
          <p className="mt-5 max-w-md text-[15px] leading-relaxed" style={{ color: MUTED }}>
            Every stage builds on the one before it. Pick a stage and open a drill from it.
          </p>
        </Reveal>

        <div className="mt-10 grid items-start gap-8 md:mt-16 lg:grid-cols-[2fr_3fr] lg:gap-14">
          <Reveal delay={0.06}>
            <ul className="flex flex-col gap-1">
              {stages.map((s) => {
                const isActive = s.id === active;
                const Icon = s.icon;
                return (
                  <li key={s.id}>
                    <button
                      type="button"
                      onClick={() => setActive(s.id)}
                      aria-expanded={isActive}
                      className="group w-full rounded-xl px-3 py-3 text-left transition-colors duration-300 lg:px-5 lg:py-4"
                      style={{ background: isActive ? SURFACE : "transparent" }}
                    >
                      <span className="flex items-center gap-3.5 lg:gap-4">
                        <span
                          className="grid h-10 w-10 shrink-0 place-items-center rounded-full transition-colors duration-300 lg:h-11 lg:w-11"
                          style={{ background: isActive ? ACCENT : "#eceeec" }}
                        >
                          <Icon
                            strokeWidth={1.7}
                            className="h-[18px] w-[18px]"
                            style={{ color: isActive ? "#fff" : MUTED }}
                          />
                        </span>
                        <span
                          className="min-w-0 flex-1 font-display text-[16px] lg:text-[20px]"
                          style={{ color: isActive ? INK : "#a7afa9" }}
                        >
                          {s.title}
                        </span>
                        <span className="mono-label shrink-0" style={{ color: MUTED }}>
                          {s.no}
                        </span>
                      </span>
                      <span
                        className={`hidden overflow-hidden transition-all duration-500 lg:grid ${
                          isActive ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
                        }`}
                      >
                        <span className="overflow-hidden">
                          <span
                            className="block pt-2.5 pl-[60px] text-[14px] leading-relaxed"
                            style={{ color: MUTED }}
                          >
                            {s.summary}
                          </span>
                        </span>
                      </span>
                    </button>
                  </li>
                );
              })}
            </ul>
          </Reveal>

          <Reveal delay={0.14} className="lg:sticky lg:top-28">
            <div className="relative overflow-hidden" style={{ clipPath: "inset(0 round 22px)" }}>
              <AnimatePresence mode="wait">
                <motion.div
                  key={current.id}
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={{ opacity: 0 }}
                  transition={{ duration: reduce ? 0 : 0.45, ease }}
                  className="relative aspect-[16/11] w-full"
                >
                  <img
                    src={current.image}
                    alt={current.imageAlt}
                    loading="lazy"
                    className={`absolute inset-0 h-full w-full object-cover ${current.imagePosition}`}
                  />
                  <div
                    aria-hidden
                    className="absolute inset-0"
                    style={{
                      background:
                        "linear-gradient(to top, rgba(12,17,13,0.75) 0%, rgba(12,17,13,0.06) 55%)",
                    }}
                  />
                  <span
                    className="absolute left-4 top-4 inline-flex items-center rounded-full px-3.5 py-2 text-[12px] font-semibold text-white lg:left-5 lg:top-5"
                    style={{ background: "rgba(12,17,13,0.55)", backdropFilter: "blur(6px)" }}
                  >
                    Stage {current.no} &middot; {current.title}
                  </span>
                  <div className="absolute inset-x-0 bottom-0 p-5 lg:p-6">
                    <p className="mono-label text-white/60">Drill from this stage</p>
                    <p className="mt-1 font-display text-[18px] text-white lg:text-[22px]">
                      {current.drill.name}
                    </p>
                  </div>
                </motion.div>
              </AnimatePresence>
            </div>

            <div className="mt-5 rounded-[18px] p-6 md:p-7" style={{ background: SURFACE }}>
              <p className="text-[14.5px] leading-relaxed" style={{ color: "#3b463f" }}>
                {current.drill.text}
              </p>
              <ul className="mt-4 space-y-2">
                {current.drill.points.map((p) => (
                  <li
                    key={p}
                    className="flex items-start gap-2.5 text-[13.5px]"
                    style={{ color: MUTED }}
                  >
                    <span
                      className="mt-1.5 h-1 w-1 shrink-0 rounded-full"
                      style={{ background: ACCENT }}
                    />
                    {p}
                  </li>
                ))}
              </ul>
              <p className="mono-label mt-5" style={{ color: MUTED }}>
                {current.drill.duration} &middot; {current.drill.players} &middot;{" "}
                {current.drill.intensity}
              </p>
            </div>
          </Reveal>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * How it works
 * ------------------------------------------------------------------ */

function HowItWorks() {
  return (
    <section
      className="relative z-10 -mt-8 scroll-mt-24 overflow-hidden rounded-t-[2.5rem] py-16 md:-mt-14 md:rounded-t-[3.5rem] md:py-28"
      style={{ background: SURFACE }}
      aria-labelledby="how-title"
    >
      <div className="cs-container">
        <Reveal className="max-w-xl">
          <div className="mono-label inline-flex items-center gap-2" style={{ color: ACCENT_DARK }}>
            <Compass strokeWidth={1.6} className="h-3.5 w-3.5" />
            How Cleansheet works
          </div>
          <h2
            id="how-title"
            className="font-display mt-5"
            style={{ fontSize: "clamp(1.8rem, 3.6vw, 2.9rem)" }}
          >
            Four steps from method to pitch.
          </h2>
        </Reveal>

        <div className="mt-10 grid gap-4 md:mt-14 md:grid-cols-2 md:gap-6 xl:grid-cols-4">
          {howSteps.map((s, i) => {
            const Icon = s.icon;
            return (
              <Reveal key={s.no} delay={i * 0.08}>
                <div className="group flex h-full flex-col rounded-2xl bg-white p-6 md:p-7">
                  <span
                    className="relative grid h-11 w-11 shrink-0 place-items-center overflow-hidden transition-colors duration-300"
                    style={{ background: TINT, clipPath: "circle(50% at 50% 50%)" }}
                  >
                    <span
                      className="mono-label transition-all duration-300 group-hover:-translate-y-2 group-hover:opacity-0"
                      style={{ color: ACCENT_DARK }}
                    >
                      {s.no}
                    </span>
                    <Icon
                      strokeWidth={1.7}
                      className="absolute h-[18px] w-[18px] translate-y-2 opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100"
                      style={{ color: ACCENT }}
                    />
                  </span>
                  <p className="mt-6 font-display text-[18px]">{s.title}</p>
                  <p className="mt-1.5 mono-label" style={{ color: MUTED }}>
                    {s.product}
                  </p>
                  <p className="mt-3 text-[14px] leading-relaxed" style={{ color: MUTED }}>
                    {s.text}
                  </p>
                </div>
              </Reveal>
            );
          })}
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * Use cases
 * ------------------------------------------------------------------ */

function UseCases() {
  return (
    <section className="bg-white py-16 md:py-28" aria-labelledby="usecases-title">
      <div className="cs-container">
        <Reveal className="mx-auto max-w-2xl text-center">
          <div className="mono-label inline-flex items-center gap-2" style={{ color: ACCENT_DARK }}>
            <Users strokeWidth={1.6} className="h-3.5 w-3.5" />
            Built for goalkeeper coaches
          </div>
          <h2
            id="usecases-title"
            className="font-display mt-5"
            style={{ fontSize: "clamp(1.8rem, 3.6vw, 2.9rem)" }}
          >
            For everyone developing goalkeepers.
          </h2>
        </Reveal>

        <div className="mt-10 grid gap-6 md:mt-16 md:grid-cols-3 md:gap-8">
          {useCases.map((c, i) => (
            <Reveal key={c.no} delay={i * 0.08}>
              <div className="flex items-center gap-5 text-left md:flex-col md:gap-0 md:text-center">
                <ScaleCircle
                  src={c.image}
                  alt={c.alt}
                  className="w-28 shrink-0 sm:w-36 md:w-full md:max-w-[240px]"
                  imgClassName={c.position}
                />
                <div className="min-w-0">
                  <p className="font-display mt-0 text-[17px] md:mt-7 md:text-[19px]">{c.title}</p>
                  <p
                    className="mt-2 max-w-[18rem] text-[13.5px] leading-relaxed md:mt-3"
                    style={{ color: MUTED }}
                  >
                    {c.text}
                  </p>
                </div>
              </div>
            </Reveal>
          ))}
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * Ecosystem / toolkit
 * ------------------------------------------------------------------ */

function Ecosystem() {
  const [active, setActive] = useState<number | null>(null);
  const current = active === null ? null : tools[active];
  const isMobile = useIsMobile();

  return (
    <section
      id="toolkit"
      className="scroll-mt-24 py-16 md:py-28"
      style={{ background: SURFACE }}
      aria-labelledby="toolkit-title"
    >
      <div className="cs-container">
        <Reveal className="mx-auto max-w-2xl text-center">
          <h2
            id="toolkit-title"
            className="font-display"
            style={{ fontSize: "clamp(1.8rem, 3.6vw, 2.9rem)" }}
          >
            One system.
            <br />
            Four tools.
          </h2>
        </Reveal>

        {isMobile ? (
          <div className="mt-10 flex flex-col gap-2.5">
            {tools.map((t, i) => {
              const open = active === i;
              const Icon = t.icon;
              return (
                <Reveal key={t.product} delay={i * 0.06}>
                  <button
                    type="button"
                    onClick={() => setActive(open ? null : i)}
                    aria-expanded={open}
                    className="w-full rounded-2xl bg-white p-4 text-left"
                  >
                    <span className="flex items-center gap-3.5">
                      <span
                        className="grid h-11 w-11 shrink-0 place-items-center rounded-full transition-colors duration-300"
                        style={{ background: open ? ACCENT : "#eceeec" }}
                      >
                        <Icon
                          strokeWidth={1.7}
                          className="h-[17px] w-[17px]"
                          style={{ color: open ? "#fff" : ACCENT_DARK }}
                        />
                      </span>
                      <span className="min-w-0 flex-1">
                        <span className="mono-label block" style={{ color: MUTED }}>
                          {t.step}
                        </span>
                        <span className="mt-0.5 block font-display text-[16px]">{t.product}</span>
                      </span>
                      <ChevronDown
                        strokeWidth={2}
                        className={`h-4 w-4 shrink-0 transition-transform duration-300 ${open ? "rotate-180" : ""}`}
                        style={{ color: MUTED }}
                      />
                    </span>
                    <span
                      className={`grid transition-all duration-500 ${
                        open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
                      }`}
                    >
                      <span className="overflow-hidden">
                        <span
                          className="block pt-3 pl-[58px] text-[14px] leading-relaxed"
                          style={{ color: MUTED }}
                        >
                          {t.text}
                        </span>
                      </span>
                    </span>
                  </button>
                </Reveal>
              );
            })}
          </div>
        ) : (
          <div className="relative mx-auto mt-16 flex max-w-3xl flex-col items-center">
            <Reveal
              delay={0.05}
              className="relative aspect-square w-[64%] overflow-hidden"
              style={{ clipPath: "circle(50% at 50% 50%)" }}
            >
              <img
                src={IMG.orbitGreen}
                alt="Sunlit turf moving in the wind"
                className="h-full w-full object-cover"
                loading="lazy"
              />
              <div
                className="absolute inset-0 grid place-items-center px-[14%] text-center transition-all duration-500"
                style={{ background: current ? "rgba(12,17,13,0.72)" : "rgba(12,17,13,0)" }}
                aria-hidden={!current}
              >
                <div
                  className={`transition-all duration-500 ${current ? "translate-y-0 opacity-100" : "translate-y-3 opacity-0"}`}
                >
                  <p className="mono-label" style={{ color: ACCENT_BRIGHT }}>
                    {current?.step}
                  </p>
                  <p className="mt-3 text-2xl font-extrabold tracking-tight text-white md:text-3xl">
                    {current?.product}
                  </p>
                  <p className="mt-3 text-[14px] leading-relaxed text-white/75">{current?.text}</p>
                </div>
              </div>
            </Reveal>

            <div className="mt-8 grid w-full grid-cols-2 gap-4 md:absolute md:inset-0 md:mt-0 md:block md:gap-0">
              {tools.map((t, i) => {
                const Icon = t.icon;
                const positions = [
                  "md:absolute md:left-[-6%] md:top-[6%]",
                  "md:absolute md:right-[-6%] md:top-[24%]",
                  "md:absolute md:left-[-4%] md:bottom-[16%]",
                  "md:absolute md:right-[-3%] md:bottom-[2%]",
                ];
                return (
                  <Reveal
                    key={t.product}
                    delay={0.1 + i * 0.06}
                    className={`w-full md:w-[228px] ${positions[i]}`}
                  >
                    <button
                      type="button"
                      onMouseEnter={() => setActive(i)}
                      onMouseLeave={() => setActive((v) => (v === i ? null : v))}
                      onFocus={() => setActive(i)}
                      onBlur={() => setActive((v) => (v === i ? null : v))}
                      className={`cs-float flex w-full items-center gap-3.5 rounded-2xl bg-white p-3.5 pr-5 text-left transition-transform duration-300 ${
                        active === i ? "scale-[1.04]" : ""
                      }`}
                      style={{ animationDelay: `${i * 0.4}s` }}
                    >
                      <span
                        className="grid h-11 w-11 shrink-0 place-items-center rounded-full transition-colors duration-300"
                        style={{ background: active === i ? ACCENT : "#eceeec" }}
                      >
                        <Icon
                          strokeWidth={1.7}
                          className="h-[17px] w-[17px]"
                          style={{ color: active === i ? "#fff" : ACCENT_DARK }}
                        />
                      </span>
                      <span className="min-w-0">
                        <span className="mono-label block" style={{ color: MUTED }}>
                          {t.step}
                        </span>
                        <span className="mt-0.5 block font-display text-[16px]">{t.product}</span>
                        <span
                          className="mt-1 block text-[12px] leading-relaxed"
                          style={{ color: MUTED }}
                        >
                          {t.short}
                        </span>
                      </span>
                    </button>
                  </Reveal>
                );
              })}
            </div>
          </div>
        )}
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * Planning portal (product showcase)
 * ------------------------------------------------------------------ */

function PlanningPortal() {
  const [index, setIndex] = useState(0);
  const session = sessions[index] ?? sessions[0]!;
  const reduce = useReducedMotion();

  useEffect(() => {
    if (reduce) return;
    const id = window.setInterval(() => setIndex((v) => (v + 1) % sessions.length), 5200);
    return () => window.clearInterval(id);
  }, [reduce]);

  return (
    <section
      id="portal"
      className="scroll-mt-24 py-16 text-white sm:py-24 md:py-32"
      style={{ background: CHARCOAL }}
    >
      <div className="cs-container">
        <div className="grid gap-12 lg:grid-cols-12 lg:items-center lg:gap-14">
          <div className="lg:col-span-5">
            <Reveal>
              <p className="mono-label" style={{ color: "rgba(255,255,255,0.5)" }}>
                Portal in action
              </p>
              <h2 className="font-display mt-6" style={{ fontSize: "clamp(1.85rem, 4vw, 3.1rem)" }}>
                From a coaching idea
                <br />
                to a finished session.
              </h2>
              <p
                className="mt-6 max-w-md text-[15px] leading-relaxed"
                style={{ color: "rgba(255,255,255,0.62)" }}
              >
                The Cleansheet portal: plan sessions, combine drills and structure your goalkeepers'
                development, all in one place.
              </p>
              <a
                href="#portal"
                className="group mt-9 inline-flex items-center gap-2 rounded-lg bg-white px-5 py-3 text-[14px] font-semibold transition-colors"
                style={{ color: INK }}
              >
                <CalendarDays strokeWidth={2} className="h-4 w-4" />
                Plan a session
                <ArrowRight
                  strokeWidth={2}
                  className="h-4 w-4 transition-transform group-hover:translate-x-0.5"
                />
              </a>
            </Reveal>
          </div>

          <Reveal delay={0.1} className="lg:col-span-7">
            <div
              className="overflow-hidden bg-white"
              style={{ color: INK, clipPath: "inset(0 round 20px)" }}
            >
              <div className="grid sm:grid-cols-[176px_1fr]">
                <aside className="hidden p-4 sm:block" style={{ background: SURFACE }}>
                  <p className="mono-label" style={{ color: "#8b968f" }}>
                    Workspace
                  </p>
                  <nav className="mt-4 space-y-1">
                    {sidebarNav.map((item, i) => (
                      <span
                        key={item.label}
                        className="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-[14px]"
                        style={
                          i === 1
                            ? { background: "#fff", fontWeight: 600, color: INK }
                            : { color: "#8b968f" }
                        }
                      >
                        <item.icon
                          strokeWidth={1.7}
                          className="h-4 w-4"
                          style={{ color: i === 1 ? ACCENT : "#aab3ad" }}
                        />
                        {item.label}
                      </span>
                    ))}
                  </nav>

                  <p className="mono-label mt-7" style={{ color: "#8b968f" }}>
                    Week 12
                  </p>
                  <div className="mt-3 flex items-end gap-1.5">
                    {week.map((d) => (
                      <span key={d} className="flex flex-1 flex-col items-center gap-1.5">
                        <span className="text-[9px] font-semibold" style={{ color: "#aab3ad" }}>
                          {d.slice(0, 1)}
                        </span>
                        <span
                          className="h-1 w-full rounded-full transition-colors duration-500"
                          style={{ background: d === session.day ? ACCENT : "#e1e5e2" }}
                        />
                      </span>
                    ))}
                  </div>
                </aside>

                <div className="p-6 md:p-8">
                  <div className="flex flex-wrap items-baseline gap-x-6 gap-y-2">
                    <AnimatePresence mode="wait">
                      <motion.h3
                        key={session.id}
                        initial={{ opacity: 0, y: reduce ? 0 : 8 }}
                        animate={{ opacity: 1, y: 0 }}
                        exit={{ opacity: 0 }}
                        transition={{ duration: 0.4, ease }}
                        className="font-display text-[20px]"
                      >
                        {session.title}
                      </motion.h3>
                    </AnimatePresence>
                    <span
                      className="mono-label ml-auto flex items-center gap-2"
                      style={{ color: ACCENT }}
                    >
                      <span
                        className="h-1.5 w-1.5 animate-pulse rounded-full"
                        style={{ background: ACCENT }}
                      />
                      Active session
                    </span>
                  </div>

                  <div className="mt-7 grid gap-5 sm:grid-cols-3 sm:gap-6">
                    {[
                      [session.duration, "Training duration"],
                      [session.focus, "Today's focus"],
                      [session.keepers, "Goalkeepers"],
                    ].map(([v, k]) => (
                      <div key={k} className="flex items-baseline justify-between gap-3 sm:block">
                        <p className="text-[20px] font-extrabold tracking-tight md:text-[22px]">
                          {v}
                        </p>
                        <p className="mono-label sm:mt-1.5" style={{ color: "#8b968f" }}>
                          {k}
                        </p>
                      </div>
                    ))}
                  </div>

                  <div className="mt-8">
                    <p className="mono-label" style={{ color: "#8b968f" }}>
                      Session timeline
                    </p>
                    <div className="mt-4 space-y-3.5">
                      {session.modules.map((m, i) => (
                        <div key={`${session.id}-${m.name}`} className="flex items-center gap-4">
                          <span className="w-28 shrink-0 truncate text-[14px] font-medium">
                            {m.name}
                          </span>
                          <span
                            className="relative h-2.5 flex-1 overflow-hidden"
                            style={{ background: SURFACE, clipPath: "inset(0 round 999px)" }}
                          >
                            <motion.span
                              key={`${session.id}-${m.name}-bar`}
                              initial={{ width: "0%" }}
                              animate={{ width: `${m.load}%` }}
                              transition={{ duration: 0.7, ease, delay: i * 0.1 }}
                              className="absolute inset-y-0 left-0 rounded-full"
                              style={{ background: i === session.accent ? ACCENT : "#c9d1cb" }}
                            />
                          </span>
                          <span
                            className="mono-label w-10 text-right tabular-nums"
                            style={{ color: "#8b968f" }}
                          >
                            {m.min}&prime;
                          </span>
                        </div>
                      ))}
                    </div>
                  </div>

                  <div className="mt-7 flex items-center gap-3">
                    {sessions.map((s, i) => (
                      <span
                        key={s.id}
                        className="h-1 rounded-full transition-all duration-500"
                        style={{
                          width: i === index ? 32 : 16,
                          background: i === index ? ACCENT : "#e1e5e2",
                        }}
                        aria-hidden
                      />
                    ))}
                    <span className="mono-label ml-auto" style={{ color: "#8b968f" }}>
                      {index + 1} / {sessions.length} planned sessions
                    </span>
                  </div>
                </div>
              </div>
            </div>
          </Reveal>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * Journal / news
 * ------------------------------------------------------------------ */

function Journal() {
  return (
    <section
      id="journal"
      className="scroll-mt-24 bg-white py-16 md:py-28"
      aria-labelledby="journal-title"
    >
      <div className="cs-container">
        <Reveal className="flex flex-wrap items-baseline justify-between gap-4">
          <h2 id="journal-title" className="text-[24px] font-extrabold tracking-tight">
            Latest from Cleansheet.
          </h2>
        </Reveal>

        <div className="mt-10 grid gap-4 md:grid-cols-3 md:gap-6">
          {journalEntries.map((e, i) => {
            const Icon = e.icon;
            return (
              <Reveal key={e.title} delay={i * 0.08}>
                <a
                  href="#journal"
                  className="group flex h-full flex-col rounded-[18px] p-6 transition-colors duration-300 md:p-7"
                  style={{ background: SURFACE }}
                >
                  <div className="flex items-center gap-3">
                    <span
                      className="grid h-10 w-10 shrink-0 place-items-center rounded-full transition-colors duration-300"
                      style={{ background: TINT }}
                    >
                      <Icon
                        strokeWidth={1.7}
                        className="h-[17px] w-[17px]"
                        style={{ color: ACCENT_DARK }}
                      />
                    </span>
                    <span className="mono-label" style={{ color: MUTED }}>
                      {e.date}
                    </span>
                    <span
                      className="mono-label ml-auto rounded-full px-3 py-1.5"
                      style={{ background: "#fff", color: MUTED }}
                    >
                      {e.category}
                    </span>
                  </div>
                  <p className="mt-5 font-display text-[17px] leading-snug">{e.title}</p>
                  <p className="mt-3 text-[13.5px] leading-relaxed" style={{ color: MUTED }}>
                    {e.text}
                  </p>
                  <span
                    className="mono-label mt-6 flex items-center gap-2 pt-1 opacity-0 transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100 md:-translate-x-2"
                    style={{ color: INK }}
                  >
                    Read more
                    <span style={{ color: ACCENT }}>&rarr;</span>
                  </span>
                </a>
              </Reveal>
            );
          })}
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * Closing CTA scene
 * ------------------------------------------------------------------ */

function CtaScene() {
  const ref = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();
  const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] });
  const y = useTransform(scrollYProgress, [0, 1], reduce ? ["0%", "0%"] : ["-6%", "6%"]);

  return (
    <section className="px-3 py-3 md:px-4 md:py-4" style={{ background: SURFACE }}>
      <div
        ref={ref}
        className="relative overflow-hidden"
        style={{ clipPath: "inset(0 round 24px)" }}
      >
        <motion.img
          src={IMG.ctaGoal}
          alt="Empty goal net inside a stadium"
          style={{ y }}
          className="absolute inset-0 h-full w-full scale-110 object-cover"
          loading="lazy"
        />
        <div
          aria-hidden
          className="absolute inset-0"
          style={{
            background: "linear-gradient(120deg, rgba(12,17,13,0.15) 0%, rgba(12,17,13,0.55) 100%)",
          }}
        />
        <div className="relative flex min-h-[560px] items-center md:min-h-[680px]">
          <div className="cs-container flex justify-end py-16 md:py-24">
            <Reveal delay={0.05} className="w-full max-w-md text-white md:max-w-lg">
              {/* A solid panel rather than a frosted one: a backdrop-filter
                  child ignores its parent's border-radius in Chromium and the
                  card rendered with square corners over the photo. */}
              <div
                style={{ background: "rgba(12,17,13,0.72)" }}
                className="rounded-[18px] p-7 md:p-9"
              >
                <p className="mono-label" style={{ color: "rgba(255,255,255,0.55)" }}>
                  Get started
                </p>
                <h2
                  className="font-display mt-4"
                  style={{ fontSize: "clamp(1.7rem, 3vw, 2.5rem)" }}
                >
                  From the method to
                  <br />
                  your next session.
                </h2>
                <p
                  className="mt-4 max-w-sm text-[14px] leading-relaxed"
                  style={{ color: "rgba(255,255,255,0.72)" }}
                >
                  Plan your first session in the portal, or start with the fundamentals of the
                  Cleansheet method.
                </p>
                <div className="mt-7 flex flex-wrap items-center gap-4">
                  <a
                    href="#portal"
                    className="group inline-flex items-center gap-2.5 rounded-lg bg-white px-5 py-3 text-[14px] font-semibold transition-colors"
                    style={{ color: INK }}
                  >
                    <CalendarDays strokeWidth={2} className="h-4 w-4" />
                    Plan a session
                    <span className="transition-transform duration-300 group-hover:translate-x-1">
                      &rarr;
                    </span>
                  </a>
                  <InjectAction href="#method" first="Method" second="overview" icon={Goal} />
                </div>
              </div>
            </Reveal>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * Footer
 * ------------------------------------------------------------------ */

const footerNav = [
  { label: "Method", href: "#method" },
  { label: "Toolkit", href: "#toolkit" },
  { label: "Portal", href: "#portal" },
  { label: "Journal", href: "#journal" },
];

const socials = [
  { label: "Instagram", icon: Instagram, href: "#" },
  { label: "YouTube", icon: Youtube, href: "#" },
  { label: "LinkedIn", icon: Linkedin, href: "#" },
];

function Footer() {
  const wordmark = `${BRAND}.`;
  return (
    <footer className="px-3 pt-3 md:px-4 md:pt-4" style={{ background: SURFACE }}>
      <div className="overflow-hidden rounded-t-3xl text-white" style={{ background: CHARCOAL }}>
        <div className="px-6 pt-14 md:px-12 md:pt-20">
          <div className="flex flex-col items-center gap-10 text-center md:flex-row md:items-start md:justify-between md:text-left">
            <Reveal className="max-w-xs">
              <p className="font-display text-[18px]">Coaching for the last line.</p>
              <p
                className="mt-3 text-[14px] leading-relaxed"
                style={{ color: "rgba(255,255,255,0.55)" }}
              >
                A coaching method, digital tools and online planning for modern goalkeeper training.
              </p>
              <div className="mt-6 flex items-center justify-center gap-3 md:justify-start">
                {socials.map((s) => {
                  const Icon = s.icon;
                  return (
                    <a
                      key={s.label}
                      href={s.href}
                      aria-label={s.label}
                      className="grid h-10 w-10 place-items-center rounded-full transition-colors"
                      style={{ background: "rgba(255,255,255,0.08)" }}
                    >
                      <Icon strokeWidth={1.7} className="h-[17px] w-[17px]" />
                    </a>
                  );
                })}
              </div>
            </Reveal>

            <Reveal
              delay={0.08}
              as="ul"
              className="grid w-full max-w-[17rem] grid-cols-2 gap-x-4 gap-y-3 text-center md:flex md:max-w-none md:flex-wrap md:justify-start md:gap-x-7 md:text-left"
            >
              {footerNav.map((item) => (
                <li key={item.label}>
                  <a
                    href={item.href}
                    className="text-[14px] font-medium transition-colors"
                    style={{ color: "rgba(255,255,255,0.55)" }}
                  >
                    {item.label}
                  </a>
                </li>
              ))}
            </Reveal>
          </div>

          <Reveal
            delay={0.12}
            className="mt-12 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-center md:mt-14 md:justify-start md:text-left"
          >
            <span className="mono-label" style={{ color: "rgba(255,255,255,0.4)" }}>
              &copy; 2026 {BRAND}
            </span>
            <a
              href="#"
              className="mono-label transition-colors"
              style={{ color: "rgba(255,255,255,0.4)" }}
            >
              Imprint
            </a>
            <a
              href="#"
              className="mono-label transition-colors"
              style={{ color: "rgba(255,255,255,0.4)" }}
            >
              Privacy
            </a>
            <span
              className="mono-label hidden md:ml-auto md:inline"
              style={{ color: "rgba(255,255,255,0.4)" }}
            >
              Method &rarr; Drills &rarr; Plan &rarr; Perform
            </span>
          </Reveal>

          <p
            aria-hidden
            className="pointer-events-none mt-8 flex justify-center overflow-hidden pb-1 font-display leading-[0.85] tracking-[-0.045em] select-none md:mt-8 md:translate-y-[18%]"
            style={{ fontSize: "clamp(2.2rem, 13vw, 12rem)" }}
          >
            {[...wordmark].map((ch, i) => (
              <Reveal key={i} as="span" delay={i * 0.045} y={0} className="inline-block">
                <span style={{ color: ch === "." ? ACCENT_BRIGHT : "#fff" }}>{ch}</span>
              </Reveal>
            ))}
          </p>
        </div>
      </div>
    </footer>
  );
}

/* ------------------------------------------------------------------ *
 * Page
 * ------------------------------------------------------------------ */

export function CleansheetPage() {
  return (
    <div className="cs-root">
      <style>{scopedStyles}</style>
      <Header />
      <main>
        <Hero />
        <Statement />
        <Concept />
        <HowItWorks />
        <UseCases />
        <Ecosystem />
        <PlanningPortal />
        <Journal />
        <CtaScene />
      </main>
      <Footer />
    </div>
  );
}
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