A payments-routing hero with the processors on a wheel under your mark. It drills its own outage within two seconds: the live processor drops, the charge reroutes to the next one, then traffic moves back. Visitors can take one offline themselves.
registry/blocks/reroute-hero.tsx
"use client";
import * as React from "react";
import { Reroute, type RerouteItem, type RerouteReason } from "@/components/ui/reroute";
/* ── Content ─────────────────────────────────────────────────
* Everything you'd change to make the section yours: the copy,
* the processors on the wheel and the outage drill. The drill
* takes the live processor down, brings it back, then moves
* traffic on, so a visitor sees a failover within two seconds.
* It stops the first time they press anything.
* ─────────────────────────────────────────────────────────── */
/** The one accent: the hub, the cable, the primary buttons. */
const ACCENT = "#ff3b1f";
const ACCENT_DARK = "#ff5a3c";
const BRAND = { name: "Switchyard", href: "#" };
const NAV_LINKS = [
{ label: "Product", href: "#product" },
{ label: "Processors", href: "#processors" },
{ label: "Pricing", href: "#pricing" },
{ label: "Docs", href: "#docs" },
];
const SIGN_IN = { label: "Sign in", href: "#sign-in" };
const NAV_CTA = { label: "Get a demo", href: "#demo" };
const BADGE = { tag: "New", text: "Failover routing is out of beta" };
const HEADLINE = "Checkout that routes around outages.";
const SUBLINE =
"Switchyard sits between your checkout and every processor you use. When one stalls, the charge retries on the next healthy one before your customer notices.";
const PRIMARY_CTA = { label: "Start routing", href: "#start" };
const SECONDARY_CTA = { label: "Read the docs", href: "#docs" };
const STATS = [
{ value: "99.995%", label: "checkout uptime" },
{ value: "38 ms", label: "median failover" },
{ value: "6", label: "processors, one API" },
];
const PANEL = { title: "Live route", scope: "EU checkout · production", healthy: "All processors up", degraded: "1 processor down" };
const LOG_EMPTY = "Watching every processor…";
/** ms: wait, take the live one down, bring it back, move on. */
const DRILL = { first: 1600, down: 3400, next: 2600, rest: 10000 };
function Mark({ children }: { children: React.ReactNode }) {
return (
<svg viewBox="0 0 24 24" className="size-full" aria-hidden>
{children}
</svg>
);
}
// Every processor name and mark is made up.
const PROCESSORS: RerouteItem[] = [
{
id: "northpay",
name: "Northpay",
detail: "auth 98.4% · 212 ms",
icon: (
<Mark>
<rect x="3" y="3" width="18" height="18" rx="5" fill="#1d4ed8" />
<path d="M8 16V8l8 8V8" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</Mark>
),
},
{
id: "kestrel",
name: "Kestrel Payments",
detail: "auth 97.9% · 188 ms",
icon: (
<Mark>
<path d="M3 16.5 12 5l9 11.5-4.2-2.4L12 19l-4.8-4.9z" fill="#f97316" />
</Mark>
),
},
{
id: "paywell",
name: "Paywell",
detail: "auth 98.1% · 240 ms",
icon: (
<Mark>
<circle cx="12" cy="12" r="9" fill="#0d9488" />
<path d="M6.5 12.5c1.8-2.4 3.6-2.4 5.5 0s3.7 2.4 5.5 0" fill="none" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" />
</Mark>
),
},
{
id: "ledgerline",
name: "Ledgerline",
detail: "auth 98.7% · 176 ms",
icon: (
<Mark>
<rect x="4" y="4" width="16" height="4" rx="2" fill="currentColor" />
<rect x="4" y="10" width="11" height="4" rx="2" fill="currentColor" opacity="0.6" />
<rect x="4" y="16" width="7" height="4" rx="2" fill="currentColor" opacity="0.35" />
</Mark>
),
},
{
id: "orbit",
name: "Orbit Pay",
detail: "auth 97.6% · 205 ms",
icon: (
<Mark>
<circle cx="12" cy="12" r="7.5" fill="none" stroke="#7c3aed" strokeWidth="3" />
<circle cx="18.2" cy="6.6" r="2.6" fill="#a78bfa" />
</Mark>
),
},
{
id: "tandem",
name: "Tandem",
detail: "auth 98.2% · 198 ms",
icon: (
<Mark>
<circle cx="8.5" cy="12" r="5" fill="#16a34a" />
<circle cx="15.5" cy="12" r="5" fill="#86efac" opacity="0.85" />
</Mark>
),
},
];
const nameOf = (id: string | null) => PROCESSORS.find((p) => p.id === id)?.name ?? "nothing";
const shortName = (id: string | null) => nameOf(id).split(" ")[0];
const clock = () => new Date().toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
/* ── Marks ── */
function SwitchyardMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden>
<rect x="2" y="2" width="20" height="20" rx="6" fill="currentColor" />
<path d="M9 18V6M9 14.5c0-3.5 6-3 6-8.5" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function Arrow({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" className={className} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M3.5 8h9M8.5 4l4 4-4 4" />
</svg>
);
}
/* ── Section ── */
type Entry = { id: number; time: string; text: string; fail: boolean };
export function RerouteHero() {
const [value, setValue] = React.useState(PROCESSORS[0].id);
const [down, setDown] = React.useState<string[]>([]);
const [live, setLive] = React.useState<string | null>(PROCESSORS[0].id);
const [log, setLog] = React.useState<Entry[]>([]);
const [touched, setTouched] = React.useState(false);
const liveRef = React.useRef(live);
const prevLive = React.useRef(live);
const seq = React.useRef(0);
const onRoute = React.useCallback((id: string | null, reason: RerouteReason) => {
const from = prevLive.current;
prevLive.current = id;
liveRef.current = id;
setLive(id);
const text =
reason === "failover"
? `${shortName(from)} timed out · retried on ${shortName(id)}`
: reason === "recover"
? `${shortName(id)} recovered · traffic moved back`
: `Routing to ${shortName(id)}`;
seq.current += 1;
const entry = { id: seq.current, time: clock(), text, fail: reason === "failover" };
setLog((l) => [entry, ...l].slice(0, 2));
}, []);
// The outage drill, until the visitor takes over.
React.useEffect(() => {
if (touched) return;
let alive = true;
const timers: number[] = [];
const wait = (ms: number) => new Promise<void>((r) => timers.push(window.setTimeout(r, ms)));
(async () => {
await wait(DRILL.first);
while (alive) {
const victim = liveRef.current;
if (victim) setDown([victim]);
await wait(DRILL.down);
if (!alive) return;
setDown([]);
await wait(DRILL.next);
if (!alive) return;
const at = PROCESSORS.findIndex((p) => p.id === liveRef.current);
setValue(PROCESSORS[(at + 1) % PROCESSORS.length].id);
await wait(DRILL.next);
}
})();
return () => {
alive = false;
timers.forEach((t) => window.clearTimeout(t));
};
}, [touched]);
const anyDown = down.length > 0;
return (
<section
style={{ "--accent": ACCENT, "--accent-dark": ACCENT_DARK } as React.CSSProperties}
className="relative overflow-hidden bg-white text-zinc-950 dark:bg-zinc-950 dark:text-white"
>
<header className="border-b border-zinc-950/[0.07] dark:border-white/[0.08]">
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between gap-6 px-6">
<a
href={BRAND.href}
className="flex items-center gap-2 rounded-md text-[15px] font-semibold tracking-tight focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-(--accent)"
>
<SwitchyardMark className="size-6 text-(--accent) dark:text-(--accent-dark)" />
{BRAND.name}
</a>
<nav aria-label="Main" className="hidden items-center gap-8 text-sm text-zinc-600 md:flex dark:text-zinc-400">
{NAV_LINKS.map((link) => (
<a
key={link.label}
href={link.href}
className="rounded-sm transition-colors duration-150 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-(--accent) dark:hover:text-white"
>
{link.label}
</a>
))}
</nav>
<div className="flex items-center gap-2">
<a
href={SIGN_IN.href}
className="hidden h-9 items-center rounded-full bg-zinc-950/[0.04] px-4 text-[13px] font-medium text-zinc-800 transition-colors duration-150 hover:bg-zinc-950/[0.08] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) sm:flex dark:bg-white/[0.06] dark:text-zinc-200 dark:hover:bg-white/[0.1]"
>
{SIGN_IN.label}
</a>
<a
href={NAV_CTA.href}
className="flex h-9 items-center rounded-full bg-[color-mix(in_oklab,var(--accent)_10%,white)] px-4 text-[13px] font-semibold text-(--accent) transition-colors duration-150 hover:bg-[color-mix(in_oklab,var(--accent)_16%,white)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) dark:bg-[color-mix(in_oklab,var(--accent-dark)_16%,transparent)] dark:text-(--accent-dark) dark:hover:bg-[color-mix(in_oklab,var(--accent-dark)_24%,transparent)]"
>
{NAV_CTA.label}
</a>
</div>
</div>
</header>
<div className="mx-auto grid max-w-6xl grid-cols-1 items-center gap-12 px-6 pb-20 pt-12 sm:pt-16 lg:grid-cols-[1fr_minmax(0,34rem)] lg:gap-14 lg:pb-24 lg:pt-14">
<div className="max-w-xl">
<a
href="#changelog"
className="inline-flex items-center gap-2 rounded-full border border-zinc-950/[0.08] bg-white py-1 pl-1 pr-3 text-[13px] text-zinc-600 transition-colors duration-150 hover:border-zinc-950/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-white/25"
>
<span className="rounded-full bg-(--accent) px-2 py-0.5 text-[11px] font-semibold text-white dark:bg-(--accent-dark)">{BADGE.tag}</span>
{BADGE.text}
<Arrow className="size-3.5 text-zinc-400" />
</a>
<h1 className="mt-6 text-balance text-[2.5rem] font-semibold leading-[1.05] tracking-[-0.04em] sm:text-[3.5rem] lg:text-[3.75rem]">
{HEADLINE}
</h1>
<p className="mt-5 max-w-lg text-pretty text-base leading-relaxed text-zinc-600 sm:text-[17px] dark:text-zinc-400">{SUBLINE}</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<a
href={PRIMARY_CTA.href}
className="inline-flex h-11 items-center gap-2 rounded-full bg-(--accent) px-5 text-sm font-semibold text-white shadow-[0_8px_24px_-10px_var(--accent)] transition-[transform,filter] duration-150 hover:brightness-110 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) active:scale-[0.97] dark:bg-(--accent-dark)"
>
{PRIMARY_CTA.label}
<Arrow className="size-4" />
</a>
<a
href={SECONDARY_CTA.href}
className="inline-flex h-11 items-center rounded-full border border-zinc-950/10 px-5 text-sm font-semibold text-zinc-900 transition-colors duration-150 hover:border-zinc-950/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) dark:border-white/15 dark:text-white dark:hover:border-white/30"
>
{SECONDARY_CTA.label}
</a>
</div>
<dl className="mt-12 grid max-w-md grid-cols-3 gap-6 border-t border-zinc-950/[0.07] pt-6 dark:border-white/[0.08]">
{STATS.map((s) => (
<div key={s.label}>
<dt className="sr-only">{s.label}</dt>
<dd className="text-xl font-semibold tabular-nums tracking-tight sm:text-2xl">{s.value}</dd>
<dd className="mt-1 text-xs leading-snug text-zinc-500 dark:text-zinc-400">{s.label}</dd>
</div>
))}
</dl>
</div>
<div className="relative">
<div
aria-hidden
className="pointer-events-none absolute -inset-x-10 -inset-y-12 -z-0 bg-[radial-gradient(closest-side,color-mix(in_oklab,var(--accent)_13%,transparent),transparent)]"
/>
<div className="relative overflow-hidden rounded-[28px] border border-zinc-950/[0.08] bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04),0_24px_60px_-28px_rgba(0,0,0,0.18)] dark:border-white/10 dark:bg-zinc-900 dark:shadow-none">
<div className="flex items-center justify-between gap-3 px-5 pt-5">
<div className="min-w-0">
<p className="text-[13px] font-medium tracking-tight">{PANEL.title}</p>
<p className="truncate text-xs text-zinc-500 dark:text-zinc-400">{PANEL.scope}</p>
</div>
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-zinc-950/[0.08] px-2.5 py-1 text-[11px] font-medium text-zinc-600 dark:border-white/10 dark:text-zinc-300">
<span className={anyDown ? "size-1.5 rounded-full bg-zinc-950 dark:bg-zinc-200" : "size-1.5 rounded-full bg-(--accent) dark:bg-(--accent-dark)"} />
<span aria-live="polite">{anyDown ? PANEL.degraded : PANEL.healthy}</span>
</span>
</div>
<Reroute
className="mt-1 [--reroute-accent:var(--accent)] dark:[--reroute-accent:var(--accent-dark)]"
label="Payment processor"
items={PROCESSORS}
value={value}
onValueChange={(id) => {
setTouched(true);
setValue(id);
}}
down={down}
onRoute={onRoute}
hub={<SwitchyardGlyph />}
/>
<div className="flex flex-col items-start gap-3 border-t border-zinc-950/[0.06] px-5 py-4 sm:flex-row sm:items-center sm:justify-between dark:border-white/[0.08]">
<ol className="w-full min-w-0 flex-1 space-y-1" aria-label="Routing log">
{log.length === 0 ? (
<li className="text-xs text-zinc-400 dark:text-zinc-500">{LOG_EMPTY}</li>
) : (
log.map((e, i) => (
<li key={e.id} className={i ? "flex gap-2 text-xs opacity-50" : "flex gap-2 text-xs"}>
<span className="shrink-0 font-mono tabular-nums text-zinc-400 dark:text-zinc-500">{e.time}</span>
<span className={e.fail ? "truncate font-medium" : "truncate text-zinc-600 dark:text-zinc-300"}>{e.text}</span>
</li>
))
)}
</ol>
<button
type="button"
onClick={() => {
setTouched(true);
setDown(anyDown ? [] : live ? [live] : []);
}}
className="h-9 shrink-0 rounded-full bg-zinc-950 px-4 text-[13px] font-medium text-white transition-transform duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) active:scale-[0.97] dark:bg-white dark:text-zinc-950"
>
{anyDown ? `Bring ${shortName(down[0])} back` : `Take ${shortName(live)} offline`}
</button>
</div>
</div>
</div>
</div>
</section>
);
}
function SwitchyardGlyph() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden>
<path d="M8.5 20V4M8.5 15c0-4.5 7-4 7-11" />
</svg>
);
}
Live preview and the components it's built with: https://keyframeui.com/blocks/hero-sections/reroute-hero