Reroute Features
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.
"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>
);
}
Installation
The CLI writes components/reroute-features.tsx, the components it's built with (components/ui/checkpoint.tsx and components/ui/reroute.tsx) and lib/utils.ts, and installs clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/reroute-featuresThen put it where the section goes. The copy, links and data sit in constants at the top of the file, so making it yours starts there.
import { RerouteFeatures } from "@/components/reroute-features";
export default function Page() {
return <RerouteFeatures />;
}
Built with
Each of these is a component on its own, with its own examples and props. The block installs them for you.
- Checkpoint A request that has to clear your rules before it runs. A token drops out from under the request and drives down a lane, past one barrier per rule: each one swings open downward, the way the token is going, as its rule passes and the row ticks. A rule that needs sign-off keeps its barrier down and turns its row into Approve and Deny; a rule the request breaks stays shut, and the token knocks against it twice and is turned away. Every outcome rolls into a short log underneath. @keyframeui/checkpoint
- Reroute Your hub at the top, wired by a cable to one option on a wheel of many. Requests run down the cable into the live option. Pick another, spin the wheel or arrow to it, and the cable lifts out, the wheel turns it round to the socket and the cable drops in with a click. When the live option goes down, its request bounces back up the cable and the wheel turns on to the next healthy one, then back again once it recovers. @keyframeui/reroute