This is a complete component you can paste into any Next.js or Vite +
React project. It detects the Arkena provider, requests connection,
restores on mount for returning users, and reacts to accountsChanged
and disconnect events. About 80 lines.
Setup
The component depends on the small helper from
Detect the Arkena provider.
Drop it in src/lib/arkena.ts:
src/lib/arkena.ts
type ArkenaProvider = {
request: (args: { method: string; params?: Record<string, unknown> }) => Promise<any>;
on: (event: string, handler: (...args: any[]) => void) => void;
removeListener: (event: string, handler: (...args: any[]) => void) => void;
};
declare global {
interface Window {
arkena?: ArkenaProvider;
}
}
export async function getArkenaProvider(timeoutMs = 1000): Promise<ArkenaProvider | null> {
if (typeof window === "undefined") return null;
if (window.arkena) return window.arkena;
return new Promise((resolve) => {
const onReady = () => resolve(window.arkena ?? null);
window.addEventListener("arkena#initialized", onReady, { once: true });
setTimeout(() => resolve(window.arkena ?? null), timeoutMs);
});
}The component
src/components/connect-button.tsx
"use client";
import { useEffect, useState } from "react";
import { getArkenaProvider } from "@/lib/arkena";
type State =
| { status: "loading" }
| { status: "no-wallet" }
| { status: "disconnected" }
| { status: "connected"; account: string };
function shorten(party: string) {
if (party.length <= 18) return party;
return party.slice(0, 8) + "…" + party.slice(-6);
}
export function ConnectButton() {
const [state, setState] = useState<State>({ status: "loading" });
useEffect(() => {
let mounted = true;
async function init() {
const provider = await getArkenaProvider();
if (!provider || !mounted) {
if (mounted) setState({ status: "no-wallet" });
return;
}
// Try a silent connect — if the origin is already trusted, no popup.
// If not trusted yet, this surfaces the approval popup; gate behind a
// user gesture instead if you'd rather defer that.
try {
const { accounts } = await provider.request({ method: "canton_connect" });
if (mounted) setState({ status: "connected", account: accounts[0] });
} catch (err) {
if (mounted) setState({ status: "disconnected" });
}
const onAccounts = (accounts: string[]) => {
if (!mounted) return;
if (accounts.length === 0) setState({ status: "disconnected" });
else setState({ status: "connected", account: accounts[0] });
};
const onDisconnect = () => {
if (mounted) setState({ status: "disconnected" });
};
provider.on("accountsChanged", onAccounts);
provider.on("disconnect", onDisconnect);
return () => {
provider.removeListener("accountsChanged", onAccounts);
provider.removeListener("disconnect", onDisconnect);
};
}
void init();
return () => {
mounted = false;
};
}, []);
if (state.status === "loading") return <button disabled>…</button>;
if (state.status === "no-wallet") {
return (
<a href="https://arkena.io/install" target="_blank" rel="noreferrer">
Install Arkena
</a>
);
}
if (state.status === "disconnected") {
return (
<button
onClick={async () => {
const provider = await getArkenaProvider();
if (!provider) return;
try {
const { accounts } = await provider.request({ method: "canton_connect" });
setState({ status: "connected", account: accounts[0] });
} catch {
// 4001 — user rejected. Stay disconnected; don't auto-retry.
}
}}
>
Connect wallet
</button>
);
}
return (
<div>
<span>Connected as {shorten(state.account)}</span>
<button
onClick={async () => {
const provider = await getArkenaProvider();
await provider?.request({ method: "canton_disconnect" });
setState({ status: "disconnected" });
}}
>
Disconnect
</button>
</div>
);
}What's interesting in this code
Three things worth noting if you're going to extend it:
The mounted flag. Guards against setState after unmount, which
React strict-mode catches. Standard pattern for any async-effect cleanup.
The silent canton_connect on mount. The wallet remembers approved
origins; calling canton_connect on a trusted origin resolves without a
popup. This is what makes returning users skip the prompt. There's no
separate "is this connected?" call — just try, catch on 4001.
The empty-accounts case in onAccountsChanged. Some wallet
implementations emit an empty array as a soft-disconnect signal (the
user removed all accounts from this origin). Handle it the same as a
hard disconnect.
What's next
- Read account state — fetch balance and activity once connected.
- Sign and submit transactions — the next step after Connect is usually Sign.