Templates/Career Counselling
A page for people who sell an hour of their attention: counsellors, therapists, advisors, solo consultants. White and one very light grey carry everything, a deep green marks whatever can be acted on, and a fresh yellow green appears only in motion, as a circle that grows out of the left edge of every button on hover. Nothing on the page is that colour while it sits still. A serif holds the headlines and the quotes against a grotesk for everything that is read rather than looked at. The two pinned blocks fall back to plain stacked lists on phones, where a pin reads as a page that will not scroll.
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,
useMotionValueEvent,
useReducedMotion,
useScroll,
useTransform,
type MotionValue,
} from "framer-motion";
import {
ArrowRight,
ArrowUp,
ArrowUpRight,
CalendarPlus,
Check,
ClipboardList,
Compass,
Eye,
FileText,
Flag,
GraduationCap,
Handshake,
Layers,
Mail,
MapPin,
MessageSquare,
Menu,
Phone,
Rocket,
Scale,
ScrollText,
Share2,
Sparkle,
Target,
Timer,
Users,
Wallet,
X,
} from "lucide-react";
/*
* A full page for a one to one career counselling practice.
*
* The page is white and one very light grey, with a deep green for anything
* that can be acted on and a fresh yellow green as the single accent. That
* accent only ever appears in motion: every button carries a circle that
* grows out of its left edge on hover and swaps the green for the lime.
* Nothing on the page is that colour while it sits still.
*
* Typography runs on two voices. A serif carries the headlines and the
* quotes, a grotesk carries everything that is read rather than looked at.
*
* Motion is tied to the scroll rather than to a one off entrance, because a
* page like this is scrolled slowly and an entrance that finishes after a
* second stands still for the rest of the visit:
*
* - the hero is held back while the rest of the page slides over it and
* keeps drifting up instead of freezing,
* - the sentences people actually open with lie in a pile and scatter
* while the block is pinned, then the whole block flies up and out,
* - the pivot sentence recedes and blurs while the before/after pairs
* travel past it,
* - photographs drift inside their frames, the quote band grows into
* place, and two rows of chips and one row of cards run continuously.
*
* Everything checks prefers-reduced-motion and falls back to a still page,
* and the two pinned blocks fall back to plain stacked lists on phones,
* where a pin costs a lot and reads as a page that will not scroll.
*
* The practice, the counsellor and every line of text are invented.
*/
/* ------------------------------------------------------------- grundwerte */
const PAPER = "#ffffff";
const MIST = "#f3f4f1";
const EDGE = "rgba(31,36,28,0.10)";
const FOREST = "#44573d";
const DEEP = "#2c3827";
const LIME = "#bcd94a";
const INK = "#3a3d37";
const QUIET = "#6c7068";
const SERIF = '"Instrument Serif", ui-serif, Georgia, serif';
const SANS = '"Figtree", "Manrope", ui-sans-serif, system-ui, sans-serif';
/* Instrument Serif liefert nur einen Schnitt. Deshalb steht das Gewicht fest
auf 400: erbt die Ueberschrift ein fettes Gewicht, rechnet der Browser es
selbst aus und die Schrift wird breiig. Kursiv, weil genau das der Schnitt
ist, der die Seite traegt. */
const display: CSSProperties = {
fontFamily: SERIF,
fontStyle: "italic",
fontWeight: 400,
letterSpacing: "-0.005em",
};
/* Feines Korn auf den hellgrauen Flaechen. Nicht skaliert, damit ein Korn auf
einem Punkt sitzt: gedehnt wird es weich und liest sich als Schmutz. */
const GRAIN =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.24 0 0 0 0 0.26 0 0 0 0 0.22 0 0 0 0.2 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")";
const EASE = [0.25, 0.8, 0.28, 1] as const;
/*
* Sieben Aufnahmen aus einer Welt: Wiese, Nebel, spaeter Nachmittag, und
* Menschen, die darin arbeiten. Vorher stand hier ein Querschnitt durch die
* Galerie, ein Modeshooting neben einem dunklen Schreibtisch neben einem
* Flughafenhimmel, und die Seite fiel bei jedem Abschnitt in eine andere
* Stimmung.
*
* Ausgewaehlt ist ausserdem nach Datendichte und nicht nur nach Motiv. Die
* vorherige Fassung hatte zwei Bilder mit rund 30 KB auf zwei Megapixel; das
* sieht in einer kleinen Kachel gut aus und zerfaellt, sobald es ueber die
* ganze Breite laeuft. Alle hier liegen ueber 160 KB je Megapixel.
*/
const BILD = {
hero: "/heros/highly-realistic-vertical-editorial-campaig.webp",
wiese: "/heros/late-afternoon-sun-casting-long-shadows-2.webp",
sitzen: "/heros/highly-realistic-vertical-campaign.webp",
portrait: "/heros/overhead-perspective-of-a-wide-grassland-covered.webp",
huegel: "/heros/hero-spring-field.webp",
wald: "/heros/a-misty-forest-scene-with-sunlight-2.webp",
garten: "/heros/blurry-piet-oudolf-garden-photorealist-afbc.webp",
};
/* ------------------------------------------------------------ kleinteiliges */
/**
* Ab welcher Breite die zwei angehefteten Bloecke laufen. Auf dem Telefon
* kostet ein Pin viel und liest sich wie eine Seite, die nicht weiterscrollt;
* dort stehen dieselben Inhalte als ruhige Liste.
*/
function useBreit(abfrage = "(min-width: 768px) and (pointer: fine)") {
const [breit, setBreit] = useState(false);
useEffect(() => {
const mq = window.matchMedia(abfrage);
const lesen = () => setBreit(mq.matches);
lesen();
mq.addEventListener("change", lesen);
return () => mq.removeEventListener("change", lesen);
}, [abfrage]);
return breit;
}
/**
* Erscheint einmal beim Hereinkommen: aus einer weichen Unschaerfe anheben,
* aufblenden, stehenbleiben.
*
* Die Unschaerfe ist der Unterschied zwischen "war schon da" und "kommt
* gerade": ein reines Aufblenden sieht nach einem Schalter aus, ein Aufblenden
* aus drei Pixeln Unschaerfe nach einem Scharfstellen.
*
* Auf schmalen Schirmen bleibt sie weg. Eine Unschaerfe auf vielen Elementen
* gleichzeitig ist genau das, was Scrollen auf dem Telefon stocken laesst,
* und der Gewinn ist dort am kleinsten.
*/
function Auftritt({
children,
verzug = 0,
className = "",
}: {
children: ReactNode;
verzug?: number;
className?: string;
}) {
const reduce = useReducedMotion();
const breit = useBreit("(min-width: 768px)");
const weich = breit && !reduce;
return (
<motion.div
className={className}
initial={
reduce ? false : weich ? { opacity: 0, y: 16, filter: "blur(3px)" } : { opacity: 0, y: 12 }
}
whileInView={weich ? { opacity: 1, y: 0, filter: "blur(0px)" } : { opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: weich ? 1 : 0.7, delay: verzug, ease: EASE }}
>
{children}
</motion.div>
);
}
/**
* Ein Bild, das in seinem Rahmen mitwandert. Der Rahmen schneidet ab, das
* Bild ist leicht groesser als er und verschiebt sich gegen den Scroll.
*/
function Wanderbild({
src,
alt,
weite = 44,
className = "",
}: {
src: string;
alt: string;
/** Gesamtweg in Pixeln ueber den ganzen Durchlauf. */
weite?: number;
className?: string;
}) {
const reduce = useReducedMotion();
const rahmen = useRef<HTMLSpanElement>(null);
const { scrollYProgress } = useScroll({
target: rahmen,
offset: ["start end", "end start"],
});
const y = useTransform(scrollYProgress, [0, 1], [weite / 2, -weite / 2]);
return (
<span ref={rahmen} className="absolute inset-0 block overflow-hidden">
<motion.img
src={src}
alt={alt}
draggable={false}
loading="lazy"
decoding="async"
className={`h-full w-full scale-[1.12] object-cover select-none ${className}`}
style={{ y: reduce ? 0 : y }}
/>
</span>
);
}
/** Kicker: gesperrte Kleinschrift auf einer ganz leichten Flaeche. */
function Marke({
icon: Icon,
children,
hell = false,
}: {
icon: typeof Compass;
children: ReactNode;
hell?: boolean;
}) {
return (
/* Zurueckgenommen: der Kicker sagt, worum es gleich geht, und ist nicht
selbst die Nachricht. Vorher stand hier eine graue Pille in
Textgroesse, die gegen die Ueberschrift darunter antrat. Jetzt traegt
nur noch die Flaeche, und die Schrift ist kleiner, heller und
gesperrt. */
<span
className="inline-flex items-center gap-1.5 px-2 py-1 text-[11.5px] font-medium"
style={{
borderRadius: 5,
fontFamily: SANS,
letterSpacing: "0.05em",
background: hell ? "rgba(255,255,255,0.12)" : "rgba(31,36,28,0.04)",
color: hell ? "rgba(255,255,255,0.82)" : "rgba(108,112,104,0.9)",
}}
>
<Icon size={13} strokeWidth={1.5} aria-hidden />
{children}
</span>
);
}
/**
* Knopf mit wachsendem Kreis.
*
* Der Kreis sitzt hinter der Beschriftung, faengt links ausserhalb an und
* waechst beim Zeigen ueber die ganze Flaeche. Deshalb ist der Akzent im
* Ruhezustand nirgends zu sehen und trotzdem ueberall dort, wo etwas
* anklickbar ist. Der Knopf selbst wird dabei nicht groesser.
*/
function Knopf({
href,
children,
art = "voll",
icon: Icon,
onClick,
}: {
href?: string;
children: ReactNode;
/** voll: gruen. hell: gruen auf Bild, fuellt weiss. leise: fast durchsichtig. */
art?: "voll" | "hell" | "leise";
icon?: typeof Compass;
onClick?: () => void;
}) {
const reduce = useReducedMotion();
const grund = art === "leise" ? "rgba(68,87,61,0.10)" : FOREST;
const schrift = art === "leise" ? FOREST : "#ffffff";
/* Ein Hoverbild fuer die ganze Seite: aus dem tiefen Gruen waechst das
helle. Auch auf den Bildern, wo frueher Weiss einlief; zwei verschiedene
Fuellungen liessen denselben Knopf je nach Untergrund anders aussehen.
Nur der leise Knopf bleibt anders: er ist schon fast durchsichtig und
wird beim Zeigen zu dem, was der andere im Ruhezustand ist. */
const kreis = art === "leise" ? FOREST : LIME;
const nachher = art === "leise" ? "#ffffff" : INK;
const inhalt = (
<>
{!reduce && (
<span
aria-hidden
className="pointer-events-none absolute top-1/2 -left-[10%] -z-10 aspect-square w-[130%] origin-left scale-0 rounded-full opacity-0 transition-[transform,opacity] duration-[900ms] group-hover:scale-100 group-hover:opacity-100 group-focus-visible:scale-100 group-focus-visible:opacity-100"
style={{
background: kreis,
translate: "0 -50%",
transitionTimingFunction: "cubic-bezier(0.33,1,0.68,1)",
}}
/>
)}
{children}
{Icon && (
<Icon
size={17}
strokeWidth={1.7}
aria-hidden
/* Das Symbol kommt erst beim Zeigen heraus. Im Ruhezustand ist es
null Pixel breit, der Knopf aendert dabei seine Breite kaum. */
className="max-w-0 -translate-x-1 opacity-0 transition-all duration-300 group-hover:max-w-5 group-hover:translate-x-0 group-hover:opacity-100 group-focus-visible:max-w-5 group-focus-visible:opacity-100 motion-reduce:max-w-5 motion-reduce:translate-x-0 motion-reduce:opacity-100"
/>
)}
</>
);
const gemeinsam = {
className:
"group relative isolate inline-flex min-h-[2.75rem] items-center justify-center gap-2 overflow-hidden px-5 text-[15px] font-medium",
style: {
borderRadius: 6,
fontFamily: SANS,
background: grund,
color: schrift,
transition: "color 640ms cubic-bezier(0.33,1,0.68,1)",
} as CSSProperties,
onMouseEnter: (ev: React.MouseEvent<HTMLElement>) => {
ev.currentTarget.style.color = nachher;
},
onMouseLeave: (ev: React.MouseEvent<HTMLElement>) => {
ev.currentTarget.style.color = schrift;
},
};
if (href) {
return (
<a href={href} onClick={onClick} {...gemeinsam}>
{inhalt}
</a>
);
}
return (
<button type="button" onClick={onClick} {...gemeinsam}>
{inhalt}
</button>
);
}
/** Textlink, dessen Linie beim Zeigen von rechts nach links einzieht. */
function Leiselink({
href,
children,
icon: Icon = ArrowRight,
pfeil = true,
}: {
href: string;
children: ReactNode;
icon?: typeof ArrowRight;
/** Ohne Pfeil, wo der Link neben einem Knopf steht, der schon einen hat. */
pfeil?: boolean;
}) {
return (
<a
href={href}
className="group inline-flex items-center gap-1.5 pb-0.5 text-[15px] font-medium"
style={{
fontFamily: SANS,
color: FOREST,
backgroundImage: "linear-gradient(currentColor, currentColor)",
backgroundSize: "100% 1px",
backgroundRepeat: "no-repeat",
backgroundPosition: "0 100%",
transition: "background-size 240ms ease, color 200ms ease",
}}
onMouseEnter={(ev) => {
(ev.currentTarget as HTMLElement).style.backgroundSize = "35% 1px";
}}
onMouseLeave={(ev) => {
(ev.currentTarget as HTMLElement).style.backgroundSize = "100% 1px";
}}
>
{children}
{pfeil && (
<Icon
size={15}
strokeWidth={1.7}
aria-hidden
className="max-w-0 -translate-x-1 opacity-0 transition-all duration-300 group-hover:max-w-4 group-hover:translate-x-0 group-hover:opacity-100 motion-reduce:max-w-4 motion-reduce:translate-x-0 motion-reduce:opacity-100"
/>
)}
</a>
);
}
/* ------------------------------------------------------------------ kopf */
const NAV = [
{ label: "Your situation", href: "#situation" },
{ label: "How I work", href: "#method" },
{ label: "About", href: "#about" },
{ label: "Fees & format", href: "#fees" },
];
function Wortmarke({ farbe = INK, klein = false }: { farbe?: string; klein?: boolean }) {
return (
<span
className={klein ? "text-[17px]" : "text-[19px]"}
style={{ ...display, color: farbe, letterSpacing: "-0.02em" }}
>
Ashworth
<span style={{ color: FOREST }}>.</span>
</span>
);
}
function Kopf() {
const [offen, setOffen] = useState(false);
const [weg, setWeg] = useState(false);
const { scrollY } = useScroll();
const letzte = useRef(0);
/* Beim Herunterscrollen verschwindet die Leiste, beim kleinsten Ruck nach
oben ist sie wieder da. Oben auf der Seite steht sie immer. */
useMotionValueEvent(scrollY, "change", (y) => {
const delta = y - letzte.current;
letzte.current = y;
if (y < 120) setWeg(false);
else if (delta > 6) setWeg(true);
else if (delta < -6) setWeg(false);
});
return (
<header
className="sticky top-0 z-50"
style={{
background: "rgba(255,255,255,0.9)",
backdropFilter: "blur(14px) saturate(1.2)",
WebkitBackdropFilter: "blur(14px) saturate(1.2)",
transform: weg && !offen ? "translateY(-100%)" : "translateY(0)",
transition: "transform 360ms cubic-bezier(0.2,0.8,0.2,1)",
}}
>
<div className="mx-auto grid min-h-[4.5rem] w-full max-w-[84rem] grid-cols-[1fr_auto] items-center gap-6 px-5 sm:px-8 lg:min-h-[5.5rem] lg:grid-cols-[1fr_auto_1fr]">
<a href="#top" className="justify-self-start">
<Wortmarke />
</a>
<nav className="hidden items-center gap-9 justify-self-center lg:flex" aria-label="Main">
{NAV.map((item) => (
<a
key={item.href}
href={item.href}
className="group relative py-1.5 text-[12px] font-medium tracking-[0.12em] whitespace-nowrap uppercase transition-colors"
style={{ fontFamily: SANS, color: QUIET }}
>
{item.label}
<span
aria-hidden
className="absolute inset-x-0 bottom-0 h-px origin-left scale-x-0 transition-transform duration-300 group-hover:scale-x-100"
style={{ background: INK }}
/>
</a>
))}
</nav>
<div className="flex items-center gap-2 justify-self-end">
<span className="hidden md:inline-flex">
<Knopf href="#contact" icon={CalendarPlus}>
Book a first call
</Knopf>
</span>
<button
type="button"
onClick={() => setOffen((v) => !v)}
aria-expanded={offen}
aria-controls="nav-klein"
aria-label={offen ? "Close menu" : "Open menu"}
className="inline-flex h-11 w-11 items-center justify-center transition-colors lg:hidden"
style={{ borderRadius: 6, color: INK }}
>
{offen ? <X size={22} strokeWidth={1.6} /> : <Menu size={22} strokeWidth={1.6} />}
</button>
</div>
</div>
{/* Aufklappen ueber die Zeilenhoehe des Rasters: das braucht keine feste
Hoehe und kommt deshalb ohne Messen aus. */}
<div
id="nav-klein"
className="grid lg:hidden"
style={{
gridTemplateRows: offen ? "1fr" : "0fr",
transition: "grid-template-rows 320ms cubic-bezier(0.2,0.8,0.2,1)",
}}
>
<div className="overflow-hidden">
<nav
className="mx-auto flex w-full max-w-[84rem] flex-col px-5 py-3 sm:px-8"
aria-label="Mobile"
>
{NAV.map((item) => (
<a
key={item.href}
href={item.href}
onClick={() => setOffen(false)}
className="py-3 text-xl"
style={{ ...display, color: INK }}
>
{item.label}
</a>
))}
<span className="mt-3 mb-4 md:hidden">
<Knopf href="#contact" icon={CalendarPlus} onClick={() => setOffen(false)}>
Book a first call
</Knopf>
</span>
</nav>
</div>
</div>
</header>
);
}
/* ------------------------------------------------------------------ hero */
function Hero() {
const reduce = useReducedMotion();
const breit = useBreit("(min-width: 768px)");
const halter = useRef<HTMLDivElement>(null);
/* Der Hero bleibt stehen, waehrend der Rest der Seite darueber schiebt,
wandert dabei aber langsam weiter nach oben. Ein Bild, das festklebt und
sich nicht ruehrt, sieht aus wie ein Fehler. */
const { scrollYProgress } = useScroll({
target: halter,
offset: ["start start", "end start"],
});
const y = useTransform(scrollYProgress, [0, 1], [0, -110]);
/* Das Nachwandern gehoert zum Festhalten. Ohne das Festhalten waere es nur
ein Bild, das sich vom Rest der Seite loest und eine Luecke hinterlaesst,
deshalb laeuft beides zusammen oder gar nicht. */
const haelt = breit && !reduce;
return (
<div ref={halter} id="top">
<motion.section
className={haelt ? "sticky top-0 z-0" : "relative"}
style={{ y: haelt ? y : 0 }}
>
<div className="mx-auto grid w-full max-w-[84rem] items-center gap-x-12 gap-y-9 px-5 py-10 sm:px-8 sm:py-14 lg:grid-cols-12 lg:py-24">
<div className="lg:col-span-6">
<Auftritt>
<Marke icon={Compass}>One to one, by video call. Since 2004.</Marke>
</Auftritt>
<Auftritt verzug={0.08}>
<h1
className="mt-5 text-[clamp(2rem,3.5vw,3.05rem)] leading-[1.07] text-balance"
style={{ ...display, color: INK }}
>
The job still works on paper. That is usually the hard part.
</h1>
</Auftritt>
<Auftritt verzug={0.16}>
<p
className="mt-5 max-w-[58ch] text-[17px] leading-relaxed"
style={{ fontFamily: SANS, color: QUIET }}
>
Careers rarely break in one go. They drift, and one day the week no longer fits the
person living it. Talk it through with someone whose whole job is this conversation,
before you hand in anything.
</p>
</Auftritt>
<Auftritt verzug={0.24}>
<div className="mt-8 flex flex-wrap items-center gap-x-6 gap-y-3">
<Knopf href="#contact" icon={CalendarPlus}>
Book a first call
</Knopf>
<Leiselink href="#method" pfeil={false}>
See how a session runs
</Leiselink>
</div>
</Auftritt>
{/* Auf schmalen Schirmen steht das Portrait nicht neben dem Text,
sondern als kleine Zeile darunter. Sonst waere es unter dem
Falz. */}
<Auftritt verzug={0.3}>
<a href="#about" className="mt-8 flex items-center gap-3 lg:hidden">
<img
src={BILD.portrait}
alt="Robin Ashworth, career counsellor"
className="h-11 w-11 shrink-0 rounded-full object-cover object-[57%_42%]"
/>
<span className="text-sm leading-snug" style={{ fontFamily: SANS, color: QUIET }}>
<span style={{ color: INK, fontWeight: 600 }}>Robin Ashworth</span>, the person on
the other side of the call
</span>
</a>
</Auftritt>
</div>
<div className="lg:col-span-6">
<Auftritt verzug={0.12}>
<div className="relative mx-auto max-w-[580px] lg:mr-0 lg:ml-auto">
<figure
className="relative aspect-[5/6] overflow-hidden"
style={{ borderRadius: 12 }}
>
<Wanderbild
src={BILD.hero}
alt="A person standing in a flowering meadow, open laptops resting in the grass"
weite={54}
/>
</figure>
{/* Statt eines Siegels die Zahl selbst. Ein Siegel ist ein
Bild, das man nicht lesen kann, ohne es zu vergroessern. */}
<a
href="#voices"
className="absolute -top-4 -left-4 hidden flex-col px-4 py-3 sm:flex"
style={{ borderRadius: 10, background: PAPER, fontFamily: SANS }}
>
<span className="text-[19px] font-semibold" style={{ color: INK }}>
4.9 / 5
</span>
<span
className="text-[11.5px] tracking-[0.08em] uppercase"
style={{ color: QUIET }}
>
412 reviews
</span>
</a>
</div>
</Auftritt>
</div>
</div>
</motion.section>
</div>
);
}
/* ---------------------------------------------------- saetze, die zerfallen */
/*
* Jede Karte liegt zuerst lose auf einem Stapel in der Mitte (sx, sy, sr) und
* wandert an ihren Platz (zx, zy, zr). Die Werte sind Anteile der Flaeche in
* Prozent, damit die Anordnung mit dem Fenster mitwaechst. v verzoegert die
* einzelne Karte innerhalb des Scrollwegs.
*/
const SAETZE = [
{
text: "I got promoted and I have dreaded Mondays ever since.",
sx: -4,
sy: -7,
sr: -7,
zx: -30,
zy: -33,
zr: -4,
v: 0,
},
{
text: "Every opening I look at is the same job with a new name on it.",
sx: 5,
sy: -2,
sr: 5,
zx: 3,
zy: -38,
zr: 2,
v: 0.06,
},
{
text: "I have been meaning to leave for three years.",
sx: -2,
sy: 5,
sr: -3,
zx: 31,
zy: -31,
zr: 4,
v: 0.12,
},
{
text: "I am good at this. I just do not want to do it any more.",
sx: 6,
sy: 8,
sr: 8,
zx: -36,
zy: 4,
zr: -2,
v: 0.03,
},
{
text: "I took two years out and now nobody calls back.",
sx: -7,
sy: 1,
sr: -6,
zx: 35,
zy: 7,
zr: 3,
v: 0.09,
},
{
text: "I would take the pay cut. I do not know what for.",
sx: 3,
sy: -9,
sr: 4,
zx: -23,
zy: 35,
zr: 2,
v: 0.15,
},
{
text: "My CV explains my past. It does not argue for anything.",
sx: -1,
sy: 10,
sr: -9,
zx: 25,
zy: 36,
zr: -3,
v: 0.18,
},
];
const WEG_VH = 140;
function Satzkarte({
satz,
lauf,
mass,
i,
}: {
satz: (typeof SAETZE)[number];
lauf: MotionValue<number>;
mass: { w: number; h: number };
i: number;
}) {
const p = useTransform(lauf, [satz.v, satz.v + 0.7], [0, 1], { clamp: true });
const x = useTransform(p, [0, 1], [(satz.sx / 100) * mass.w, (satz.zx / 100) * mass.w]);
const y = useTransform(p, [0, 1], [(satz.sy / 100) * mass.h, (satz.zy / 100) * mass.h]);
const rotate = useTransform(p, [0, 1], [satz.sr, satz.zr]);
const scale = useTransform(p, [0, 1], [0.94, 1]);
return (
<motion.li
/* Die halbe Karte wird ueber die eigenstaendige translate-Eigenschaft
zurueckgeschoben, nicht ueber das Transform: framer-motion belegt das
Transform selbst, und beides zusammen in einer Angabe loescht sich
gegenseitig aus. Gedreht wird dadurch um die Kartenmitte. */
className="pointer-events-auto absolute top-1/2 left-1/2 w-max max-w-[17rem] -translate-x-1/2 -translate-y-1/2 px-[1.2rem] py-[0.95rem] text-center text-[15px] leading-snug font-medium text-balance lg:max-w-[14rem] xl:max-w-[17rem]"
style={{
borderRadius: 12,
background: PAPER,
color: INK,
fontFamily: SANS,
zIndex: 10 - i,
x,
y,
rotate,
scale,
}}
>
{`„${satz.text}“`}
</motion.li>
);
}
function Saetze() {
const reduce = useReducedMotion();
const breit = useBreit();
const an = breit && !reduce;
const buehne = useRef<HTMLElement>(null);
const kasten = useRef<HTMLDivElement>(null);
const [mass, setMass] = useState({ w: 0, h: 0 });
/* Die Zielpunkte stehen in Prozent der Flaeche. Damit daraus Pixel werden,
muss die Flaeche gemessen werden; eine Prozentangabe im Transform bezoege
sich sonst auf die Karte selbst. */
useEffect(() => {
const el = kasten.current;
if (!el || !an) return;
const beobachter = new ResizeObserver(([eintrag]) => {
const r = eintrag.contentRect;
setMass({ w: r.width, h: r.height });
});
beobachter.observe(el);
return () => beobachter.disconnect();
}, [an]);
const { scrollYProgress } = useScroll({
target: buehne,
offset: ["start start", "end end"],
});
/* Der Stapel zerfaellt ueber die ersten zwei Drittel, im letzten Drittel
hebt der ganze Kasten ab. So folgt auf das Auseinandergehen ein Abgang
und nicht ein Stillstand. */
const p = useTransform(scrollYProgress, [0, 0.72], [0, 1], { clamp: true });
const ab = useTransform(scrollYProgress, [0.74, 1], [0, 1], { clamp: true });
const kastenY = useTransform(ab, [0, 1], ["0vh", "-8vh"]);
const kastenScale = useTransform(ab, [0, 1], [1, 0.8]);
const kastenOpacity = useTransform(ab, [0, 1], [1, 0]);
const titelOpacity = useTransform(p, [0.3, 0.68], [0, 1]);
const bildY = useTransform(p, [0, 1], [24, -24]);
return (
<section
id="situation"
ref={buehne}
className="relative scroll-mt-24"
style={an ? { height: `calc(min(86vh, 56rem) + ${WEG_VH}vh)` } : undefined}
>
<div
className="px-5 sm:px-8 lg:px-12 xl:px-16"
style={
an
? {
position: "sticky",
top: "calc((100vh - min(86vh, 56rem)) / 2)",
height: "min(86vh, 56rem)",
}
: undefined
}
>
<motion.div
ref={kasten}
className={`relative isolate flex h-full items-center justify-center overflow-hidden px-6 py-16 ${
an ? "" : "flex-col py-14"
}`}
style={{
borderRadius: 20,
transformOrigin: "50% 0",
y: an ? kastenY : 0,
scale: an ? kastenScale : 1,
opacity: an ? kastenOpacity : 1,
}}
>
<motion.span
aria-hidden
className="absolute inset-[-10%] -z-20 block"
style={{ y: an ? bildY : 0 }}
>
<img
src={BILD.wiese}
alt=""
loading="lazy"
decoding="async"
className="h-full w-full object-cover object-[50%_40%] select-none"
/>
</motion.span>
<span
aria-hidden
className="absolute inset-0 -z-10"
style={{ background: "rgba(0,0,0,0.48)" }}
/>
<motion.div
className={`relative z-[1] mx-auto w-full max-w-[46rem] text-center ${an ? "" : "order-2"}`}
style={an ? { opacity: titelOpacity } : undefined}
>
<Marke icon={Users} hell>
You are not the only one saying it
</Marke>
<h2
className="mt-4 text-[clamp(1.9rem,3.2vw,2.75rem)] leading-[1.06] text-balance"
style={{ ...display, color: "#ffffff" }}
>
Any of this sound familiar?
</h2>
<p
className="mx-auto mt-4 max-w-[46ch] text-[16px] leading-relaxed"
style={{ fontFamily: SANS, color: "rgba(255,255,255,0.85)" }}
>
Sentences people open a first call with. Most of them have been true for months before
anyone says them out loud.
</p>
<p className="mt-5 font-medium" style={{ fontFamily: SANS, color: "#ffffff" }}>
Yes. That is more or less my week.
</p>
<span className="mt-4 inline-flex">
<Knopf href="#contact" art="hell" icon={MessageSquare}>
Put yours into words
</Knopf>
</span>
</motion.div>
{an ? (
<ul className="pointer-events-none absolute inset-0 z-[2] m-0 list-none p-0">
{SAETZE.map((satz, i) => (
<Satzkarte key={satz.text} satz={satz} lauf={p} mass={mass} i={i} />
))}
</ul>
) : (
/* Ruhige Fassung: ein Stapel im Zickzack, der das Foto zwischen
den Karten noch durchscheinen laesst. */
<ul className="relative z-[1] order-1 m-0 mb-8 grid w-full list-none grid-cols-[minmax(0,1fr)] gap-1.5 p-0">
{SAETZE.map((satz, i) => (
<li
key={satz.text}
className={`max-w-[88%] px-3.5 py-2.5 text-[15px] leading-snug font-medium ${
i % 2 === 1 ? "justify-self-end" : ""
}`}
style={{ borderRadius: 12, background: PAPER, color: INK, fontFamily: SANS }}
>
{`„${satz.text}“`}
</li>
))}
</ul>
)}
</motion.div>
</div>
</section>
);
}
/* ---------------------------------------------------------- so lauft es ab */
const PUNKTE = [
{ icon: Users, text: "One to one by video call. No cohort, no curriculum, no group chat." },
{
icon: Target,
text: "Built around your actual constraints: money, notice period, people at home.",
},
{
icon: Check,
text: "Steps small enough that you will still have done them by the next session.",
},
];
function Methode() {
return (
<section id="method" className="scroll-mt-24 pt-16 pb-14 sm:pt-24 sm:pb-20 lg:pt-32 lg:pb-28">
<div className="mx-auto grid w-full max-w-[84rem] items-center gap-x-12 gap-y-10 px-5 sm:px-8 lg:grid-cols-12 lg:gap-x-20">
<div className="lg:col-span-7">
<Auftritt>
<Marke icon={ClipboardList}>How I work</Marke>
</Auftritt>
<Auftritt verzug={0.06}>
<h2
className="mt-4 max-w-[24ch] text-[clamp(1.6rem,2.5vw,2.15rem)] leading-[1.1] text-balance"
style={{ ...display, color: INK }}
>
You are not buying a framework. You are buying an hour of undivided attention.
</h2>
</Auftritt>
<Auftritt verzug={0.12}>
<div
className="mt-5 max-w-[62ch] space-y-3 text-[16px] leading-relaxed"
style={{ fontFamily: SANS, color: QUIET }}
>
<p>
You have probably read the books, taken the strengths test and made the list of pros
and cons twice. Most of that advice is sound. None of it knows your notice period,
your mortgage or the conversation you are dreading at home.
</p>
<p>What you do not need now is another reasonable suggestion.</p>
</div>
</Auftritt>
<ul
className="m-0 mt-7 list-none space-y-2.5 p-0 text-[15px]"
style={{ fontFamily: SANS }}
>
{PUNKTE.map((punkt, i) => (
<Auftritt key={punkt.text} verzug={0.16 + i * 0.05}>
<li className="flex items-start gap-2.5" style={{ color: QUIET }}>
<punkt.icon
size={17}
strokeWidth={1.6}
aria-hidden
className="mt-[0.2rem] shrink-0"
style={{ color: FOREST }}
/>
<span>{punkt.text}</span>
</li>
</Auftritt>
))}
</ul>
<Auftritt verzug={0.32}>
<div className="mt-8">
<Knopf href="#fees" icon={ArrowRight}>
See the format and the fees
</Knopf>
</div>
</Auftritt>
</div>
{/* Das Zitat liegt auf dem Bild und kommt erst beim Zeigen. Auf
Geraeten ohne Zeiger steht es dauerhaft da, sonst waere es dort
nicht erreichbar. */}
<Auftritt verzug={0.1} className="lg:col-span-5">
<figure
className="group relative aspect-square overflow-hidden"
style={{ borderRadius: 12 }}
>
<Wanderbild
src={BILD.sitzen}
alt="A person sitting alone on a lawn in late afternoon light"
className="object-[50%_40%]"
/>
<figcaption
className="absolute inset-0 z-[1] flex items-end p-6 opacity-100 transition-opacity duration-500 sm:p-8 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100"
style={{
background: "linear-gradient(180deg, rgba(0,0,0,0.05), rgba(0,0,0,0.58))",
color: "#ffffff",
}}
>
<blockquote className="m-0 translate-y-0 transition-transform duration-500 md:translate-y-2.5 md:group-hover:translate-y-0">
<p
className="max-w-[34ch] text-[15px] leading-relaxed font-medium text-balance lg:text-base"
style={{ fontFamily: SANS }}
>
{
"„Most people arrive with the decision already made. What is missing is the sentence they can say out loud.“"
}
</p>
<footer
className="mt-2 text-xs"
style={{ fontFamily: SANS, color: "rgba(255,255,255,0.75)" }}
>
Robin Ashworth
</footer>
</blockquote>
</figcaption>
</figure>
</Auftritt>
</div>
</section>
);
}
/* --------------------------------------------------------------- ueber mich */
function UeberMich() {
return (
<section
id="about"
className="relative scroll-mt-24 pt-16 pb-14 sm:pt-24 sm:pb-20 lg:pt-32 lg:pb-28"
>
<div className="mx-auto grid w-full max-w-[84rem] items-center gap-x-12 gap-y-9 px-5 sm:px-8 lg:grid-cols-12">
<Auftritt className="mx-auto max-w-[440px] lg:col-span-5 lg:mx-0">
<figure className="relative aspect-[3/4] overflow-hidden" style={{ borderRadius: 12 }}>
<Wanderbild
src={BILD.portrait}
alt="Robin Ashworth working from a laptop in tall summer grass"
className="object-[57%_38%]"
/>
</figure>
</Auftritt>
<div className="lg:col-span-7">
<Auftritt>
<Marke icon={Timer}>Twenty one years of these conversations</Marke>
</Auftritt>
<Auftritt verzug={0.06}>
<h2
className="mt-4 text-[clamp(1.6rem,2.5vw,2.15rem)] leading-[1.1] text-balance"
style={{ ...display, color: INK }}
>
I am Robin Ashworth.
</h2>
</Auftritt>
<Auftritt verzug={0.12}>
<div
className="mt-5 max-w-[62ch] space-y-3 text-[16px] leading-relaxed"
style={{ fontFamily: SANS, color: QUIET }}
>
<p>
I spent eleven years hiring for other people before I started sitting on this side
of the table. That is where the habit comes from: I read a career the way the person
reading your application will, and then we work backwards from there.
</p>
<p>
Sessions run by video call, which means it does not matter where you live or which
time zone your working day sits in. Evenings and early mornings are usually the
easiest slots to find.
</p>
<p>
Registered practitioner, member number 4412, in supervision with Elena Bright since
2011. The longer version, including the two years I spent getting this wrong, is on{" "}
<a
href="#about"
className="inline-flex items-center gap-0.5 font-semibold"
style={{
color: FOREST,
textDecoration: "underline",
textDecorationColor: "rgba(68,87,61,0.45)",
textUnderlineOffset: "0.2em",
}}
>
the about page
<ArrowUpRight size={14} strokeWidth={1.7} aria-hidden />
</a>
</p>
</div>
</Auftritt>
<Auftritt verzug={0.2}>
<div className="mt-7">
<Knopf href="#about" art="leise" icon={ArrowRight}>
Read the long version
</Knopf>
</div>
</Auftritt>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------ die andere frage */
function AndereFrage() {
return (
<section className="pb-14 sm:pb-20 lg:pb-28">
<div className="mx-auto w-full max-w-[84rem] px-5 sm:px-8">
<div
/* Auf Hoehe dieses Abschnitts steht die Tintbuehne auf Weiss,
deshalb setzt sich das hellgraue Feld hier ab. */
className="relative isolate overflow-hidden px-6 py-10 sm:px-10 lg:px-14 lg:py-16"
style={{ borderRadius: 16, background: MIST }}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 opacity-55 mix-blend-multiply"
style={{ backgroundImage: GRAIN, backgroundSize: "160px 160px" }}
/>
<div className="grid gap-x-14 gap-y-8 lg:grid-cols-12">
<div className="lg:col-span-6">
<Auftritt>
<Marke icon={Eye}>A different question</Marke>
</Auftritt>
<Auftritt verzug={0.06}>
<h2
className="mt-5 text-[clamp(1.05rem,1.5vw,1.25rem)] leading-snug font-medium"
style={{ fontFamily: SANS, color: INK }}
>
Looking for the job that fixes this?
</h2>
</Auftritt>
{/* Der Dreh des Abschnitts traegt das Gewicht, deshalb steht er
gross und in der Akzentfarbe der Seite. */}
<Auftritt verzug={0.12}>
<p
className="mt-4 text-[clamp(1.6rem,3.1vw,2.55rem)] leading-[1.12] text-balance"
style={{ ...display, color: FOREST }}
>
What do I want an ordinary Wednesday to look like?
</p>
</Auftritt>
</div>
<div className="space-y-4 lg:col-span-6 lg:pt-2">
<Auftritt verzug={0.1}>
<p
className="text-[16px] leading-relaxed"
style={{ fontFamily: SANS, color: QUIET }}
>
When work stops fitting, the first instinct is to look for a better title.
Sometimes that is exactly right. More often the title was never the problem, and
the new one comes with the same Wednesday attached.
</p>
</Auftritt>
<Auftritt verzug={0.16}>
<p
className="text-[16px] leading-relaxed"
style={{ fontFamily: SANS, color: QUIET }}
>
So we start with the week itself: the hours, the people, the commute, the part of
the work you would miss. Roles are easier to judge once you know what you are
judging them against.
</p>
</Auftritt>
</div>
</div>
<Auftritt verzug={0.2}>
<div
className="mt-10 flex flex-wrap items-center justify-between gap-x-8 gap-y-2 pt-5 text-sm"
style={{ borderTop: `1px solid ${EDGE}`, fontFamily: SANS }}
>
<p className="font-medium" style={{ color: INK }}>
The week before the title
</p>
<Leiselink href="#method" icon={ArrowUpRight}>
How the first two sessions are structured
</Leiselink>
</div>
</Auftritt>
</div>
</div>
</section>
);
}
/* ----------------------------------------------------------------- themen */
type Thema = { label: string; icon: typeof Compass };
const REIHE_EINS: Thema[] = [
{ label: "A promotion you did not want", icon: Layers },
{ label: "Going freelance", icon: Rocket },
{ label: "Coming back after a break", icon: Flag },
{ label: "Leaving the family business", icon: Handshake },
{ label: "Burnout and what comes after", icon: Timer },
{ label: "Management or the craft", icon: Scale },
{ label: "Redundancy at short notice", icon: FileText },
];
const REIHE_ZWEI: Thema[] = [
{ label: "Asking for more money", icon: Wallet },
{ label: "Studying again at forty", icon: GraduationCap },
{ label: "Moving countries for work", icon: MapPin },
{ label: "First job after a doctorate", icon: ScrollText },
{ label: "Stepping back from leading", icon: Users },
{ label: "Turning a side project into the job", icon: Sparkle },
];
function Chips({ items, versteckt = false }: { items: Thema[]; versteckt?: boolean }) {
return (
<ul
className="m-0 flex flex-none list-none gap-2.5 p-0 pr-2.5"
aria-hidden={versteckt || undefined}
>
{items.map((thema) => (
<li
key={thema.label}
className="inline-flex flex-none items-center gap-2 px-3.5 text-sm font-medium whitespace-nowrap"
style={{
minHeight: "2.4rem",
borderRadius: 6,
/* Weiss und nicht hellgrau: die Chips sitzen am unteren Ende der
Tintbuehne, und dort ist die Flaeche wieder grau. Grau auf Grau
waere eine Reihe, die man nicht mehr sieht. */
background: PAPER,
color: INK,
fontFamily: SANS,
}}
>
<thema.icon size={16} strokeWidth={1.6} aria-hidden style={{ color: FOREST }} />
{thema.label}
</li>
))}
</ul>
);
}
function Laufband({
items,
dauer,
rueckwaerts = false,
}: {
items: Thema[];
dauer: number;
rueckwaerts?: boolean;
}) {
const reduce = useReducedMotion();
return (
<div
className="min-w-0 max-w-full overflow-hidden"
style={{
maskImage: "linear-gradient(90deg, transparent, #000 10%, #000 90%, transparent)",
WebkitMaskImage: "linear-gradient(90deg, transparent, #000 10%, #000 90%, transparent)",
}}
>
<motion.div
className="flex w-max"
animate={reduce ? undefined : { x: rueckwaerts ? ["-50%", "0%"] : ["0%", "-50%"] }}
transition={reduce ? undefined : { duration: dauer, ease: "linear", repeat: Infinity }}
>
<Chips items={items} />
{/* Die zweite Haelfte ist nur die Fortsetzung des Bandes und wird
deshalb vor Vorlesegeraeten versteckt. Sonst wird jede Zeile
zweimal vorgelesen. */}
<Chips items={items} versteckt />
</motion.div>
</div>
);
}
function Themen() {
return (
<section className="py-14 sm:py-20 lg:py-28">
<div className="mx-auto grid w-full max-w-[84rem] items-stretch gap-x-10 gap-y-10 px-5 sm:px-8 lg:grid-cols-12">
<Auftritt className="mx-auto w-full max-w-[440px] lg:col-span-5 lg:mx-0">
<figure className="relative aspect-[10/9] overflow-hidden" style={{ borderRadius: 12 }}>
<Wanderbild src={BILD.huegel} alt="A wide meadow of tall grass bending in the wind" />
</figure>
</Auftritt>
<div className="flex min-w-0 flex-col lg:col-span-7">
<div>
<Auftritt>
<Marke icon={Layers}>What people bring</Marke>
</Auftritt>
<Auftritt verzug={0.06}>
<h2
className="mt-4 text-[clamp(1.75rem,2.8vw,2.4rem)] leading-[1.08] text-balance"
style={{ ...display, color: INK }}
>
What can a session actually be about?
</h2>
</Auftritt>
<Auftritt verzug={0.12}>
<p
className="mt-4 max-w-[54ch] text-[16px] leading-relaxed"
style={{ fontFamily: SANS, color: QUIET }}
>
Every working life is its own shape. These are the situations that come up most
often, and none of them needs to be tidy before the first call.
</p>
</Auftritt>
<Auftritt verzug={0.18}>
<div className="mt-6">
<Knopf href="#contact" art="leise" icon={MessageSquare}>
Bring yours
</Knopf>
</div>
</Auftritt>
</div>
{/* min-w-0 ist hier Bedingung und nicht Kuer: ein Flex-Kind wird von
sich aus nicht schmaler als sein Inhalt, und das Band ist ein
paar Meter lang. Ohne das schiebt es die ganze Seite breiter,
statt in sich zu laufen. */}
<Auftritt verzug={0.22} className="mt-9 min-w-0 lg:mt-auto">
<div className="space-y-3">
<Laufband items={REIHE_EINS} dauer={58} />
<Laufband items={REIHE_ZWEI} dauer={66} rueckwaerts />
</div>
</Auftritt>
</div>
</div>
</section>
);
}
/* -------------------------------------------------------------- zitatband */
function Zitatband() {
const reduce = useReducedMotion();
const band = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: band,
offset: ["start end", "center center"],
});
/* Das Band waechst beim Hereinkommen auf volle Groesse, statt fertig
dazustehen. Es ist das einzige Element der Seite, das das tut. */
const scale = useTransform(scrollYProgress, [0, 1], [0.92, 1]);
return (
<section
className="relative isolate px-4 py-4 lg:px-6 lg:py-6"
style={{ background: MIST }}
aria-label="Quote"
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 opacity-55 mix-blend-multiply"
style={{ backgroundImage: GRAIN, backgroundSize: "160px 160px" }}
/>
<motion.div
ref={band}
className="relative isolate overflow-hidden py-24 lg:py-36"
style={{ borderRadius: 16, scale: reduce ? 1 : scale }}
>
<Wanderbild src={BILD.wald} alt="" weite={70} />
{/* Etwas mehr Schwarz als bei den anderen Bildflaechen: die Wiese ist
hell, und eine helle Serifenschrift auf hellem Gruen liest sich
nicht. Gedimmt, nicht ausgewaschen. */}
<span aria-hidden className="absolute inset-0" style={{ background: "rgba(0,0,0,0.52)" }} />
<div className="relative mx-auto w-[min(100%-2.5rem,46rem)] text-center">
<Auftritt>
<blockquote className="m-0">
<p
className="text-[clamp(1.5rem,2.8vw,2.4rem)] leading-[1.2] text-balance"
style={{ ...display, color: "#ffffff" }}
>
{
"„A career is not a ladder. It is a series of decisions, and most of them are still open.“"
}
</p>
<footer
className="mt-5 text-sm font-medium"
style={{ fontFamily: SANS, color: "rgba(255,255,255,0.85)" }}
>
Robin Ashworth
</footer>
</blockquote>
</Auftritt>
</div>
</motion.div>
</section>
);
}
/* ------------------------------------------------------------- was sich dreht */
const PAARE = [
{ vor: "Drifting", nach: "A decision", icon: Compass, seite: "links", x: 24 },
{
vor: "Every posting open in a tab",
nach: "Three real options",
icon: Target,
seite: "rechts",
x: 22,
},
{ vor: "Someday", nach: "A date in the calendar", icon: CalendarPlus, seite: "links", x: 34 },
{
vor: "Guessing what you are worth",
nach: "A number you can say",
icon: Wallet,
seite: "rechts",
x: 30,
},
{ vor: "A CV that explains", nach: "A CV that argues", icon: FileText, seite: "links", x: 20 },
{ vor: "Waiting to feel ready", nach: "Starting anyway", icon: Rocket, seite: "rechts", x: 36 },
{ vor: "Telling nobody", nach: "Four people who know", icon: Share2, seite: "links", x: 28 },
] as const;
const SCHRITT_VH = 38;
const START_VH = 88;
function Wendung() {
const reduce = useReducedMotion();
const breit = useBreit();
const an = breit && !reduce;
const buehne = useRef<HTMLElement>(null);
const { scrollYProgress } = useScroll({ target: buehne, offset: ["start start", "end end"] });
/* Der Satz bleibt stehen und tritt zurueck, waehrend die Paare an ihm
vorbeiziehen. Er verschwindet nicht, er ruecht in den Hintergrund. */
const scale = useTransform(scrollYProgress, [0, 1], [1, 0.62]);
const blur = useTransform(scrollYProgress, [0, 1], ["blur(0px)", "blur(7px)"]);
const opacity = useTransform(scrollYProgress, [0, 1], [1, 0.45]);
return (
<section
ref={buehne}
className="relative isolate scroll-mt-24"
style={{
background: MIST,
height: an ? `calc(100vh + ${PAARE.length * SCHRITT_VH}vh)` : undefined,
}}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 opacity-55 mix-blend-multiply"
style={{ backgroundImage: GRAIN, backgroundSize: "160px 160px" }}
/>
<div
className={an ? "sticky top-0 flex h-screen items-center justify-center" : "pt-14 sm:pt-20"}
>
<motion.div
className="mx-auto w-[min(100%-2.5rem,46rem)] text-center"
style={an ? { scale, filter: blur, opacity } : undefined}
>
<h2
className="text-[clamp(1.75rem,4.2vw,3.5rem)] leading-[1.06] text-balance"
style={{ ...display, color: INK }}
>
Change starts as a sentence you finally finish.
</h2>
{!an && (
<p
className="mx-auto mt-4 max-w-[44ch] text-[16px] leading-relaxed"
style={{ fontFamily: SANS, color: QUIET }}
>
What shifts in counselling shows up first in small things during an ordinary week.
</p>
)}
</motion.div>
</div>
<ul
className={
an ? "absolute inset-0 m-0 list-none p-0" : "m-0 mt-10 mb-14 list-none p-0 sm:mb-20"
}
>
{PAARE.map((paar, i) =>
an ? (
<li
key={paar.nach}
className="absolute inline-flex items-center gap-3 py-3.5 pr-[1.15rem] pl-[0.85rem] text-[15px] whitespace-nowrap"
style={{
borderRadius: 10,
background: PAPER,
fontFamily: SANS,
top: `calc(${START_VH}vh + ${i * SCHRITT_VH}vh)`,
...(paar.seite === "links" ? { left: `${paar.x}%` } : { right: `${paar.x}%` }),
}}
>
<span
aria-hidden
className="inline-flex h-9 w-9 items-center justify-center"
style={{ borderRadius: 8, background: MIST, color: INK }}
>
<paar.icon size={18} strokeWidth={1.6} />
</span>
<span style={{ color: QUIET }}>{paar.vor}</span>
<ArrowRight size={16} strokeWidth={1.8} aria-hidden style={{ color: FOREST }} />
<span style={{ color: FOREST, fontWeight: 600 }}>{paar.nach}</span>
</li>
) : (
/* Ruhige Fassung: eine Zeile je Paar, die Pfeile in einer Spalte
untereinander. Das liest sich schneller als sieben Karten. */
<li
key={paar.nach}
className="mx-auto grid w-[min(100%-2.5rem,46rem)] grid-cols-[1.25rem_minmax(0,1.15fr)_auto_minmax(0,1fr)] items-center gap-2.5 px-0.5 py-3.5 text-sm"
style={{
fontFamily: SANS,
borderTop: `1px solid ${EDGE}`,
borderBottom: i === PAARE.length - 1 ? `1px solid ${EDGE}` : undefined,
}}
>
<paar.icon size={17} strokeWidth={1.6} aria-hidden style={{ color: QUIET }} />
<span style={{ color: QUIET }}>{paar.vor}</span>
<ArrowRight size={15} strokeWidth={1.8} aria-hidden style={{ color: FOREST }} />
<span style={{ color: FOREST, fontWeight: 600 }}>{paar.nach}</span>
</li>
),
)}
</ul>
</section>
);
}
/* --------------------------------------------------------------- stimmen */
const STIMMEN = [
{
text: "I came in wanting a new title and left with a different question. Six months later I am doing the same work three days a week and it is the right amount.",
wer: "Product manager, 38",
},
{
text: "The part I did not expect was how practical it got. We wrote the actual sentence I would say to my head of department, and then I said it.",
wer: "Physiotherapist, 44",
},
{
text: "Nobody told me to follow my passion, which was a relief. We looked at the numbers first and the rest of the conversation was much easier after that.",
wer: "Site engineer, 51",
},
{
text: "After two years at home I had stopped believing the gap was explainable. It took one session to find the version of it that is simply true.",
wer: "Returning after parental leave, 35",
},
{
text: "Evenings by video call, no travel, no waiting room. We would never have managed it otherwise, and we did the last three sessions as a couple.",
wer: "Teacher, 41",
},
{
text: "I had the offer in my inbox for eleven days. What I needed was not more analysis, it was somebody to ask what I was actually afraid of.",
wer: "Data analyst, 29",
},
{
text: "I did not change jobs in the end. I changed how the job is set up, and that turned out to be the thing I had been avoiding all along.",
wer: "Practice owner, 47",
},
];
function Sterne() {
return (
<p
className="m-0 mb-4 flex gap-px"
aria-label="Five out of five stars"
style={{ color: FOREST }}
>
{Array.from({ length: 5 }, (_, i) => (
<svg key={i} viewBox="0 0 20 20" width="13" height="13" aria-hidden focusable="false">
<path
fill="currentColor"
d="M10 1.6l2.47 5.01 5.53.8-4 3.9.94 5.51L10 14.21l-4.94 2.6.94-5.51-4-3.9 5.53-.8z"
/>
</svg>
))}
</p>
);
}
function Karten({ versteckt = false }: { versteckt?: boolean }) {
return (
<ul
className="m-0 flex flex-none list-none gap-6 p-0 pr-6"
aria-hidden={versteckt || undefined}
>
{STIMMEN.map((stimme) => (
<li
key={stimme.wer}
className="flex w-[78vw] min-h-[66vw] flex-none flex-col p-6 sm:w-[21rem] sm:min-h-[16rem] sm:p-7"
style={{ borderRadius: 14, background: MIST }}
>
<Sterne />
<p
className="m-0 text-[15px] leading-relaxed sm:text-base"
style={{ fontFamily: SANS, color: INK }}
>
{`„${stimme.text}“`}
</p>
<p
className="mt-auto mb-0 pt-5 text-[11px] font-medium tracking-[0.11em] uppercase"
style={{ fontFamily: SANS, color: QUIET }}
>
{stimme.wer}
</p>
</li>
))}
</ul>
);
}
function Stimmen() {
const reduce = useReducedMotion();
return (
<section id="voices" className="scroll-mt-24 py-16 sm:py-24 lg:py-28">
<div className="mx-auto w-[min(100%-2.5rem,46rem)]">
<Auftritt>
<div className="flex justify-center">
<Marke icon={MessageSquare}>What people say afterwards</Marke>
</div>
</Auftritt>
<Auftritt verzug={0.06}>
<h2
className="mt-4 text-center text-[clamp(1.5rem,2.4vw,2rem)] leading-[1.12] text-balance"
style={{ ...display, color: INK }}
>
Seven working lives, a year later
</h2>
</Auftritt>
<Auftritt verzug={0.12}>
<p className="mt-3 text-center text-sm" style={{ fontFamily: SANS, color: QUIET }}>
412 reviews · 4.9 out of 5
</p>
</Auftritt>
</div>
<div
className="mt-12 overflow-hidden sm:mt-14"
style={{
maskImage: "linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent)",
WebkitMaskImage: "linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent)",
}}
>
<motion.div
className="flex w-max"
animate={reduce ? undefined : { x: ["0%", "-50%"] }}
transition={reduce ? undefined : { duration: 110, ease: "linear", repeat: Infinity }}
>
<Karten />
<Karten versteckt />
</motion.div>
</div>
<p
className="mx-auto mt-10 w-[min(100%-2.5rem,46rem)] text-center text-xs"
style={{ fontFamily: SANS, color: QUIET }}
>
All reviews are published anonymously, with permission.
</p>
</section>
);
}
/* ----------------------------------------------------------------- kontakt */
const FORMAT = [
{ label: "First call", wert: "25 minutes, free" },
{ label: "Single session", wert: "£140, 60 minutes" },
{ label: "Four sessions", wert: "£480, over three months" },
];
function Kontakt() {
return (
<section id="contact" className="scroll-mt-24 py-14 sm:py-20 lg:py-28">
<div
id="fees"
className="relative isolate mx-auto flex w-[min(100%-2.5rem,64rem)] scroll-mt-24 flex-col items-center overflow-hidden px-7 py-16 text-center sm:px-14 lg:px-20 lg:py-24"
style={{ borderRadius: 16, color: "#ffffff" }}
>
{/* Der Grund gehoert zur Sache und ist kein Farbschleier: dasselbe
Motiv, nur unscharf und mit Schwarz gedimmt. */}
<span aria-hidden className="absolute inset-[-4%] -z-20 block">
<img
src={BILD.garten}
alt=""
loading="lazy"
decoding="async"
className="h-full w-full object-cover object-[50%_40%] select-none"
style={{ filter: "blur(7px) saturate(0.9)" }}
/>
</span>
<span
aria-hidden
className="absolute inset-0 -z-10"
style={{
backgroundColor: "rgba(0,0,0,0.5)",
backgroundImage: GRAIN,
backgroundSize: "160px 160px",
}}
/>
<Auftritt>
<Marke icon={CalendarPlus} hell>
The first call
</Marke>
</Auftritt>
<Auftritt verzug={0.06}>
<h2
className="mt-4 text-[clamp(1.9rem,3.4vw,2.9rem)] leading-[1.06] text-balance"
style={{ ...display }}
>
Talk it through before you decide anything.
</h2>
</Auftritt>
<Auftritt verzug={0.12}>
<p
className="mt-4 max-w-xl text-[16px] leading-relaxed"
style={{ fontFamily: SANS, color: "rgba(255,255,255,0.85)" }}
>
Twenty five minutes, by video call, at no cost. You describe the situation, I say
whether I am the right person for it, and if I am not I will tell you who is.
</p>
</Auftritt>
<Auftritt verzug={0.18}>
<dl
className="mt-9 flex flex-wrap items-start justify-center gap-x-10 gap-y-5 text-center"
style={{ fontFamily: SANS }}
>
{FORMAT.map((zeile) => (
<div key={zeile.label}>
<dt
className="text-[11px] font-medium tracking-[0.11em] uppercase"
style={{ color: "rgba(255,255,255,0.7)" }}
>
{zeile.label}
</dt>
<dd className="mt-1 ml-0 text-[15px] font-medium">{zeile.wert}</dd>
</div>
))}
</dl>
</Auftritt>
<Auftritt verzug={0.24}>
<div className="mt-9">
<Knopf href="#contact" art="hell" icon={CalendarPlus}>
Book a first call
</Knopf>
</div>
</Auftritt>
</div>
</section>
);
}
/* --------------------------------------------------------------- fusszeile */
const FUSS_EXTERN = [
{ label: "Reading list", href: "#about" },
{ label: "Notes on the work", href: "#about" },
];
const FUSS_KONTAKT = [
{ label: "+44 20 7946 1180", href: "#contact", icon: Phone },
{ label: "hello@ashworth-careers.co.uk", href: "#contact", icon: Mail },
];
function Fusszeile() {
return (
<footer
className="relative isolate mx-4 overflow-hidden lg:mx-6"
style={{ borderRadius: 16, background: MIST, color: INK }}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 opacity-55 mix-blend-multiply"
style={{ backgroundImage: GRAIN, backgroundSize: "160px 160px" }}
/>
<div
className="mx-auto grid w-full max-w-[84rem] gap-x-10 gap-y-10 px-5 pt-14 pb-12 text-sm sm:grid-cols-2 sm:px-8 lg:grid-cols-[1.5fr_1fr_1fr_1fr] lg:pt-16"
style={{ fontFamily: SANS }}
>
<Auftritt className="sm:col-span-2 lg:col-span-1">
<Wortmarke />
<p className="mt-4 max-w-xs leading-relaxed" style={{ color: QUIET }}>
One to one career counselling by video call. Plain, practical and paid for by the hour.
Since 2004.
</p>
<div className="mt-4">
<Leiselink href="#about" icon={ArrowUpRight}>
Newsletter, once a month
</Leiselink>
</div>
</Auftritt>
<Auftritt verzug={0.06}>
<p className="text-sm font-semibold" style={{ color: INK }}>
Navigation
</p>
<ul className="m-0 mt-3 list-none space-y-2.5 p-0">
{NAV.map((item) => (
<li key={item.href}>
<a href={item.href} className="transition-colors" style={{ color: QUIET }}>
{item.label}
</a>
</li>
))}
</ul>
</Auftritt>
<Auftritt verzug={0.12}>
<p className="text-sm font-semibold" style={{ color: INK }}>
Elsewhere
</p>
<ul className="m-0 mt-3 list-none space-y-2.5 p-0">
{FUSS_EXTERN.map((item) => (
<li key={item.label}>
<a
href={item.href}
className="inline-flex items-center gap-1 transition-colors"
style={{ color: QUIET }}
>
{item.label}
<ArrowUpRight size={14} strokeWidth={1.7} aria-hidden />
</a>
</li>
))}
</ul>
</Auftritt>
<Auftritt verzug={0.18}>
<p className="text-sm font-semibold" style={{ color: INK }}>
Contact
</p>
<ul className="m-0 mt-3 list-none space-y-2.5 p-0">
{FUSS_KONTAKT.map((item) => (
<li key={item.label}>
<a
href={item.href}
className="inline-flex items-center gap-2 transition-colors"
style={{ color: QUIET }}
>
<item.icon size={15} strokeWidth={1.6} aria-hidden className="shrink-0" />
{item.label}
</a>
</li>
))}
</ul>
<p className="mt-4 max-w-[26ch]" style={{ color: QUIET }}>
Sessions run by video call, wherever you happen to live.
</p>
</Auftritt>
</div>
<div
className="mx-auto flex w-full max-w-[84rem] flex-wrap items-center justify-between gap-x-8 gap-y-2 px-5 pt-4 pr-14 pb-8 text-xs sm:px-8 sm:pr-16 lg:pr-20"
style={{ fontFamily: SANS, color: QUIET }}
>
<p className="flex flex-wrap items-center gap-x-4">
<span>© 2026 Robin Ashworth</span>
<a href="#top">Imprint</a>
<a href="#top">Privacy</a>
</p>
<p>The photographs on this site are generated.</p>
</div>
</footer>
);
}
/* ---------------------------------------------------------- hinweis und top */
/**
* Kleiner Hinweis, einmal je Besuch, sobald der Abschnitt ueber die Person
* erreicht ist. Nicht beim Laden: dort ist er eine Tuer, die zugeht, bevor
* jemand den Raum gesehen hat.
*/
function Hinweis() {
const [offen, setOffen] = useState(false);
const gesehen = useRef(false);
useEffect(() => {
try {
if (window.sessionStorage.getItem("ashworth-hinweis")) return;
} catch {
// Gesperrter Speicher: der Hinweis kommt dann eben wieder.
}
const ziel = document.querySelector("#about");
if (!ziel) return;
const beobachter = new IntersectionObserver(
(eintraege) => {
if (!eintraege.some((e) => e.isIntersecting) || gesehen.current) return;
gesehen.current = true;
beobachter.disconnect();
window.setTimeout(() => setOffen(true), 600);
},
{ rootMargin: "-25% 0px -25% 0px" },
);
beobachter.observe(ziel);
return () => beobachter.disconnect();
}, []);
const schliessen = () => {
setOffen(false);
try {
window.sessionStorage.setItem("ashworth-hinweis", "1");
} catch {
// Ignorieren.
}
};
return (
<AnimatePresence>
{offen && (
<motion.div
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.45, ease: EASE }}
onClick={schliessen}
style={{ background: "rgba(31,36,28,0.18)", backdropFilter: "blur(3px)" }}
>
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby="hinweis-titel"
className="relative w-[min(23rem,calc(100vw-2rem))] px-7 pt-7 pb-6"
style={{ borderRadius: 16, background: PAPER }}
initial={{ opacity: 0, y: 12, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.97 }}
transition={{ duration: 0.56, ease: EASE }}
onClick={(ev) => ev.stopPropagation()}
>
<button
type="button"
onClick={schliessen}
aria-label="Close"
className="absolute top-3 right-3 inline-flex h-9 w-9 items-center justify-center transition-colors"
style={{ borderRadius: 6, color: QUIET }}
>
<X size={18} strokeWidth={1.7} aria-hidden />
</button>
<Marke icon={Timer}>New slots</Marke>
<h2
id="hinweis-titel"
className="mt-4 pr-8 text-[1.4rem] leading-[1.15] text-balance"
style={{ ...display, color: INK }}
>
Evening appointments from October
</h2>
<p
className="mt-3 text-[15px] leading-relaxed"
style={{ fontFamily: SANS, color: QUIET }}
>
Two evenings a week are opening up after the summer, for people who cannot get away
during the working day.
</p>
<div className="mt-5">
<Knopf art="leise" onClick={schliessen}>
Good to know
</Knopf>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
function NachOben() {
const [da, setDa] = useState(false);
const { scrollY } = useScroll();
useMotionValueEvent(scrollY, "change", (y) => setDa(y > window.innerHeight * 1.5));
return (
<button
type="button"
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
aria-label="Back to top"
tabIndex={da ? 0 : -1}
className="fixed right-5 bottom-5 z-40 inline-flex h-11 w-11 items-center justify-center lg:right-8 lg:bottom-8"
style={{
borderRadius: 999,
background: "rgba(243,244,241,0.92)",
backdropFilter: "blur(10px)",
color: INK,
opacity: da ? 1 : 0,
translate: da ? "0 0" : "0 0.75rem",
pointerEvents: da ? "auto" : "none",
transition:
"opacity 420ms cubic-bezier(0.33,1,0.68,1), translate 420ms cubic-bezier(0.33,1,0.68,1)",
}}
>
<ArrowUp size={20} strokeWidth={1.7} aria-hidden />
</button>
);
}
/* ------------------------------------------------------------- tintbuehne */
/**
* Der Block hinter den ersten Abschnitten wechselt beim Scrollen die Flaeche.
*
* Er faengt in hellem Grau an, wird weiss, solange in der Mitte gelesen wird,
* und geht vor seinem Ende wieder ins Graue. Damit hat die Seite einen
* Uebergang, der nicht an einer Kante haengt: es gibt keine Stelle, an der
* eine Farbe aufhoert und die naechste anfaengt, sondern nur eine Strecke, auf
* der sich etwas aendert.
*
* Das Korn liegt nur auf dem grauen Teil und blendet mit weg. Auf Weiss ist es
* schmutzig, auf Grau ist es Papier.
*/
function Tintbuehne({ children }: { children: ReactNode }) {
const reduce = useReducedMotion();
const feld = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target: feld, offset: ["start end", "end start"] });
const grund = useTransform(scrollYProgress, [0, 0.22, 0.6, 0.88], [MIST, PAPER, PAPER, MIST]);
const korn = useTransform(scrollYProgress, [0, 0.22, 0.6, 0.88], [0.5, 0, 0, 0.5]);
return (
<motion.div
ref={feld}
className="relative isolate"
style={{ background: reduce ? PAPER : grund }}
>
<motion.span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 mix-blend-multiply"
style={{
backgroundImage: GRAIN,
backgroundSize: "160px 160px",
opacity: reduce ? 0 : korn,
}}
/>
{children}
</motion.div>
);
}
/* ------------------------------------------------------------------ seite */
export function AshworthCareerPage() {
return (
/* Der weisse Rand unter der Fusszeile steht hier als Innenabstand und
nicht als Aussenabstand der Fusszeile selbst: ein Aussenabstand faellt
durch diesen Knoten hindurch und liegt danach ausserhalb der Vorlage,
wo ihn der naechste Hintergrund verschluckt. */
<div className="pb-4 lg:pb-6" style={{ background: PAPER, color: INK, fontFamily: SANS }}>
<Kopf />
<main>
<Hero />
{/* Der Block ueber dem Hero: er schiebt sich mit eigener Flaeche und
abgerundeter Oberkante darueber, deshalb bleibt vom Hero eine
Kante stehen statt eines harten Schnitts. */}
<div
className="relative z-[1]"
style={{ background: PAPER, borderRadius: "20px 20px 0 0" }}
>
<Tintbuehne>
<Saetze />
<Methode />
<UeberMich />
<AndereFrage />
<Themen />
</Tintbuehne>
<Zitatband />
<Wendung />
<Stimmen />
<Kontakt />
</div>
</main>
<Fusszeile />
<Hinweis />
<NachOben />
</div>
);
}
export default AshworthCareerPage;
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