Wallet connection for EVM apps: EIP-6963 discovery, connect/disconnect, session restore and chain switching over EIP-1193 — with optional React and Preact UIs that carry no design system of their own.
  • TypeScript 95.5%
  • JavaScript 2.4%
  • CSS 2.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-18 20:30:39 -04:00
dist Extend connected account actions 2026-09-18 20:26:02 -04:00
scripts Initial commit 2026-08-08 16:33:26 -04:00
src Extend connected account actions 2026-09-18 20:26:02 -04:00
.gitignore v0.2.2 2026-08-31 20:37:08 -04:00
AGENTS.md v0.2.2 2026-08-31 20:37:08 -04:00
CHANGELOG.md Extend connected account actions 2026-09-18 20:26:02 -04:00
LICENSE Initial commit 2026-08-08 16:33:26 -04:00
package-lock.json v0.2.3 2026-09-18 20:30:39 -04:00
package.json v0.2.3 2026-09-18 20:30:39 -04:00
README.md Extend connected account actions 2026-09-18 20:26:02 -04:00
RELEASING.md Initial commit 2026-08-08 16:33:26 -04:00
tsconfig.json Initial commit 2026-08-08 16:33:26 -04:00
tsup.config.ts Initial commit 2026-08-08 16:33:26 -04:00
tsup.preact.config.ts Initial commit 2026-08-08 16:33:26 -04:00
vitest.config.ts Initial commit 2026-08-08 16:33:26 -04:00

icu-wallet-kit

Wallet connection for EVM apps.

It handles the whole connect lifecycle — discovering the wallets a browser actually has, connecting and disconnecting, restoring a session on reload, switching chains, and turning provider errors into something a person can read. It speaks EIP-1193 directly, so the core needs no framework and no wallet library underneath it.

The optional UI carries no design system. Components render through slots you fill with your own, so the picker inherits your app's look rather than imposing one.

  • EIP-6963 discovery, deduplicated — the wallets actually installed, each listed once, with the name and icon the wallet itself announces.
  • WalletConnect is optional and switched on by configuration. Without a project ID it is never registered, so the picker cannot offer an option that would not work.
  • No dead ends. Every row corresponds to something that can actually complete; when nothing can, it says so instead.
  • Peer dependencies only, and viem is the only one always required.

Install

// package.json
"dependencies": {
  "icu-wallet-kit": "git+https://git.gui.icu/dev/icu-wallet-kit.git#v0.1.0"
}

The pin is a tag, and tags here are immutable — a published one is never moved. Upgrading means changing the ref and re-resolving the lockfile; see RELEASING.md.

Three entries — take only what you need

entry peers use when
icu-wallet-kit/core viem the kit on its own — discovery, connect, session, chain switching, no UI and no framework
icu-wallet-kit/preact preact, viem you want the ready-made UI in a Preact app
icu-wallet-kit react, wagmi, viem you want the ready-made UI in a React app that already uses wagmi

Everything except viem is optional, so an app installs only what its entry point touches.

