Skip to content
All templates

Templates/Goalkeeper Coaching

A bold full-page template for goalkeeper coaching, academies and camps. A layered hero with a cut-out keeper, a scroll driven statement and an interactive five stage method explorer carry the top of the page. A product orbit, a live planning portal mockup with cycling sessions, and a full-bleed closing scene round it out. One pitch green accent, real photography, calm motion throughout.

Sports & CoachingFull page

The finished page to scroll through, switchable between desktop, tablet and mobile.

Source code

The complete page as a single file. Copy it into ./components and add the component to your route.

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

Frequently asked questions

A curated landing page made of matching library sections in a sensible order from hero to footer. You copy the sections one by one and assemble them with the composition snippet.

Didn't find your question? We're happy to help.

Get in touch