Skip to content
All templates

Templates/Crypto Platform

A calm counter-proposal to the usual crypto site. One blue, real photography, and a scroll that tells a story instead of performing: the hero hands off to a market table, a pinned scene walks through funding, buying and withdrawing, and the fee section shows what a 1,000 € order actually costs. Everything is real markup, so the numbers and the interface stay editable. One section needs GSAP: the release shelf, whose cards run endlessly along an invisible arc. Everything else runs without it.

Fintech & CryptoFull page

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

Source code

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

import { useEffect, useRef, useState, type 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;
FAQ

Frequently asked questions

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

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

Get in touch