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.
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} · {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} · {current.drill.players} ·{" "}
{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}′
</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 }}>→</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">
→
</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)" }}>
© 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 → Drills → Plan → 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>
);
}
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