Pick one entry per app and import the session API from it exclusively — never /core and /preact (or .) together. Each entry is a separately built, self-contained bundle (splitting: false on purpose, so a /core-only consumer never pays for the UI layer it didn't import), which means each has its own independent copy of the connect/session state. Importing getWalletProvider/switchChain/etc. from /core while connecting through /preact's useWallet silently reads a session that was never populated — getWalletProvider() returns undefined forever, switchChain() throws "No wallet connected." on a wallet the UI shows as plainly connected. /preact (and ., where applicable) re-export the full /core surface for exactly this reason — "a host needs one import specifier, not two."

The kit on its own

core is the whole thing minus the UI, and it depends on nothing but viem:

import {
  initWalletKit, connectWallet, disconnectWallet, restoreWallet,
  switchChain, getWalletProvider, getWalletState, subscribeWallet,
} from "icu-wallet-kit/core";
import { pulsechain } from "viem/chains";

initWalletKit({ chains: [pulsechain.id] });

await restoreWallet();                    // silently re-attach a prior session
const wallets = getWalletState().wallets; // EIP-6963 discoveries, deduplicated
await connectWallet(wallets[0].id);

subscribeWallet((state) => render(state.address, state.chainId));

getWalletProvider() hands back the raw EIP-1193 provider, so it drops into viem's custom() transport — or into anything else that speaks the standard.

With the bundled UI

Both UI entries expose the same names and the same props — WalletKitProvider + slots + classNames, ConnectButton, WalletPicker — so a theme file and its call sites port between them unchanged.

// Preact
import { WalletKitProvider, ConnectButton, useWallet } from "icu-wallet-kit/preact";
import { pulsechain, pulsechainV4 } from "viem/chains";

useWallet({
  chains: [pulsechain.id, pulsechainV4.id],
  // Omit, or leave projectId blank, to ship injected-only — the row is then
  // never built, so the picker cannot offer an option that cannot work.
  walletConnect: { projectId: runtimeConfig.wcProjectId },
});

For a React app already built on wagmi, the React entry builds the wagmi config for you and the components read from it:

import { buildWagmiConfig, WalletKitProvider, ConnectButton } from "icu-wallet-kit";
import { pulsechain, pulsechainV4 } from "viem/chains";

export const wagmiConfig = buildWagmiConfig({
  chains: [pulsechain, pulsechainV4],
  rpcUrls: { [pulsechain.id]: ["https://rpc.pulsechain.com", "https://rpc-pulsechain.g4mm4.io"] },
  walletConnect: {
    // RUNTIME config, not a build-time env var — see below.
    projectId: window.__APP_CONFIG__?.wcProjectId,
    metadata: { name: "My App", description: "…", url: location.origin, icons: [] },
  },
});

Then wrap once, inside WagmiProvider, injecting your own components:

<WalletKitProvider
  slots={{ Button: MyButton, Modal: MyModal, Alert: MyAlert }}
  classNames={{ walletItem: "w-full justify-start gap-3" }}
>
  <App />
</WalletKitProvider>

<ConnectButton /> then drops in anywhere.

Connected-account menus accept host actions and a post-disconnect callback:

<ConnectButton
  accountMenuItems={[{ id: "sign-out", label: "Sign out", onSelect: signOut }]}
  onDisconnect={signOut}
/>

Theming

Two levers, and between them the kit can be made to look like anything:

  • slots — supply your own Button, Modal, and Alert. They receive plain props, so components from any UI library or hand-written equivalents drop straight in. The picker owns the error live region; Alert owns only its visual treatment.
  • classNames — per-element class hooks (connectButton, walletList, walletItem, walletName, walletIcon, walletBadge, walletConnectPanel, error, emptyState, …) for when you only need to restyle, not replace.

Each wallet row also carries wallet.installed (true for anything actually discovered live over EIP-6963, false for the WalletConnect entry) — the picker renders an "Installed" badge for it automatically, styled via classNames.walletBadge. The badge only appears when the list actually mixes installed and not-installed wallets, i.e. WalletConnect is configured and at least one wallet was also discovered — a label that's true on every row (no WalletConnect configured, or nothing discovered yet) conveys nothing, so the picker suppresses it rather than showing it unconditionally.

Omit both and you get unstyled defaults that work but assume nothing.

Connection errors are separated from the wallet choices in both layouts by var(--wallet-kit-notice-gap, 0.75rem). Override the custom property on the modal or [data-icu-wallet-notice] when a host needs a different stack gap.

For a fully custom UI, skip the components and use the headless hook:

const { wallets, connect, isPending, error } = useWalletPicker();

Layout: list or grid

<WalletPicker layout="grid" /> (or <ConnectButton layout="grid" />, which forwards it) switches the picker from its default single-column row list to a square-tile grid — a data-icu-wallet-layout="grid" attribute on the <ul> is the only thing this adds; layout="list" (the default) renders exactly as it always has, byte-for-byte.

The kit still ships no CSS of its own for either layout. For a ready-made grid look with zero CSS to write, import the optional companion stylesheet once:

import "icu-wallet-kit/styles/wallet-picker.css";

It styles layout="grid" only (square tiles, full wallet names wrapping onto a 2nd line rather than truncating, an auto-fill grid so a single wallet stays a fixed-size tile instead of stretching to fill the row) via a handful of --wallet-kit-* custom properties with sane fallbacks — list mode is untouched by this file too, so importing it can never change an existing list-mode consumer's look. Skip the import and style [data-icu-wallet-layout="grid"] yourself if you want full control instead.

WalletConnect

Supply projectId and the WalletConnect row appears; omit it and the connector is never registered.

Mobile / coarse-pointer contexts still fully delegate to WalletConnect's own modal (showQrModal: true) for QR and deeplinks — reimplementing its per-wallet deeplink registry is out of scope for this kit, so that part is unchanged.

Desktop contexts render the pairing QR inline instead, in the picker's own modal, themed via the same Button slot as everything else — no second, WalletConnect-branded popup on top of your app's UI. classNames.walletConnectPanel styles the wrapper; WalletPicker/ConnectButton need no extra prop for this, it activates automatically whenever a connect is waiting on a scan. The choice between the two is prefersDelegatedQrModal() (exported from every entry), based on matchMedia("(pointer: coarse)") — override it by building your own UI against the headless hook if you need different behavior.

Either way, WalletConnect (and its QR-rendering dependency) is loaded lazily on first use, so it costs nothing until a user actually picks it.

Get the ID at runtime, not at build time. A build-time VITE_* variable is compiled into the bundle, so it cannot differ per deployment and every operator self-hosting your app would be stuck with yours. It also fails quietly: an unset variable becomes an empty string, and a blank project ID produces a connector that dead-ends at the relay rather than an error you would notice.

Mobile coverage, for the record:

situation handled by
wallet's in-app browser the injected provider — already works, no WalletConnect needed
mobile Safari/Chrome WalletConnect modal's deeplinks
desktop + phone wallet (QR) WalletConnect — the pairing protocol is not something to reimplement

What this deliberately does not do

  • Chain switching UI. Consuming apps generally already have their own. The payload builder is exported: use buildAddEthereumChainParameter with switchChain({ addEthereumChainParameter }). It matters — a wallet asked to switch to a chain it has never seen can reject immediately and show the user nothing at all, so the add-chain fallback is effectively the whole flow, and what you hand it decides which RPCs the user ends up with.
  • ENS names and avatars. Opt in via resolveName. It is off by default because on forked chains the ENS registry is inherited at the canonical address, so lookups resolve to pre-fork names that may no longer belong to that address on the original chain — a wrong name is worse than none.
  • Recent-wallet memory, transaction history, balances. Out of scope.

Development

npm install
npm test          # vitest, jsdom
npm run typecheck
npm run build     # tsup → dist/ (ESM + d.ts) for both entries

dist/ is committed. The package is consumed as a git dependency, and a consumer installing with --ignore-scripts would never run prepare — so shipping the build output keeps the package usable without asking anyone to relax their install flags or put a build toolchain in their image. Rebuild and commit it alongside any source change.

License

MIT — see LICENSE.