Templates/Krypto-Plattform
Ein ruhiger Gegenentwurf zur üblichen Krypto-Seite. Ein Blau, echte Fotografie, und ein Scrollverlauf, der erzählt statt zu performen: Der Hero übergibt an eine Kurstabelle, eine festgehaltene Szene führt durch Einzahlen, Kaufen und Auszahlen, und der Gebührenteil zeigt, was eine Order über 1.000 € wirklich kostet. Alles ist echtes Markup, Zahlen und Oberfläche bleiben also änderbar. Ein Abschnitt braucht GSAP: das Release-Regal, dessen Karten endlos auf einem unsichtbaren Bogen laufen. Alles andere kommt ohne aus.
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 ReactNode } from "react";
import gsap from "gsap";
import { MotionPathPlugin } from "gsap/MotionPathPlugin";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import {
AnimatePresence,
motion,
useMotionValueEvent,
useReducedMotion,
useScroll,
useSpring,
useTransform,
} from "framer-motion";
import {
ArrowDownToLine,
ArrowLeftRight,
ArrowRight,
ArrowUpRight,
BadgeCheck,
Bitcoin,
Check,
CreditCard,
Euro,
FileSearch,
Gem,
Landmark,
Menu,
Minus,
Plus,
Receipt,
ShieldCheck,
Smartphone,
Wallet,
X,
Zap,
type LucideIcon,
} from "lucide-react";
// Needs GSAP: npm i gsap
//
// Only one section uses it: the release shelf, where the cards run along an
// invisible arc. Everything else on this page runs on framer-motion and CSS.
//
// A neutral, invented brand: "Arvo", a European crypto platform. No real
// companies, people, licence numbers or prices. The page lives off a single
// colour, real photography and three calm moments of movement: the image change
// in the hero, a pinned product scene and a fee calculation that builds itself
// as it appears. Everything else stands still. Fonts, colour tokens and reveals
// sit portably in the scoped style block.
const HERO_IMG = "/heros/a-silver-crypto-coin-floating-above-dark-rocks.webp";
const DEPTH_IMG = "/heros/a-crypto-coin-sealed-in-a-glass-block.webp";
const AUDIT_IMG = "/heros/a-close-up-shot-of-hands.webp";
const ease = [0.22, 1, 0.36, 1] as const;
const scopedStyles = `
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=Manrope:wght@400;500;600&display=swap');
.arvo {
--ink: #0a0f1f;
--paper: #ffffff;
--soft: #f2f4f9;
--line: #e2e6ef;
--muted: #59617a;
/* Freundliches Blau statt des dunklen Indigo. Der helle Markenton (#4285f4)
traegt weisse Schrift nicht sicher genug: auf dem Knopf stuende Weiss auf
Blau bei 3:1. Deshalb der eine Stufe tiefere Ton, der bei 4,5:1 liegt und
genauso freundlich wirkt. */
--accent: #1a73e8;
--accent-deep: #174ea6;
--font-display: "Sora", ui-sans-serif, system-ui, sans-serif;
--font-body: "Manrope", ui-sans-serif, system-ui, sans-serif;
font-family: var(--font-body);
color: var(--ink);
background: var(--paper);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.arvo h1, .arvo h2, .arvo h3, .arvo .display {
font-family: var(--font-display);
letter-spacing: -0.03em;
line-height: 1.02;
}
.arvo ::selection { background: var(--accent); color: #fff; }
.arvo a, .arvo button { outline-color: var(--accent); }
/* Ein einziger Reveal fuer alle ruhigen Abschnitte: erst unsichtbar und ein
paar Pixel tiefer, dann an Ort und Stelle. Kein Bouncen, kein Zoom. */
.arvo [data-reveal] {
opacity: 0;
transform: translateY(14px);
transition: opacity 700ms cubic-bezier(0.22,1,0.36,1), transform 700ms cubic-bezier(0.22,1,0.36,1);
}
.arvo [data-reveal].is-in { opacity: 1; transform: none; }
.arvo [data-reveal][data-delay="1"] { transition-delay: 90ms; }
.arvo [data-reveal][data-delay="2"] { transition-delay: 180ms; }
.arvo [data-reveal][data-delay="3"] { transition-delay: 270ms; }
@media (prefers-reduced-motion: reduce) {
.arvo [data-reveal] { opacity: 1; transform: none; transition: none; }
}
`;
/** One IntersectionObserver for the whole page; the reveals stay CSS. */
function useReveals(rootRef: React.RefObject<HTMLDivElement | null>) {
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const items = Array.from(root.querySelectorAll<HTMLElement>("[data-reveal]"));
if (!("IntersectionObserver" in window)) {
items.forEach((el) => el.classList.add("is-in"));
return;
}
const obs = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add("is-in");
obs.unobserve(e.target);
}
}
},
{ rootMargin: "0px 0px -12% 0px", threshold: 0.15 },
);
items.forEach((el) => obs.observe(el));
return () => obs.disconnect();
}, [rootRef]);
}
// ------------------------------------------------------ Prices and digit roll
type Asset = {
code: string;
name: string;
base: number;
decimals: number;
/** How far the price may swing per update. */
drift: number;
change: number;
series: number[];
Icon: LucideIcon;
};
const ASSETS: Asset[] = [
{
code: "BTC",
name: "Bitcoin",
base: 58420.1,
decimals: 2,
drift: 14,
change: 1.8,
series: [32, 36, 33, 41, 44, 40, 47, 52, 49, 56],
Icon: Bitcoin,
},
{
code: "ETH",
name: "Ethereum",
base: 3082.64,
decimals: 2,
drift: 1.6,
change: 0.7,
series: [40, 38, 42, 41, 45, 43, 46, 44, 48, 50],
Icon: Gem,
},
{
code: "SOL",
name: "Solana",
base: 142.05,
decimals: 2,
drift: 0.12,
change: -2.4,
series: [52, 55, 51, 48, 50, 45, 43, 46, 41, 38],
Icon: Zap,
},
{
code: "EURA",
name: "Euro Anchor",
base: 1,
decimals: 4,
drift: 0.0002,
change: 0.0,
// An anchor to the euro must hardly move: a gentle drift instead of a jump
// between two values, otherwise the curve spikes.
series: [44, 44.1, 44.3, 44.2, 44.4, 44.5, 44.4, 44.5, 44.7, 44.6],
Icon: Euro,
},
];
const TICK_MS = 2600;
type Rate = { value: number; dir: -1 | 0 | 1 };
function fmt(value: number, decimals: number): string {
return value.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
}
/**
* Prices that move every few seconds. The footnote under the table promises
* exactly that, so the page should show it as well. The starting value is
* fixed: during hydration the browser must show the same thing as the HTML from
* the server. With reduced motion everything stands still.
*/
function useLiveRates(reduce: boolean | null): Record<string, Rate> {
const [rates, setRates] = useState<Record<string, Rate>>(() =>
Object.fromEntries(ASSETS.map((a) => [a.code, { value: a.base, dir: 0 as const }])),
);
useEffect(() => {
if (reduce) return;
const id = window.setInterval(() => {
setRates((prev) => {
const next: Record<string, Rate> = {};
for (const a of ASSETS) {
const cur = prev[a.code]?.value ?? a.base;
// A random step plus a restoring force: the price swings around its starting
// value instead of running away over the minutes.
const value = Math.max(
0,
cur + (Math.random() - 0.5) * 2 * a.drift + (a.base - cur) * 0.1,
);
const rounded = Number(value.toFixed(a.decimals));
next[a.code] = {
value: rounded,
dir: rounded > cur ? 1 : rounded < cur ? -1 : 0,
};
}
return next;
});
}, TICK_MS);
return () => window.clearInterval(id);
}, [reduce]);
return rates;
}
/**
* Digit roll: every place sits in a mask of its own, and on a change the old
* digit drives up and out while the new one follows from below. Only the places
* that really changed move. Separators stand still, so the spacing of the
* number stays right.
*
* The clipping is done with clip-path, not with overflow: hidden. An
* inline-block with overflow: hidden puts its baseline on the bottom edge, and
* the digits would then sit higher than the comma next to them. clip-path
* changes nothing about the layout, so the digit stays in the line where it
* belongs.
*/
function RollingValue({ text }: { text: string }) {
return (
<span className="tabular-nums">
{text.split("").map((ch, i) =>
/\d/.test(ch) ? (
<span
key={i}
className="relative inline-block text-center"
// lineHeight 1: the window is then exactly one line tall. In a taller line the
// travelling digit would stay visible and float next to the number.
style={{ width: "0.6em", lineHeight: 1, clipPath: "inset(0 0 0 0)" }}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={ch}
initial={{ y: "100%" }}
animate={{ y: "0%" }}
exit={{ y: "-100%" }}
transition={{ duration: 0.42, ease }}
className="inline-block"
style={{ lineHeight: 1 }}
>
{ch}
</motion.span>
</AnimatePresence>
</span>
) : (
<span key={i}>{ch}</span>
),
)}
</span>
);
}
// ---------------------------------------------------------------- Navigation
const NAV = [
{ label: "Assets", href: "#markets" },
{ label: "How it works", href: "#how" },
{ label: "Custody", href: "#custody" },
{ label: "Fees", href: "#fees" },
];
function Nav() {
const reduce = useReducedMotion();
const [open, setOpen] = useState(false);
// The bar floats over the content. While reading on it therefore drives out of
// view and comes straight back on scrolling up: otherwise it sits on headings
// permanently.
const [hidden, setHidden] = useState(false);
useEffect(() => {
let last = window.scrollY;
const onScroll = () => {
const y = window.scrollY;
if (y < 120) setHidden(false);
else if (y > last + 6) setHidden(true);
else if (y < last - 6) setHidden(false);
last = y;
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
if (!open) return;
setHidden(false);
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
return (
<>
{/* A compact bar floating on the image. White and opaque instead of milky: it
should be readable, not decorative. */}
<header
className="pointer-events-none fixed inset-x-0 top-4 z-50 flex justify-center px-4 transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] md:top-6"
style={{ transform: hidden && !open ? "translateY(-160%)" : "none" }}
>
<nav
aria-label="Main"
className="pointer-events-auto flex w-full max-w-3xl items-center gap-2 rounded-full bg-white px-3 py-2 md:gap-6 md:px-5"
>
<a
href="#top"
className="display shrink-0 px-1 text-[17px] font-semibold tracking-[-0.04em]"
style={{ color: "var(--ink)" }}
>
Arvo
</a>
{/* The links roll up one line on hover and the dark copy follows from below.
The second line comes from a text-shadow, so the markup still contains
exactly one word. */}
<ul className="ml-2 hidden flex-1 items-center gap-6 md:flex">
{NAV.map((n) => (
<li key={n.href}>
<motion.a
href={n.href}
initial="rest"
animate="rest"
whileHover="open"
whileFocus="open"
className="block overflow-hidden text-[13.5px] font-medium"
style={{ height: "1.5em" }}
>
<motion.span
className="block"
style={{
color: "var(--muted)",
lineHeight: "1.5em",
textShadow: "0 1.5em 0 var(--ink)",
}}
variants={{ rest: { y: 0 }, open: { y: reduce ? 0 : "-1.5em" } }}
transition={{ duration: 0.32, ease }}
>
{n.label}
</motion.span>
</motion.a>
</li>
))}
</ul>
<a
href="#open"
className="ml-auto hidden rounded-[8px] px-4 py-2 text-[13px] font-semibold text-white transition-colors duration-300 md:inline-flex"
style={{ background: "var(--accent)" }}
>
Open an account
</a>
<button
type="button"
aria-label={open ? "Close menu" : "Open menu"}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="ml-auto grid h-9 w-9 place-items-center rounded-full md:hidden"
style={{ background: "var(--soft)" }}
>
{open ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
</button>
</nav>
</header>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
className="fixed inset-0 z-40 flex flex-col justify-end px-4 pb-6 pt-24 md:hidden"
style={{ background: "var(--paper)" }}
>
<ul className="flex flex-col gap-1">
{NAV.map((n, i) => (
<motion.li
key={n.href}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.05 + i * 0.05, ease }}
>
<a
href={n.href}
onClick={() => setOpen(false)}
className="display block py-3 text-[30px] font-semibold"
>
{n.label}
</a>
</motion.li>
))}
</ul>
<a
href="#open"
onClick={() => setOpen(false)}
className="mt-8 inline-flex items-center justify-center rounded-[8px] px-5 py-3.5 text-[15px] font-semibold text-white"
style={{ background: "var(--accent)" }}
>
Open an account
</a>
</motion.div>
)}
</AnimatePresence>
</>
);
}
// ---------------------------------------------------------------------- Hero
/**
* Primary button with a text roll: on hover the text moves up one line and the
* copy follows from below. The second line is not a second element but a
* text-shadow exactly one line height lower. The button itself never gets
* bigger.
*/
function RollButton({
href,
children,
tone = "accent",
className = "",
}: {
href: string;
children: string;
tone?: "accent" | "paper";
className?: string;
}) {
const reduce = useReducedMotion();
const paper = tone === "paper";
const text = paper ? "var(--accent-deep)" : "#ffffff";
return (
<motion.a
href={href}
initial="rest"
animate="rest"
whileHover="open"
whileFocus="open"
whileTap="open"
className={`inline-flex items-center gap-2 rounded-[8px] px-6 py-3.5 text-[14.5px] font-semibold ${className}`}
style={{ background: paper ? "#ffffff" : "var(--accent)", color: text }}
>
<span className="block overflow-hidden" style={{ height: "1.4em" }}>
<motion.span
className="block"
style={{ lineHeight: "1.4em", textShadow: `0 1.4em 0 ${text}` }}
variants={{ rest: { y: 0 }, open: { y: reduce ? 0 : "-1.4em" } }}
transition={{ duration: 0.34, ease }}
>
{children}
</motion.span>
</span>
<span className="relative block h-4 w-4 overflow-hidden">
<motion.span
aria-hidden
className="absolute inset-0"
variants={{
rest: { x: 0, opacity: 1 },
open: reduce ? { x: 0, opacity: 1 } : { x: 16, opacity: 0 },
}}
transition={{ duration: 0.34, ease }}
>
<ArrowRight className="h-4 w-4" strokeWidth={1.6} />
</motion.span>
<motion.span
aria-hidden
className="absolute inset-0"
variants={{
rest: reduce ? { x: 0, opacity: 0 } : { x: -16, opacity: 0 },
open: { x: 0, opacity: 1 },
}}
transition={{ duration: 0.34, ease }}
>
<ArrowRight className="h-4 w-4" strokeWidth={1.6} />
</motion.span>
</span>
</motion.a>
);
}
function Hero({ reduce, rates }: { reduce: boolean | null; rates: Record<string, Rate> }) {
const ref = useRef<HTMLElement>(null);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end start"] });
// Only a slight travel: the image stays sharp and the crop stays
// recognisable. The transition to the next section should feel like a camera
// pan, not like an effect.
const scale = useTransform(scrollYProgress, [0, 1], [1, 1.08]);
const shift = useTransform(scrollYProgress, [0, 1], ["0%", "8%"]);
return (
<section
id="top"
ref={ref}
className="relative flex min-h-[92vh] w-full flex-col justify-end overflow-hidden"
style={{ background: "#070b16" }}
>
<motion.div
aria-hidden
style={reduce ? undefined : { scale, y: shift }}
className="absolute inset-0"
>
<img
src={HERO_IMG}
alt="A silver coin floating above a dark rocky plain under a deep blue sky"
// On the phone the crop is tall and narrow and the image is fitted by height.
// The coin would then sit exactly on the header, so the image is calculated a
// little larger there and pushed upwards: the coin stands above the type, the
// rock fills the lower half.
className="h-[124%] w-full -translate-y-[20%] object-cover object-[50%_center] md:h-full md:translate-y-0 md:object-[62%_center]"
/>
</motion.div>
{/* The subject is dark by itself, so the veils stay restrained: just enough for
the type on the left to sit safely, without washing the image grey. On the
phone the type runs across the full width, and there the gradient runs from
the bottom upwards. */}
<div
aria-hidden
className="absolute inset-0 hidden md:block"
style={{
background:
"linear-gradient(94deg, rgba(5,9,20,0.72) 0%, rgba(5,9,20,0.4) 38%, rgba(5,9,20,0.06) 66%, rgba(5,9,20,0) 82%)",
}}
/>
{/* Plus a flat gradient from below: the button and the prices stand there, and
the rock is restless in that spot. */}
<div
aria-hidden
className="absolute inset-0 hidden md:block"
style={{
background:
"linear-gradient(0deg, rgba(5,9,20,0.62) 0%, rgba(5,9,20,0.18) 28%, rgba(5,9,20,0) 48%)",
}}
/>
<div
aria-hidden
className="absolute inset-0 md:hidden"
style={{
background:
"linear-gradient(180deg, rgba(5,9,20,0.45) 0%, rgba(5,9,20,0.1) 28%, rgba(5,9,20,0.55) 60%, rgba(5,9,20,0.82) 100%)",
}}
/>
<div className="relative z-10 mx-auto flex w-full max-w-6xl flex-col gap-12 px-5 pb-14 pt-40 md:flex-row md:items-end md:justify-between md:gap-16 md:px-8 md:pb-20">
<div className="md:max-w-[62%]">
<motion.span
initial={reduce ? undefined : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.15, ease }}
className="flex items-center gap-2.5 text-[11px] font-semibold uppercase tracking-[0.22em] text-white/70"
>
<ShieldCheck aria-hidden className="h-4 w-4 text-white/85" strokeWidth={1.5} />
Licensed in the EU · Assets held 1:1
</motion.span>
<motion.h1
initial={reduce ? undefined : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, delay: 0.24, ease }}
className="mt-5 max-w-[16ch] text-[42px] font-semibold text-white sm:text-[58px] md:text-[76px]"
>
A quieter way to own crypto.
</motion.h1>
<motion.p
initial={reduce ? undefined : { opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.36, ease }}
className="mt-6 max-w-[38ch] text-[15px] leading-relaxed text-white/80 md:text-[17px]"
>
Buy, hold and move digital assets from a euro account. One price, one fee, and the keys
leave with you whenever you want them.
</motion.p>
<motion.div
initial={reduce ? undefined : { opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.46, ease }}
className="mt-9 flex flex-wrap items-center gap-x-7 gap-y-4"
>
<RollButton href="#open">Open an account</RollButton>
<a
href="#fees"
className="text-[14px] font-medium text-white underline decoration-white/40 decoration-[1.5px] underline-offset-[6px] transition-colors duration-300 hover:decoration-white"
>
See what a trade costs
</a>
</motion.div>
</div>
{/* The proof stands inside the scene, not in a metrics bar of its own below it:
two prices along the right edge, where the image is calm. */}
<motion.dl
initial={reduce ? undefined : { opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.62, ease }}
className="hidden shrink-0 flex-col gap-5 text-right md:flex"
>
{ASSETS.slice(0, 2).map((a) => {
const r = rates[a.code];
return (
<div key={a.code} className="flex flex-col gap-1">
<dt className="flex items-center justify-end gap-1.5 text-[10.5px] font-semibold uppercase tracking-[0.2em] text-white/55">
<a.Icon aria-hidden className="h-3.5 w-3.5" strokeWidth={1.5} />
{a.code} · live
</dt>
<dd className="display flex items-baseline justify-end gap-2 text-[22px] font-semibold text-white">
<span>
<RollingValue text={fmt(r.value, a.decimals)} /> €
</span>
<span className="text-[13px] font-medium text-white/60">
{a.change > 0 ? "+" : ""}
{a.change.toFixed(1)} %
</span>
</dd>
</div>
);
})}
</motion.dl>
</div>
</section>
);
}
// ------------------------------------------------------- Market: a real table
function Sparkline({ values, up, play }: { values: number[]; up: boolean; play: boolean }) {
const w = 132;
const h = 34;
const min = Math.min(...values);
const max = Math.max(...values);
const span = Math.max(1, max - min);
// The curve is not blindly stretched to the full height. Otherwise a value
// that hardly moves looks wilder than bitcoin: with the euro anchor, 1.0000
// against 1.0001 turned into spikes across the whole card. The swing therefore
// follows how much the value really fluctuates in relation to itself.
const relative = span / Math.max(1, max);
const amplitude = Math.min(1, relative / 0.15);
const usable = (h - 4) * amplitude;
const top = 2 + (h - 4 - usable) / 2;
const points = values
.map((v, i) => {
const x = (i / (values.length - 1)) * w;
const y = top + usable - ((v - min) / span) * usable;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
return (
// The width comes from CSS, so enough room is left next to the price on the
// phone. The viewBox scales the curve along.
<svg
width={w}
height={h}
viewBox={`0 0 ${w} ${h}`}
aria-hidden
className="w-[84px] overflow-visible md:w-[132px]"
>
<motion.polyline
points={points}
fill="none"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
stroke={up ? "var(--accent)" : "#98a1b6"}
initial={{ pathLength: 0 }}
animate={play ? { pathLength: 1 } : undefined}
transition={{ duration: 1.1, ease }}
/>
</svg>
);
}
function Markets({ reduce, rates }: { reduce: boolean | null; rates: Record<string, Rate> }) {
const ref = useRef<HTMLDivElement>(null);
const [play, setPlay] = useState(!!reduce);
useEffect(() => {
if (reduce) return;
const el = ref.current;
if (!el || !("IntersectionObserver" in window)) {
setPlay(true);
return;
}
const obs = new IntersectionObserver(
([e]) => {
if (e.isIntersecting) {
setPlay(true);
obs.disconnect();
}
},
{ threshold: 0.3 },
);
obs.observe(el);
return () => obs.disconnect();
}, [reduce]);
return (
<section id="markets" className="w-full px-5 py-24 md:px-8 md:py-28">
<div className="mx-auto max-w-5xl">
<div className="grid gap-8 md:grid-cols-[1fr_auto] md:items-end md:gap-16">
<h2 data-reveal className="max-w-[17ch] text-[34px] font-semibold md:text-[46px]">
Most of this market is noise. Four assets are not.
</h2>
<p
data-reveal
data-delay="1"
className="max-w-[32ch] pb-1 text-[14.5px] leading-relaxed"
style={{ color: "var(--muted)" }}
>
We list what we can hold properly and explain. Nothing gets added because it trended
last week.
</p>
</div>
<div ref={ref} className="mt-14">
<div
className="grid grid-cols-[1fr_auto] items-center gap-4 pb-3 text-[10.5px] font-semibold uppercase tracking-[0.16em] md:grid-cols-[1.5fr_1fr_0.7fr_150px]"
style={{ color: "#98a1b6" }}
>
<span>Asset</span>
<span className="hidden md:block">Price (EUR)</span>
<span className="hidden md:block">24 h</span>
<span className="text-right">7 days</span>
</div>
<ul>
{ASSETS.map((a, i) => {
const rate = rates[a.code];
return (
<li key={a.code} data-reveal data-delay={String(Math.min(3, i)) as "1"}>
<a
href="#open"
className="group grid grid-cols-[1fr_auto] items-center gap-4 rounded-xl px-3 py-[18px] transition-colors duration-300 md:grid-cols-[1.5fr_1fr_0.7fr_150px] md:px-4"
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--soft)")}
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
>
<span className="flex min-w-0 items-center gap-3 md:gap-4">
{/* Every asset gets a mark of its own: that gives the row a
start, and you recognise it when skimming by its
shape instead of by the ticker. */}
<span
aria-hidden
className="grid h-9 w-9 shrink-0 place-items-center rounded-full transition-colors duration-300 md:h-11 md:w-11"
style={{ background: "var(--soft)", color: "var(--accent)" }}
>
<a.Icon className="h-4 w-4 md:h-[18px] md:w-[18px]" strokeWidth={1.45} />
</span>
<span className="flex flex-col gap-1.5">
<span className="flex items-baseline gap-3">
<span className="display text-[17px] font-semibold">{a.code}</span>
<span
className="whitespace-nowrap text-[13px]"
style={{ color: "var(--muted)" }}
>
{a.name}
</span>
{/* The arrow only appears on hover: the row stays calm but
reveals that it leads somewhere. */}
<ArrowRight
aria-hidden
className="h-3.5 w-3.5 -translate-x-1 opacity-0 transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
strokeWidth={1.6}
style={{ color: "var(--accent)" }}
/>
</span>
{/* On the phone there are no separate columns for the price
and the daily change; both still belong in the row,
otherwise only a ticker stands there. */}
<span className="flex items-baseline gap-3 md:hidden">
<span className="whitespace-nowrap text-[14px] font-medium">
<RollingValue text={fmt(rate.value, a.decimals)} /> €
</span>
<span
className="text-[12.5px] font-medium tabular-nums"
style={{
color:
a.change > 0
? "var(--accent)"
: a.change < 0
? "#8a90a3"
: "#98a1b6",
}}
>
{a.change > 0 ? "+" : ""}
{a.change.toFixed(1)} %
</span>
</span>
</span>
</span>
<span className="hidden text-[15px] font-medium md:block">
<RollingValue text={fmt(rate.value, a.decimals)} />
</span>
<span
className="hidden text-[14px] font-medium tabular-nums md:block"
style={{
color:
a.change > 0 ? "var(--accent)" : a.change < 0 ? "#8a90a3" : "#98a1b6",
}}
>
{a.change > 0 ? "+" : ""}
{a.change.toFixed(1)} %
</span>
<span className="justify-self-end">
<Sparkline values={a.series} up={a.change >= 0} play={play} />
</span>
</a>
</li>
);
})}
</ul>
<p data-reveal className="mt-8 px-3 text-[12px] md:px-4" style={{ color: "#98a1b6" }}>
Indicative rates, refreshed every few seconds. The price you confirm in the app is the
price you pay.
</p>
</div>
</div>
</section>
);
}
// ------------------------------------------- A pinned product story
const STEPS = [
{
kicker: "01",
title: "Fund in euro",
body: "Send a normal SEPA transfer from your bank. It lands as euro, not as a token you have to swap first.",
},
{
kicker: "02",
title: "Buy at the price you see",
body: "One rate, one fee, written out before you confirm. No spread hidden behind a friendly slider.",
},
{
kicker: "03",
title: "Move it out whenever",
body: "Withdraw to your own wallet at any time. We show the network fee and the arrival estimate first.",
},
];
const STEP_ICONS: LucideIcon[] = [ArrowDownToLine, ArrowLeftRight, Wallet];
/** The product surface as real markup, not as a screenshot. */
function AppScreen({ step }: { step: number }) {
const StepIcon = STEP_ICONS[step] ?? ArrowDownToLine;
return (
<div
className="w-full overflow-hidden rounded-[22px] p-5 md:p-6"
style={{ background: "var(--paper)" }}
>
<div className="flex items-center justify-between">
<span
className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.16em]"
style={{ color: "#98a1b6" }}
>
{/* Every view gets its own icon: incoming, swapped, sent out. That reads faster
in the head than the word. */}
<StepIcon aria-hidden className="h-4 w-4" strokeWidth={1.5} />
{step === 0 ? "Deposit" : step === 1 ? "Order" : "Withdrawal"}
</span>
<span className="text-[11px] tabular-nums" style={{ color: "#98a1b6" }}>
09:41
</span>
</div>
<AnimatePresence mode="wait">
<motion.div
key={step}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.35, ease }}
className="mt-5"
>
{step === 0 && (
<>
<span className="block text-[13px]" style={{ color: "var(--muted)" }}>
Incoming transfer
</span>
<span className="display mt-1 block text-[38px] font-semibold tabular-nums">
1,000.00 €
</span>
<div className="mt-5 flex flex-col gap-2.5">
{[
["From", "Your bank · SEPA"],
["Reference", "ARVO-4471"],
["Available", "Same day"],
].map(([k, v]) => (
<span key={k} className="flex items-baseline justify-between text-[13px]">
<span style={{ color: "var(--muted)" }}>{k}</span>
<span className="font-medium">{v}</span>
</span>
))}
</div>
<span
className="mt-6 flex items-center gap-2 rounded-[10px] px-3.5 py-3 text-[13px] font-medium"
style={{ background: "var(--soft)" }}
>
<Check className="h-4 w-4" strokeWidth={2.4} style={{ color: "var(--accent)" }} />
Euro balance ready
</span>
</>
)}
{step === 1 && (
<>
<span className="block text-[13px]" style={{ color: "var(--muted)" }}>
Buy Bitcoin
</span>
<span className="display mt-1 block text-[38px] font-semibold tabular-nums">
0.01712 <span className="text-[20px] font-medium">BTC</span>
</span>
<div className="mt-5 flex flex-col gap-2.5">
{[
["Rate", "58,420.10 €"],
["Amount", "1,000.00 €"],
["Fee (0.35 %)", "3.50 €"],
].map(([k, v]) => (
<span key={k} className="flex items-baseline justify-between text-[13px]">
<span style={{ color: "var(--muted)" }}>{k}</span>
<span className="font-medium tabular-nums">{v}</span>
</span>
))}
</div>
<span
className="mt-6 flex items-center justify-between rounded-[10px] px-3.5 py-3 text-[13px] font-semibold text-white"
style={{ background: "var(--accent)" }}
>
Confirm buy
<ArrowRight className="h-4 w-4" strokeWidth={2.2} />
</span>
</>
)}
{step === 2 && (
<>
<span className="block text-[13px]" style={{ color: "var(--muted)" }}>
Send to your wallet
</span>
<span className="display mt-1 block text-[38px] font-semibold tabular-nums">
0.01712 <span className="text-[20px] font-medium">BTC</span>
</span>
<div className="mt-5 flex flex-col gap-2.5">
{[
["To", "bc1q…7f4a"],
["Network fee", "0.00004 BTC"],
["Arrives in", "~ 20 min"],
].map(([k, v]) => (
<span key={k} className="flex items-baseline justify-between text-[13px]">
<span style={{ color: "var(--muted)" }}>{k}</span>
<span className="font-medium tabular-nums">{v}</span>
</span>
))}
</div>
<span
className="mt-6 flex items-center gap-2 rounded-[10px] px-3.5 py-3 text-[13px] font-medium"
style={{ background: "var(--soft)" }}
>
<Check className="h-4 w-4" strokeWidth={2.4} style={{ color: "var(--accent)" }} />
Keys stay with you
</span>
</>
)}
</motion.div>
</AnimatePresence>
</div>
);
}
function HowItWorks({ reduce }: { reduce: boolean | null }) {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] });
const smooth = useSpring(scrollYProgress, { stiffness: 120, damping: 26, mass: 0.4 });
const [step, setStep] = useState(0);
useMotionValueEvent(smooth, "change", (v) => {
const next = v < 0.36 ? 0 : v < 0.7 ? 1 : 2;
setStep((s) => (s === next ? s : next));
});
return (
<section id="how" className="w-full" style={{ background: "var(--soft)" }}>
{/* Desktop: a scene is pinned while the text runs on. On the phone a pin across
three screen heights would be a slog, so the steps simply stand below each
other there. */}
<div className="mx-auto hidden max-w-6xl px-8 md:block">
<div ref={ref} className="relative" style={{ height: reduce ? "auto" : "240vh" }}>
<div
className={
reduce
? "grid grid-cols-[1fr_0.85fr] items-center gap-16 py-28"
: "sticky top-0 grid h-screen grid-cols-[1fr_0.85fr] items-center gap-16"
}
>
<div>
<span
className="text-[11px] font-semibold uppercase tracking-[0.22em]"
style={{ color: "var(--accent)" }}
>
How it works
</span>
<div className="relative mt-7 min-h-[210px]">
<AnimatePresence mode="wait">
<motion.div
key={step}
initial={reduce ? undefined : { opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -12 }}
transition={{ duration: 0.45, ease }}
>
<h2 className="max-w-[14ch] text-[44px] font-semibold lg:text-[54px]">
{STEPS[step].title}
</h2>
<p
className="mt-5 max-w-[38ch] text-[16px] leading-relaxed"
style={{ color: "var(--muted)" }}
>
{STEPS[step].body}
</p>
</motion.div>
</AnimatePresence>
</div>
{/* Progress as a ruler with numbers: you see where you are, what happened and
what is still to come. Clickable, so you do not have to scroll to see a step
again. */}
<div className="mt-10 flex items-start gap-6">
{STEPS.map((s, i) => (
<button
key={s.kicker}
type="button"
onClick={() => setStep(i)}
className="flex flex-1 flex-col gap-2.5 text-left outline-none"
>
<span className="h-[2px] w-full overflow-hidden rounded-full bg-[#d8dde8]">
<motion.span
className="block h-full rounded-full"
animate={{ scaleX: i <= step ? 1 : 0 }}
style={{ originX: 0, background: "var(--accent)" }}
transition={{ duration: 0.5, ease }}
/>
</span>
<span className="flex items-baseline gap-2">
<span
className="display text-[11px] font-semibold tabular-nums transition-colors duration-300"
style={{ color: i === step ? "var(--accent)" : "#b3b9c8" }}
>
{s.kicker}
</span>
<span
className="text-[12px] font-medium transition-colors duration-300"
style={{ color: i === step ? "var(--ink)" : "#98a1b6" }}
>
{s.title}
</span>
</span>
</button>
))}
</div>
</div>
{/* Two layers instead of one lonely card: the balance sits behind the current
step and changes with it. */}
<div className="ml-auto w-full max-w-[392px]">
<motion.div
animate={{ opacity: 1 }}
className="mb-3 ml-auto flex w-fit items-baseline gap-3 rounded-full py-2 pl-4 pr-5"
style={{ background: "#e4e8f2" }}
>
<span
className="text-[10.5px] font-semibold uppercase tracking-[0.16em]"
style={{ color: "#7c849b" }}
>
Balance
</span>
<AnimatePresence mode="wait">
<motion.span
key={step}
initial={reduce ? undefined : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -6 }}
transition={{ duration: 0.3, ease }}
className="display text-[14px] font-semibold tabular-nums"
>
{step === 0 ? "1,000.00 €" : step === 1 ? "0.01712 BTC" : "0.00000 BTC"}
</motion.span>
</AnimatePresence>
</motion.div>
<AppScreen step={step} />
</div>
</div>
</div>
</div>
{/* Phone: the same story as stacked steps. */}
<div className="mx-auto max-w-lg px-5 py-20 md:hidden">
<span
className="text-[11px] font-semibold uppercase tracking-[0.22em]"
style={{ color: "var(--accent)" }}
>
How it works
</span>
<div className="mt-8 flex flex-col gap-14">
{STEPS.map((s, i) => (
<div key={s.kicker} data-reveal>
<span
className="display block text-[12.5px] font-semibold tabular-nums"
style={{ color: "var(--accent)" }}
>
{s.kicker}
</span>
<h2 className="mt-3 text-[30px] font-semibold">{s.title}</h2>
<p className="mt-3 text-[15px] leading-relaxed" style={{ color: "var(--muted)" }}>
{s.body}
</p>
<div className="mt-6">
<AppScreen step={i} />
</div>
</div>
))}
</div>
</div>
</section>
);
}
// -------------------------------------------------------------- Verwahrung
const CUSTODY_FACTS = [
{
k: "1:1",
t: "Held, not lent",
b: "Every coin on your balance exists on chain. Nothing is lent out to fund a yield programme.",
},
{
k: "94 %",
t: "In cold storage",
b: "The bulk sits offline in segregated wallets. Only working balances stay online for withdrawals.",
},
{
k: "Monthly",
t: "Proof of reserves",
b: "An independent firm checks holdings against customer balances and publishes the result.",
},
];
function Custody({ reduce }: { reduce: boolean | null }) {
const ref = useRef<HTMLElement>(null);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] });
// The water passes slowly through the window while scrolling. The section is
// otherwise completely still, and the depth is the argument here.
const y = useTransform(scrollYProgress, [0, 1], ["-6%", "6%"]);
return (
<section
id="custody"
ref={ref}
className="relative w-full overflow-hidden"
style={{ background: "#04101c" }}
>
<motion.img
src={DEPTH_IMG}
alt="A coin sealed inside a block of clear glass, resting on dark rock"
className="absolute inset-0 h-[112%] w-full object-cover object-[26%_center] opacity-90"
style={reduce ? undefined : { y }}
loading="lazy"
/>
{/* The block of glass stands on the right, the type on the left. A veil sits
between them, so the bright glass edges do not run through the heading. On
the phone the type stands over the whole image, and there the gradient from
top to bottom takes over. */}
<div
aria-hidden
className="absolute inset-0 hidden md:block"
style={{
background:
"linear-gradient(96deg, rgba(4,12,24,0.72) 0%, rgba(4,12,24,0.34) 30%, rgba(4,12,24,0) 56%)",
}}
/>
<div
aria-hidden
className="absolute inset-0"
style={{
background:
"linear-gradient(180deg, rgba(4,16,28,0.45) 0%, rgba(4,16,28,0.2) 42%, rgba(4,16,28,0.78) 100%)",
}}
/>
<div className="relative z-10 mx-auto max-w-6xl px-5 py-28 md:px-8 md:py-40">
<h2
data-reveal
className="max-w-[16ch] text-[34px] font-semibold text-white md:text-[52px]"
>
The boring part is the whole point.
</h2>
<p
data-reveal
data-delay="1"
className="mt-6 max-w-[48ch] text-[15px] leading-relaxed text-white/70 md:text-[17px]"
>
Custody is where a platform is actually judged. Here is how yours is kept, in plain terms.
</p>
<dl className="mt-20 grid gap-12 md:grid-cols-3 md:gap-10">
{CUSTODY_FACTS.map((f, i) => (
<div key={f.k} data-reveal data-delay={String(i + 1) as "1"}>
<dt className="display text-[40px] font-semibold text-white md:text-[46px]">{f.k}</dt>
<dd className="mt-3">
<span className="block text-[15px] font-semibold text-white">{f.t}</span>
<span className="mt-2 block max-w-[34ch] text-[13.5px] leading-relaxed text-white/70">
{f.b}
</span>
</dd>
</div>
))}
</dl>
</div>
</section>
);
}
// ------------------------------------------------------------------ Gebuehren
const FEES = [
{ name: "Arvo", fee: 3.5, note: "0.35 %, shown before you confirm", own: true, Icon: Receipt },
{
name: "Typical broker app",
fee: 14.9,
note: "Spread folded into the rate",
Icon: Smartphone,
},
{ name: "Card purchase", fee: 24.0, note: "Processing plus conversion", Icon: CreditCard },
];
/**
* The amount counts up while the bar grows: both tell the same number, so they
* should move together.
*/
function CountUp({
to,
play,
delay,
reduce,
}: {
to: number;
play: boolean;
delay: number;
reduce: boolean | null;
}) {
const [value, setValue] = useState(0);
useEffect(() => {
if (!play) return;
if (reduce) {
setValue(to);
return;
}
let raf = 0;
let start = 0;
const run = (t: number) => {
if (!start) start = t;
const p = Math.min(1, (t - start - delay * 1000) / 900);
if (p > 0) setValue(to * (1 - Math.pow(1 - p, 3)));
if (p < 1) raf = requestAnimationFrame(run);
};
raf = requestAnimationFrame(run);
return () => cancelAnimationFrame(raf);
}, [play, to, delay, reduce]);
return <>{value.toFixed(2)}</>;
}
function Fees({ reduce }: { reduce: boolean | null }) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInViewOnce(ref);
const max = Math.max(...FEES.map((f) => f.fee));
return (
<section id="fees" className="w-full px-5 py-24 md:px-8 md:py-32">
<div className="mx-auto max-w-5xl">
<h2 data-reveal className="max-w-[18ch] text-[34px] font-semibold md:text-[48px]">
What a 1,000{" "}€ buy actually costs.
</h2>
<p
data-reveal
data-delay="1"
className="mt-6 max-w-[44ch] text-[15px] leading-relaxed"
style={{ color: "var(--muted)" }}
>
Same order, three routes. The number below is everything you pay, spread included.
</p>
{/* Three rows, three columns: who, how much as a length, how much as a number.
The note sits under the name and not under the bar, otherwise the row falls
apart into two lines without a shared edge. */}
<div ref={ref} className="mt-16 flex flex-col gap-10">
{FEES.map((f, i) => (
<div
key={f.name}
className="grid grid-cols-1 gap-4 md:grid-cols-[250px_1fr_120px] md:items-center md:gap-8"
>
<span className="flex items-center gap-3.5">
{/* Every route gets its own icon: receipt, app, card. That separates the three
rows faster than the name alone. */}
<span
aria-hidden
className="grid h-10 w-10 shrink-0 place-items-center rounded-full"
style={{
background: f.own ? "var(--accent)" : "var(--soft)",
color: f.own ? "#ffffff" : "#7c849b",
}}
>
<f.Icon className="h-[17px] w-[17px]" strokeWidth={1.45} />
</span>
<span className="flex flex-col gap-1">
<span className="text-[15.5px] font-semibold">{f.name}</span>
<span className="text-[12.5px] leading-snug" style={{ color: "var(--muted)" }}>
{f.note}
</span>
</span>
</span>
<span
className="h-[10px] w-full overflow-hidden rounded-full"
style={{ background: "var(--soft)" }}
>
<motion.span
className="block h-full rounded-full"
initial={{ scaleX: 0 }}
animate={inView ? { scaleX: f.fee / max } : undefined}
transition={{ duration: 0.9, delay: 0.1 + i * 0.12, ease }}
style={{ originX: 0, background: f.own ? "var(--accent)" : "#c6ccdb" }}
/>
</span>
<span
className="display text-[24px] font-semibold tabular-nums md:text-right"
style={{ color: f.own ? "var(--accent)" : "var(--ink)" }}
>
<CountUp to={f.fee} play={inView} delay={0.1 + i * 0.12} reduce={reduce} /> €
</span>
</div>
))}
</div>
<p data-reveal className="mt-12 max-w-[52ch] text-[12px]" style={{ color: "#98a1b6" }}>
One purchase of 1,000 € in Bitcoin, October 2026. Broker and card figures are the market
range we measured, not a specific provider.
</p>
</div>
</section>
);
}
function useInViewOnce(ref: React.RefObject<HTMLElement | null>) {
const [seen, setSeen] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || !("IntersectionObserver" in window)) {
setSeen(true);
return;
}
const obs = new IntersectionObserver(
([e]) => {
if (e.isIntersecting) {
setSeen(true);
obs.disconnect();
}
},
{ threshold: 0.25 },
);
obs.observe(el);
return () => obs.disconnect();
}, [ref]);
return seen;
}
// ------------------------------------------------------------- Was kam dazu
type Release = { bild: string; alt: string; titel: string; monat: string };
const RELEASES: Release[] = [
{
bild: "/heros/european-market-potential-refined-abstract.webp",
alt: "City lights, long exposure",
titel: "Instant SEPA",
monat: "February",
},
{
bild: "/heros/light-blue-background-white-lines-in-the.webp",
alt: "A pale blue surface in low light",
titel: "Recurring buys",
monat: "April",
},
{
bild: "/heros/dark-light-ray-grain.webp",
alt: "A shaft of light in fine grain",
titel: "Reserve report",
monat: "May",
},
{
bild: "/heros/minimalist-glass-hexagon-grid-floating-over.webp",
alt: "A glass grid floating over moss",
titel: "Second signing key",
monat: "July",
},
{
bild: "/heros/phone-dark.webp",
alt: "A phone held in low blue light",
titel: "Price alerts",
monat: "September",
},
{
bild: "/heros/silver-coin-lunar.webp",
alt: "A silver coin above a dark ridge",
titel: "Euro Anchor",
monat: "November",
},
];
/**
* The release shelf. The cards do not sit in a row, they run along an arc and
* keep running: whoever leaves on the right comes back in on the left. Both
* ends of the arc lie outside the frame, so the wrap is never visible.
*
* This is the one place on the page that needs GSAP. MotionPathPlugin measures
* the path once and then hands back a point for any position along it; doing
* that by hand would mean recomputing the curve in x and y for every window
* width. The ticker adds a slow drift so the shelf keeps moving while the page
* stands still.
*/
function Releases({ reduce }: { reduce: boolean | null }) {
const wurzel = useRef<HTMLElement>(null);
const buehne = useRef<HTMLDivElement>(null);
const bogen = useRef<SVGPathElement>(null);
useEffect(() => {
const el = wurzel.current;
const buehneEl = buehne.current;
const bogenEl = bogen.current;
if (!el || !buehneEl || !bogenEl) return;
gsap.registerPlugin(MotionPathPlugin, ScrollTrigger);
const ctx = gsap.context(() => {
const karten = gsap.utils.toArray<HTMLElement>("[data-release]");
if (!karten.length) return;
const roh = MotionPathPlugin.getRawPath(bogenEl);
MotionPathPlugin.cacheRawPathMeasurements(roh);
// The path is drawn in viewBox units, the stage has pixels, and
// preserveAspectRatio="none" stretches the two independently. So each
// axis is converted on its own.
const VB_BREITE = 1000;
const VB_HOEHE = 400;
const umlauf = gsap.utils.wrap(0, 1);
const RUNDEN = 0.8;
const DRIFT = 1 / 90;
const zustand = { scroll: 0, zeit: 0 };
const setzen = () => {
const breite = buehneEl.clientWidth || 1;
const hoehe = buehneEl.clientHeight || 1;
karten.forEach((k, i) => {
const anteil = umlauf(i / karten.length + zustand.scroll * RUNDEN + zustand.zeit * DRIFT);
const punkt = MotionPathPlugin.getPositionOnPath(roh, anteil, false);
const x = (punkt.x / VB_BREITE) * breite;
const y = (punkt.y / VB_HOEHE) * hoehe;
const skala = gsap.utils.clamp(0.72, 1.04, 1.04 - (y / hoehe) * 0.4);
// Fade out exactly as far as the card sticks out of the stage,
// otherwise the edge cuts it in half and the wrap looks like a bug.
const halb = (k.offsetWidth * skala) / 2;
const raus = Math.max(0, halb - x, x + halb - breite);
const deckung = gsap.utils.clamp(0, 1, 1 - raus / (k.offsetWidth * 0.85));
gsap.set(k, {
x: x - k.offsetWidth / 2,
y: y - k.offsetHeight / 2,
scale: skala,
opacity: deckung,
});
});
};
ScrollTrigger.create({
trigger: el,
start: "top bottom",
end: "bottom top",
onUpdate: (self) => {
zustand.scroll = self.progress;
setzen();
},
});
if (!reduce) {
const takt = (_zeit: number, delta: number) => {
zustand.zeit += delta / 1000;
setzen();
};
gsap.ticker.add(takt);
// The ticker does not belong to the context, so it is removed by hand.
return () => gsap.ticker.remove(takt);
}
setzen();
}, el);
const ro = new ResizeObserver(() => ScrollTrigger.refresh());
ro.observe(buehneEl);
return () => {
ro.disconnect();
ctx.revert();
};
}, [reduce]);
return (
<section
ref={wurzel}
className="w-full overflow-hidden py-24 md:py-32"
style={{ background: "var(--soft)" }}
>
<div className="mx-auto max-w-6xl px-5 md:px-8">
<span
data-reveal
className="text-[11px] font-semibold uppercase tracking-[0.22em]"
style={{ color: "var(--accent)" }}
>
Shipped in 2026
</span>
<h2
data-reveal
data-delay="1"
className="mt-4 max-w-[16ch] text-[34px] font-semibold md:text-[46px]"
>
Six things that went live this year.
</h2>
</div>
<div ref={buehne} className="relative mt-10 h-[420px] w-full sm:h-[480px] lg:h-[520px]">
{/* The arc itself. Invisible, but real layout: the cards hang off its
coordinates rather than off numbers computed somewhere else. */}
<svg
className="absolute inset-0 h-full w-full"
viewBox="0 0 1000 400"
preserveAspectRatio="none"
aria-hidden
>
<path ref={bogen} d="M -70 350 C 210 60, 790 60, 1070 350" fill="none" stroke="none" />
</svg>
{RELEASES.map((r, i) => (
<figure
key={r.bild}
data-release
className="absolute left-0 top-0 m-0 w-[126px] sm:w-[150px] lg:w-[174px]"
>
<span
className="block aspect-[3/4] w-full overflow-hidden rounded-[14px]"
style={{ background: "var(--line)" }}
>
<img
src={r.bild}
alt={r.alt}
loading={i < 2 ? "eager" : "lazy"}
decoding="async"
draggable={false}
className="h-full w-full select-none object-cover"
/>
</span>
<figcaption className="mt-3 flex items-baseline justify-between gap-2">
<span className="text-[13.5px] font-semibold">{r.titel}</span>
<span className="text-[12px]" style={{ color: "var(--muted)" }}>
{r.monat}
</span>
</figcaption>
</figure>
))}
</div>
</section>
);
}
// ------------------------------------------------------------------- Aufsicht
// The institutions Arvo works with. Invented names and hand drawn marks: a
// real logo in this place would claim that this company audits the reserves.
//
// So the row reads like a wall of logos and not like six icons in patches of
// colour: the marks are solid, large and built from several tones like real
// brands. One tone per mark looked like a toolbar. The colour glow of the tile
// comes from the main tone of the brand and sits radially behind the mark.
type Partner = {
name: string;
role: string;
hue: string;
mark: ReactNode;
};
// A shared palette, so the row is colourful and still belongs together.
const M = {
blue: "#1a73e8",
blueLight: "#8ab4f8",
navy: "#174ea6",
red: "#d93025",
yellow: "#f9ab00",
green: "#34a853",
teal: "#12a594",
violet: "#7048d8",
slate: "#5f6368",
};
const PARTNERS: Partner[] = [
{
name: "Nordbank",
role: "Euro balances",
hue: "26,115,232",
// Pediment, columns, base: the oldest mark for a bank, in three blues instead
// of one.
mark: (
<>
<path d="M12 2.6 22 8.2v2.2H2V8.2z" fill={M.blue} />
<path
d="M5 12.2h2.6v6.2H5zM10.7 12.2h2.6v6.2h-2.6zM16.4 12.2H19v6.2h-2.6z"
fill={M.blueLight}
/>
<path d="M2.6 20h18.8v2.2H2.6z" fill={M.navy} />
</>
),
},
{
name: "Vester",
role: "Yearly audit",
hue: "112,72,216",
// Ring with a dot: audited substance, two tones.
mark: (
<>
<path
fillRule="evenodd"
d="M12 1.8a10.2 10.2 0 100 20.4 10.2 10.2 0 000-20.4zm0 3.6a6.6 6.6 0 110 13.2 6.6 6.6 0 010-13.2z"
fill={M.violet}
/>
<circle cx="12" cy="12" r="3.1" fill={M.yellow} />
</>
),
},
{
name: "Kestrel",
role: "Reserve reports",
hue: "26,115,232",
// Two offset diamonds: a report that builds on the previous one.
mark: (
<>
<path d="M12 2.6 20.6 8 12 13.4 3.4 8z" fill={M.blue} />
<path d="M12 10.6 20.6 16 12 21.4 3.4 16z" fill={M.green} />
</>
),
},
{
name: "Almen",
role: "Cold storage",
hue: "18,165,148",
// Crystal: three crossed bars, like a snowflake without the kitsch.
mark: (
<>
<rect x="10.6" y="2" width="2.8" height="20" rx="1.4" fill={M.teal} />
<rect
x="10.6"
y="2"
width="2.8"
height="20"
rx="1.4"
fill={M.blue}
transform="rotate(60 12 12)"
/>
<rect
x="10.6"
y="2"
width="2.8"
height="20"
rx="1.4"
fill={M.blueLight}
transform="rotate(-60 12 12)"
/>
</>
),
},
{
name: "Rhone",
role: "Crime insurance",
hue: "217,48,37",
// A cut stone: the value that is insured.
mark: (
<>
<path d="M12 1.6 22.4 12 12 22.4 1.6 12z" fill={M.red} />
<path d="M12 6.6 17.4 12 12 17.4 6.6 12z" fill={M.yellow} />
</>
),
},
{
name: "Certa",
role: "EU registration",
hue: "95,99,104",
// Shield with a tick: the licence.
mark: (
<>
<path d="M12 1.8 21 5.4v6.2c0 4.4-3.4 8-9 10.6-5.6-2.6-9-6.2-9-10.6V5.4z" fill={M.slate} />
<path
d="M8.4 11.8 11 14.4l4.8-4.8"
fill="none"
stroke={M.green}
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
),
},
];
function PartnerWall() {
return (
<div className="mt-20 md:mt-24">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<h3 data-reveal className="max-w-[22ch] text-[22px] font-semibold md:text-[28px]">
The houses that check our numbers.
</h3>
<p
data-reveal
data-delay="1"
className="max-w-[38ch] text-[13.5px] leading-relaxed"
style={{ color: "var(--muted)" }}
>
Every report they publish is linked in the app, with the date it was signed.
</p>
</div>
<ul className="mt-10 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{PARTNERS.map((p, i) => (
<li key={p.name} data-reveal data-delay={String(Math.min(3, i)) as "1"}>
<div
className="flex aspect-[5/4] flex-col items-center justify-center gap-3 rounded-[18px]"
style={{
background: `radial-gradient(80% 70% at 50% 42%, rgba(${p.hue},0.2) 0%, rgba(${p.hue},0.09) 55%, rgba(${p.hue},0.05) 100%)`,
}}
>
<svg aria-hidden width="34" height="34" viewBox="0 0 24 24" fill={`rgb(${p.hue})`}>
{p.mark}
</svg>
<span className="display text-[14.5px] font-semibold tracking-[-0.02em]">
{p.name}
</span>
</div>
<span
className="mt-2.5 block text-center text-[11.5px]"
style={{ color: "var(--muted)" }}
>
{p.role}
</span>
</li>
))}
</ul>
</div>
);
}
function Oversight() {
return (
// A surface of its own instead of the third white section in a row: it gives
// the section its own weight between the numbers and the FAQ.
<section className="w-full px-5 py-24 md:px-8 md:py-32" style={{ background: "var(--soft)" }}>
<div className="mx-auto grid max-w-6xl gap-12 md:grid-cols-[0.95fr_1.05fr] md:items-center md:gap-16">
<div data-reveal className="overflow-hidden rounded-2xl" style={{ aspectRatio: "5 / 6" }}>
<img
src={AUDIT_IMG}
alt="Hands signing a document at a meeting table"
loading="lazy"
className="h-full w-full object-cover"
/>
</div>
<div data-reveal data-delay="1">
<span
className="text-[11px] font-semibold uppercase tracking-[0.22em]"
style={{ color: "var(--accent)" }}
>
Oversight
</span>
<blockquote className="mt-6 max-w-[24ch] text-[28px] font-semibold leading-[1.12] md:text-[38px]">
<p className="display">
“We would rather explain a fee twice than earn it quietly once.”
</p>
</blockquote>
<p className="mt-5 text-[13.5px]" style={{ color: "var(--muted)" }}>
Marit Ehlers, Head of Compliance
</p>
{/* Three points, three marks: licence, bank, audit. The same tick three times
would only confirm that there are three rows. */}
<ul className="mt-10 flex flex-col gap-4">
{[
{
Icon: BadgeCheck,
t: "Registered as a crypto asset service provider in the EU",
},
{
Icon: Landmark,
t: "Customer euro balances held at a partner bank, separate from company funds",
},
{ Icon: FileSearch, t: "Yearly financial audit, quarterly reserve report" },
].map((item) => (
<li key={item.t} className="flex items-start gap-3 text-[14.5px] leading-relaxed">
<item.Icon
aria-hidden
className="mt-[2px] h-[18px] w-[18px] shrink-0"
strokeWidth={1.5}
style={{ color: "var(--accent)" }}
/>
{item.t}
</li>
))}
</ul>
</div>
</div>
<div className="mx-auto max-w-6xl">
<PartnerWall />
</div>
</section>
);
}
// ------------------------------------------------------------------------ FAQ
const FAQ = [
{
q: "What do I need to open an account?",
a: "An EU address, a passport or ID card and about eight minutes. Verification is done in the app and usually confirmed the same day.",
},
{
q: "Can I move my coins to my own wallet?",
a: "Yes, at any time and to any address you control. We show the network fee and an arrival estimate before you send.",
},
{
q: "What happens if Arvo stops trading?",
a: "Customer assets are held separately from company assets. They are not part of the company balance sheet and can be returned to you.",
},
{
q: "Do you pay interest on holdings?",
a: "No. Interest would mean lending your coins out. We hold them instead, which is the whole reason people use us.",
},
];
function Faq() {
const [open, setOpen] = useState<number | null>(0);
return (
<section className="w-full px-5 py-24 md:px-8 md:py-32">
<div className="mx-auto grid max-w-5xl gap-12 md:grid-cols-[0.7fr_1.3fr] md:gap-16">
<div data-reveal>
<h2 className="text-[30px] font-semibold md:text-[40px]">Questions we get asked</h2>
<p
className="mt-5 max-w-[26ch] text-[14px] leading-relaxed"
style={{ color: "var(--muted)" }}
>
If something is still unclear, write to us. A person answers, usually within a working
day.
</p>
<a
href="#open"
className="mt-5 inline-flex items-center gap-1.5 text-[13.5px] font-medium underline decoration-[1.5px] underline-offset-4"
style={{ color: "var(--accent)" }}
>
Ask a question
<ArrowUpRight className="h-3.5 w-3.5" strokeWidth={2.2} />
</a>
</div>
<div data-reveal data-delay="1" className="flex flex-col">
{FAQ.map((f, i) => {
const on = open === i;
return (
<div key={f.q}>
<button
type="button"
onClick={() => setOpen(on ? null : i)}
aria-expanded={on}
className="flex w-full items-start justify-between gap-6 py-5 text-left"
>
<span className="text-[16px] font-semibold md:text-[17px]">{f.q}</span>
<span
className="mt-[3px] grid h-6 w-6 shrink-0 place-items-center rounded-full"
style={{ background: on ? "var(--accent)" : "var(--soft)" }}
>
{on ? (
<Minus className="h-3 w-3 text-white" strokeWidth={2.6} />
) : (
<Plus className="h-3 w-3" strokeWidth={2.6} />
)}
</span>
</button>
<AnimatePresence initial={false}>
{on && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.32, ease }}
className="overflow-hidden"
>
<p
className="max-w-[54ch] pb-6 text-[14.5px] leading-relaxed"
style={{ color: "var(--muted)" }}
>
{f.a}
</p>
</motion.div>
)}
</AnimatePresence>
<span className="block h-px w-full" style={{ background: "var(--line)" }} />
</div>
);
})}
</div>
</div>
</section>
);
}
// -------------------------------------------------------------- CTA + Footer
function Closing() {
return (
<section id="open" className="w-full px-5 pb-16 md:px-8 md:pb-20">
<div
data-reveal
className="mx-auto flex max-w-6xl flex-col items-start gap-8 rounded-[26px] px-7 py-16 md:flex-row md:items-end md:justify-between md:px-14 md:py-20"
style={{ background: "var(--accent)" }}
>
<div>
<h2 className="max-w-[16ch] text-[32px] font-semibold text-white md:text-[46px]">
Start with as little as 25{" "}€.
</h2>
<p className="mt-5 max-w-[40ch] text-[15px] leading-relaxed text-white/75">
Open the account first and look around. Nothing is charged until you place an order.
</p>
</div>
<RollButton href="#top" tone="paper" className="shrink-0">
Open an account
</RollButton>
</div>
</section>
);
}
function Footer() {
const groups = [
{ title: "Product", items: ["Assets", "Fees", "Custody", "Mobile app"] },
{ title: "Company", items: ["About", "Careers", "Press", "Contact"] },
{ title: "Legal", items: ["Terms", "Privacy", "Risk notice", "Complaints"] },
];
return (
<footer className="w-full px-5 pb-12 md:px-8">
<div className="mx-auto max-w-6xl">
<div className="grid gap-12 py-14 md:grid-cols-[1.4fr_repeat(3,0.6fr)]">
<div>
<span className="display text-[22px] font-semibold tracking-[-0.04em]">Arvo</span>
<p
className="mt-4 max-w-[34ch] text-[13px] leading-relaxed"
style={{ color: "var(--muted)" }}
>
Digital assets carry risk and can lose value. Nothing here is investment advice.
</p>
</div>
{groups.map((g) => (
<div key={g.title}>
<span
className="text-[11px] font-semibold uppercase tracking-[0.16em]"
style={{ color: "#98a1b6" }}
>
{g.title}
</span>
<ul className="mt-4 flex flex-col gap-2.5">
{g.items.map((it) => (
<li key={it}>
<a
href="#top"
className="text-[13.5px] transition-colors duration-300"
style={{ color: "var(--muted)" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "var(--ink)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "var(--muted)")}
>
{it}
</a>
</li>
))}
</ul>
</div>
))}
</div>
<div
className="flex flex-col gap-2 border-t pt-6 text-[12px] md:flex-row md:items-center md:justify-between"
style={{ borderColor: "var(--line)", color: "#98a1b6" }}
>
<span>© 2026 Arvo Digital Assets</span>
<span>Registered in the European Union</span>
</div>
</div>
</footer>
);
}
// ------------------------------------------------------------------- The page
export function ArvoCryptoPage() {
const reduce = useReducedMotion();
const rootRef = useRef<HTMLDivElement>(null);
const rates = useLiveRates(reduce);
useReveals(rootRef);
return (
<div ref={rootRef} className="arvo w-full">
<style>{scopedStyles}</style>
<Nav />
<Hero reduce={reduce} rates={rates} />
<Markets reduce={reduce} rates={rates} />
<HowItWorks reduce={reduce} />
<Custody reduce={reduce} />
<Releases reduce={reduce} />
<Fees reduce={reduce} />
<Oversight />
<Faq />
<Closing />
<Footer />
</div>
);
}
export default ArvoCryptoPage;
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