A bento grid of platform features: a processor wheel that reroutes around its own outages, a charge snippet, regions, a vault with its tokens drifting round a glowing lock, payment methods, flat pricing rows, and refunds clearing a Checkpoint of finance rules.
registry/blocks/reroute-features.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Checkpoint, type CheckpointRequest, type CheckpointRule } from "@/components/ui/checkpoint";
import { Reroute, type RerouteItem } from "@/components/ui/reroute";
/* ── Content ─────────────────────────────────────────────────
* Everything you'd change to make the section yours: the copy
* for each cell, the processors on the wheel and the outage it
* drills, the snippet, the vault's tokens, the payment methods,
* the pricing rows, and the rules a refund has to clear.
* ─────────────────────────────────────────────────────────── */
/** The one accent: the hub, the cable, the checks and highlighted values. */
const ACCENT = "#ff3b1f";
const ACCENT_DARK = "#ff5a3c";
const EYEBROW = "Platform";
const HEADLINE = "Every charge finds a way through.";
const SUBLINE = "Routing, retries and approvals in one layer between your checkout and your processors.";
const ROUTING = {
title: "Route around any outage",
body: "When a processor stalls, the charge retries on the next healthy one in under 50 ms. Customers never see the error.",
};
/** ms: how long a processor stays down in the drill, and the calm between drills. */
const DRILL = { first: 2200, down: 2600, calm: 4200 };
const API = {
title: "One API for every processor",
body: "Keep the contracts you already have. Switchyard speaks to each processor so your code doesn't have to.",
// Kept as lines, not one template string, so no line is read as an import.
code: [
["await", " yard.charges.create({", "", ""],
["", " amount: ", "", "4200,"],
["", " currency: ", "", '"eur",'],
["", " route: ", "", '"best-approval",'],
["", "});", "", ""],
],
};
const REGIONS = {
title: "Runs in the region you pick",
body: "Deploy in the US, the EU or Asia Pacific, and keep card data where your auditors want it.",
chips: ["us-east", "eu-central", "ap-southeast"],
};
const VAULT = {
title: "Card data stays in your vault",
body: "Tokens live in your own cloud account, so you can switch processors without moving a single card.",
chips: ["•••• 4242", "tok_9fQ2", "exp 08/29", "3DS ✓", "fp_a71c"],
};
const METHODS = {
stat: "140+",
unit: "ways to pay",
title: "Local methods in 60 markets",
body: "Wallets, bank debits and instant transfers switch on per country, with the right one shown at checkout.",
rows: [
["Cards", "Wallets", "SEPA Debit", "Pix", "UPI", "ACH", "Bank transfer"],
["Pay by link", "BACS", "Installments", "Instant payouts", "BLIK", "FPX"],
["Network tokens", "3-D Secure", "Subscriptions", "Multi-currency", "PayNow", "Boleto"],
],
};
const PRICING = {
stat: "0 bps",
unit: "on your volume",
title: "A flat fee, never a cut",
body: "One monthly price per environment. Retries, failover and tokens are included, however much you process.",
rows: [
{ label: "Smart retries", value: "Included" },
{ label: "Failover routing", value: "Included" },
{ label: "Network tokens", value: "Included" },
],
};
const APPROVALS = {
title: "Rules finance signs off on",
body: "Refunds and payouts clear your policy first. Anything unusual waits for a second pair of eyes, and every decision is logged.",
};
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%",
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",
detail: "auth 97.9%",
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%",
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%",
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%",
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>
),
},
];
const RULES: CheckpointRule[] = [
{ id: "role", label: "Requested by", detail: "Support or finance", value: "SUPPORT" },
{ id: "amount", label: "Amount", detail: "Over $500 needs sign-off", value: "≤ $500" },
{ id: "destination", label: "Destination", detail: "Back to the card it came from", value: "ORIGINAL" },
{ id: "age", label: "Charge age", detail: "Within the refund window", value: "≤ 120 DAYS" },
];
const REQUESTS: CheckpointRequest[] = [
{ id: "r1", label: "refund ch_3Lq8", meta: "$64.00 · Ana, support" },
{ id: "r2", label: "refund ch_9Pe2", meta: "$1,240.00 · Ana, support", verdicts: { amount: "hold" } },
{
id: "r3",
label: "refund ch_2Hx7",
meta: "$310.00 to a new card",
verdicts: { destination: "block" },
reason: "Refunds go back to the original card",
},
];
/* ── Bits ── */
const REDUCED = "(prefers-reduced-motion: reduce)";
function useReducedMotion() {
return React.useSyncExternalStore(
(onChange) => {
const q = window.matchMedia(REDUCED);
q.addEventListener("change", onChange);
return () => q.removeEventListener("change", onChange);
},
() => window.matchMedia(REDUCED).matches,
() => false,
);
}
function Check({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" className={className} fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M3.5 8.5 6.5 11.5 12.5 5" />
</svg>
);
}
function Cell({
title,
body,
className,
children,
bare,
center,
}: {
title: string;
body: string;
className?: string;
children: React.ReactNode;
bare?: boolean;
/** Center the visual in the space above the caption (the diagram cells). */
center?: boolean;
}) {
return (
<article
data-cell
className={cn(
"group relative flex flex-col overflow-hidden rounded-2xl border border-zinc-950/[0.08] bg-white transition-[border-color] duration-200 [@media(hover:hover)]:hover:border-zinc-950/[0.16]",
"bg-[linear-gradient(to_bottom,color-mix(in_oklab,var(--accent)_5%,transparent),transparent_42%)]",
"dark:border-white/[0.08] dark:bg-zinc-900/60 dark:[@media(hover:hover)]:hover:border-white/[0.16]",
className,
)}
>
<div className={cn("relative flex-1", !bare && "px-5 pt-5", center && "flex flex-col justify-center")}>{children}</div>
<div className="px-5 pb-5 pt-4">
<h3 className="text-[15px] font-medium tracking-tight text-zinc-950 dark:text-white">{title}</h3>
<p className="mt-1.5 max-w-md text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">{body}</p>
</div>
</article>
);
}
/** Chips floating round the vault's glowing lock. They drift a couple of px, slowly, and stop for reduced motion. */
function Vault({ reduced }: { reduced: boolean }) {
const chips = React.useRef<(HTMLSpanElement | null)[]>([]);
const glow = React.useRef<HTMLSpanElement>(null);
React.useEffect(() => {
if (reduced) return;
const anims = chips.current.map((el, i) =>
el?.animate([{ transform: "translateY(0)" }, { transform: `translateY(${i % 2 ? 3 : -3}px)` }], {
duration: 2600 + i * 380,
direction: "alternate",
iterations: Infinity,
easing: "ease-in-out",
delay: -i * 600,
}),
);
const g = glow.current?.animate([{ opacity: 0.55 }, { opacity: 1 }], {
duration: 2000,
direction: "alternate",
iterations: Infinity,
easing: "ease-in-out",
});
return () => {
anims.forEach((a) => a?.cancel());
g?.cancel();
};
}, [reduced]);
// A 352 × 236 box, scaled to fit: the lock sits low in the middle and the
// five chips ride a circle of radius 150 round it, so the arc runs through them.
const spots = [
{ x: 13, y: 44.5 },
{ x: 27.4, y: 22.5 },
{ x: 50, y: 12.7 },
{ x: 72.6, y: 22.5 },
{ x: 87, y: 44.5 },
];
return (
<div aria-hidden className="relative mx-auto mt-2 aspect-[352/236] w-full max-w-[22rem] overflow-hidden [mask-image:linear-gradient(to_bottom,#000_62%,transparent)]">
<span className="absolute left-1/2 top-[76.3%] aspect-square w-[85.2%] -translate-x-1/2 -translate-y-1/2 rounded-full border border-zinc-950/[0.08] dark:border-white/[0.09]" />
<span className="absolute left-1/2 top-[76.3%] aspect-square w-[54.5%] -translate-x-1/2 -translate-y-1/2 rounded-full border border-dashed border-zinc-950/[0.07] dark:border-white/[0.08]" />
{VAULT.chips.map((chip, i) => (
<span
key={chip}
ref={(el) => {
chips.current[i] = el;
}}
className="absolute will-change-transform"
style={{ left: `${spots[i].x}%`, top: `${spots[i].y}%` }}
>
<span className="block -translate-x-1/2 -translate-y-1/2 whitespace-nowrap rounded-lg border border-[color-mix(in_oklab,var(--accent)_22%,transparent)] bg-[color-mix(in_oklab,var(--accent)_7%,white)] px-2 py-1 font-mono text-[11px] text-(--accent) shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:bg-[color-mix(in_oklab,var(--accent-dark)_14%,#18181b)] dark:text-(--accent-dark)">
{chip}
</span>
</span>
))}
<span
ref={glow}
className="absolute left-1/2 top-[76.3%] aspect-square w-[46%] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklab,var(--accent)_38%,transparent),transparent)] will-change-[opacity]"
/>
<span className="absolute left-1/2 top-[76.3%] grid size-14 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-2xl bg-[linear-gradient(180deg,color-mix(in_oklab,var(--accent),white_16%),var(--accent))] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.35),0_10px_24px_-8px_color-mix(in_oklab,var(--accent)_70%,transparent)]">
<svg viewBox="0 0 24 24" className="size-6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="10.5" width="14" height="10" rx="2.5" />
<path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5" />
<circle cx="12" cy="15.5" r="1.3" fill="currentColor" stroke="none" />
</svg>
</span>
</div>
);
}
/* ── Section ── */
export function RerouteFeatures() {
const reduced = useReducedMotion();
const [down, setDown] = React.useState<string[]>([]);
const live = React.useRef<string | null>(PROCESSORS[0].id);
const sectionRef = React.useRef<HTMLElement>(null);
// The routing cell drills an outage now and then, while it's on screen.
React.useEffect(() => {
const section = sectionRef.current;
if (!section) return;
let visible = false;
let timer = 0;
const next = (ms: number, fn: () => void) => {
window.clearTimeout(timer);
timer = window.setTimeout(fn, ms);
};
const drop = () => {
if (!visible) return;
if (live.current) setDown([live.current]);
next(DRILL.down, () => {
setDown([]);
next(DRILL.calm, drop);
});
};
const io = new IntersectionObserver(([entry]) => {
const was = visible;
visible = entry.isIntersecting;
if (visible && !was) next(DRILL.first, drop);
if (!visible) {
window.clearTimeout(timer);
setDown([]);
}
});
io.observe(section);
return () => {
io.disconnect();
window.clearTimeout(timer);
};
}, []);
// Cells rise in, one after another, if the section starts below the fold.
React.useEffect(() => {
const section = sectionRef.current;
if (!section || reduced) return;
if (section.getBoundingClientRect().top < window.innerHeight * 0.9) return;
const cells = Array.from(section.querySelectorAll<HTMLElement>("[data-cell]"));
cells.forEach((c) => (c.style.opacity = "0"));
const io = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
io.disconnect();
cells.forEach((c, i) => {
c.style.opacity = "";
c.animate(
[
{ opacity: 0, transform: "translateY(14px)" },
{ opacity: 1, transform: "translateY(0)" },
],
{ duration: 520, delay: i * 60, easing: "cubic-bezier(0.23, 1, 0.32, 1)", fill: "backwards" },
);
});
},
{ threshold: 0.12 },
);
io.observe(section);
return () => {
io.disconnect();
cells.forEach((c) => (c.style.opacity = ""));
};
}, [reduced]);
return (
<section
ref={sectionRef}
style={{ "--accent": ACCENT, "--accent-dark": ACCENT_DARK } as React.CSSProperties}
className="bg-white text-zinc-950 dark:bg-zinc-950 dark:text-white"
>
<div className="mx-auto max-w-6xl px-6 py-20 sm:py-24">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[13px] font-medium text-(--accent) dark:text-(--accent-dark)">{EYEBROW}</p>
<h2 className="mt-2 text-balance text-[2rem] font-medium leading-[1.1] tracking-[-0.035em] sm:text-[2.75rem]">{HEADLINE}</h2>
</div>
<p className="max-w-sm text-pretty text-[15px] leading-relaxed text-zinc-500 dark:text-zinc-400">{SUBLINE}</p>
</div>
<div className="mt-10 grid grid-cols-1 gap-3 md:grid-cols-2 lg:mt-12 lg:grid-cols-12">
{/* Routing */}
<Cell title={ROUTING.title} body={ROUTING.body} className="lg:col-span-4" bare center>
<Reroute
className="pt-5 [--reroute-accent:var(--accent)] [--reroute-height:clamp(14rem,58cqw,16.5rem)] dark:[--reroute-accent:var(--accent-dark)]"
label="Payment processor"
items={PROCESSORS}
defaultValue="northpay"
down={down}
onRoute={(id) => (live.current = id)}
hub={
<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>
}
/>
</Cell>
{/* API + regions, stacked */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:col-span-2 md:grid-cols-2 lg:col-span-4 lg:grid-cols-1">
<Cell title={API.title} body={API.body}>
<pre
aria-label="Creating a charge"
className="overflow-hidden rounded-xl border border-zinc-950/[0.06] bg-[color-mix(in_oklab,var(--accent)_4%,white)] px-3.5 py-3 font-mono text-[11.5px] leading-[1.7] text-zinc-700 dark:border-white/[0.06] dark:bg-white/[0.03] dark:text-zinc-300"
>
{API.code.map(([kw, a, kw2, b], i) => (
<div key={i} className="whitespace-pre">
<span className="text-zinc-950 dark:text-white">{kw}</span>
{a}
<span className="text-zinc-950 dark:text-white">{kw2}</span>
<span className={/^["\d]/.test(b) ? "text-(--accent) dark:text-(--accent-dark)" : undefined}>{b}</span>
</div>
))}
</pre>
</Cell>
<Cell title={REGIONS.title} body={REGIONS.body}>
<div className="flex flex-wrap gap-1.5">
{REGIONS.chips.map((r, i) => (
<span
key={r}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 font-mono text-[11px]",
i === 1
? "border-[color-mix(in_oklab,var(--accent)_30%,transparent)] bg-[color-mix(in_oklab,var(--accent)_8%,white)] text-(--accent) dark:bg-[color-mix(in_oklab,var(--accent-dark)_14%,transparent)] dark:text-(--accent-dark)"
: "border-zinc-950/[0.08] text-zinc-600 dark:border-white/10 dark:text-zinc-400",
)}
>
<span className={cn("size-1.5 rounded-full", i === 1 ? "bg-current" : "bg-zinc-300 dark:bg-zinc-600")} />
{r}
</span>
))}
</div>
</Cell>
</div>
{/* Vault */}
<Cell title={VAULT.title} body={VAULT.body} className="lg:col-span-4" bare center>
<Vault reduced={reduced} />
</Cell>
{/* Methods + pricing, stacked */}
<div className="grid grid-cols-1 gap-3 md:col-span-2 lg:col-span-6">
<Cell title={METHODS.title} body={METHODS.body} bare>
<div className="flex items-center gap-2 pt-5">
<div className="shrink-0 pl-5">
<p className="text-[2.25rem] font-medium leading-none tracking-[-0.04em] tabular-nums">{METHODS.stat}</p>
<p className="mt-1.5 text-[15px] text-zinc-500 dark:text-zinc-400">{METHODS.unit}</p>
</div>
<div
aria-label="Payment methods"
className="min-w-0 flex-1 space-y-1.5 overflow-hidden py-1 [mask-image:linear-gradient(to_right,transparent,#000_18%,#000_88%,transparent)]"
>
{METHODS.rows.map((row, r) => (
<div key={r} className="flex gap-1.5" style={{ marginLeft: `${[0, -28, -12][r]}px` }}>
{row.map((m) => (
<span
key={m}
className="shrink-0 rounded-lg border border-zinc-950/[0.08] bg-white px-3 py-1.5 text-xs text-zinc-600 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-400"
>
{m}
</span>
))}
</div>
))}
</div>
</div>
</Cell>
<Cell title={PRICING.title} body={PRICING.body} bare>
<div className="flex flex-col gap-4 px-5 pt-5 sm:flex-row sm:items-center sm:gap-6">
<div className="shrink-0">
<p className="text-[2.25rem] font-medium leading-none tracking-[-0.04em] tabular-nums">{PRICING.stat}</p>
<p className="mt-1.5 text-[15px] text-zinc-500 dark:text-zinc-400">{PRICING.unit}</p>
</div>
<dl className="min-w-0 flex-1 divide-y divide-zinc-950/[0.06] rounded-xl border border-zinc-950/[0.08] bg-white px-3.5 dark:divide-white/[0.06] dark:border-white/10 dark:bg-zinc-900">
{PRICING.rows.map((row) => (
<div key={row.label} className="flex items-center justify-between gap-3 py-2.5">
<dt className="text-[13px] text-zinc-700 dark:text-zinc-300">{row.label}</dt>
<dd className="flex items-center gap-2 text-[13px] font-medium text-(--accent) dark:text-(--accent-dark)">
{row.value}
<span className="grid size-[18px] place-items-center rounded-[5px] bg-(--accent) text-white dark:bg-(--accent-dark)">
<Check className="size-3" />
</span>
</dd>
</div>
))}
</dl>
</div>
</Cell>
</div>
{/* Approvals */}
<Cell title={APPROVALS.title} body={APPROVALS.body} className="md:col-span-2 lg:col-span-6">
<Checkpoint
className="[--checkpoint-accent:var(--accent)] dark:[--checkpoint-accent:var(--accent-dark)]"
rules={RULES}
requests={REQUESTS}
approver="Priya, finance"
/>
</Cell>
</div>
</div>
</section>
);
}
Live preview and the components it's built with: https://keyframeui.com/blocks/feature-sections/reroute-features