The connect layer for serious dApps. Open source and MIT-licensed, built on wagmi v3 + viem.
Website · Docs · Playground · Compatibility · Benchmarks · X @wylletdev · support@wyllet.dev
pnpm add wyllet wagmi viem @tanstack/react-query
# Only if you enable WalletConnect (QR + mobile wallets) via `walletConnectProjectId`:
pnpm add @walletconnect/ethereum-providerZero direct runtime dependencies. Wyllet only uses the libraries your dApp already has (react, wagmi, viem, react-query), as peer dependencies. Even the QR code encoder is built in. WalletConnect is opt-in, so you choose and pin its version. A test in CI fails if anything else ever sneaks into the bundle.
Network requests: Wyllet makes none of its own, with one opt-in exception. When WalletConnect is enabled and a user opens All wallets, it loads the wallet list and logos from WalletConnect's registry (explorer-api.walletconnect.com), the same service your WalletConnect connections already use. Turn it off with appearance: { walletDirectory: false }.
Wyllet replaces the usual "connect modal + account dropdown" with a single system:
| Wallet Island | One morphing button: a call to action when disconnected; identity, network badge, balance and a live status dot when connected |
| Panel presentations | drawer (floating side panel, the default), popover (anchored to the button), or modal. Draggable bottom sheet on phones |
| Keyboard-first connect | Optional ⌘K shortcut (shortcut="mod+k"), ↑ ↓ Enter, number keys 1–9 to pick a wallet, one-tap Continue with {last wallet} |
| Identity card (optional) | identityCard: true swaps the compact account summary for a generative holographic card unique to every address, with 3D tilt |
| Chains & tokens in code | popularChains presets, customChain() for any EVM chain, your own token lists (pinned, chainOrder, tokenSort), built-in token and chain icons |
| Activity timeline | Transactions (live pending → confirmed), connects, network switches, sign-ins and your own entries |
| Networks | Inline chain grid with live gas price and a wrong-network state |
| Receive | A QR code for the user's own address |
| Island toasts | Themed, promise-aware notifications |
| SIWE | Plug in an adapter; the verify step is built into the connect flow |
<ConnectGate> |
Gate any UI on connected + supported network + signed in |
| Themes | Obsidian (signature mint), Porcelain, Aurora, Terminal. Every token is a CSS variable, plus per-element classNames |
| Burner wallet | An in-browser dev wallet for local testing and demos |
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { WagmiProvider } from 'wagmi'
import { base, mainnet } from 'viem/chains'
import { WylletButton, WylletProvider, getDefaultConfig } from 'wyllet'
const config = getDefaultConfig({
appName: 'My dApp',
walletConnectProjectId: 'YOUR_PROJECT_ID', // free at https://cloud.reown.com
chains: [mainnet, base],
})
const queryClient = new QueryClient()
export function App() {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<WylletProvider shortcut="mod+k">
<WylletButton />
</WylletProvider>
</QueryClientProvider>
</WagmiProvider>
)
}That's the whole integration. Styles are injected for you, so there's no CSS import.
Next.js App Router: put the providers in a
'use client'component. Wyllet ships with the directive.
<WylletProvider
appearance={{
presentation: 'drawer', // 'drawer' | 'popover' | 'modal'
drawerSide: 'right', // 'left' | 'right'
mobileSheet: true, // bottom sheet on phones
identityCard: false, // true → holographic card instead of the compact summary
cardTilt: true, // 3D tilt on the identity card
accountTabs: ['tokens', 'activity', 'networks'],
closeOnConnect: true, // false → go straight to the account panel
tokenSort: 'balance', // 'balance' | 'list'
walletSearch: 'auto', // true | false | 'auto' (6+ wallets)
walletDirectory: true, // "All wallets" via the WalletConnect registry (needs a project id)
toastPosition: 'top-center',
branding: true, // “Powered by Wyllet” footer badge
disableAnimations: false, // prefers-reduced-motion is always respected
}}
/>popover anchors to whatever element opened it. Pass the element when you open the panel yourself, e.g. open(e.currentTarget). Without an anchor, the popover falls back to a modal.
import { auroraTheme, obsidianTheme, porcelainTheme, terminalTheme } from 'wyllet'
<WylletProvider theme={obsidianTheme({ accentColor: '#C6F432', radius: 'round' })} />
// Light/dark pair following the OS (this is the default)
<WylletProvider theme={{ light: porcelainTheme(), dark: obsidianTheme() }} colorMode="auto" />Preset knobs: accentColor, accentColorForeground, accentSecondary (used in gradients), radius (sharp | soft | round | pill), fontFamily, overlayBlur (none | subtle | heavy) and overrides (a deep-partial theme).
Every token is a CSS variable, such as --wy-colors-accent or --wy-radii-panel. You can also add classes per element:
<WylletProvider classNames={{ panel: 'ring-1 ring-white/10', walletRow: 'hover:translate-x-1' }} />Wyllet's own rules are unlayered, so a utility that sets a property Wyllet already sets (such as font-family on the button) needs Tailwind's ! modifier, or a theme token instead.
Slots: overlay, panel, island, walletRow, primaryButton, identityCard, toast.
Wyllet works under a CSP without 'unsafe-inline', in either of two ways.
With a nonce (Next.js and most server-rendered apps): pass the response's nonce, and Wyllet adds it to the styles it injects.
<WylletProvider nonce={nonce} />
// Content-Security-Policy: style-src 'self' 'nonce-<nonce>'Without nonces (static sites): load the stylesheet and a theme as files, and turn injection off.
import 'wyllet/styles.css'
import 'wyllet/themes/default.css' // or obsidian, porcelain, aurora, terminal
<WylletProvider injectStyles={false} injectTheme={false} />
// Content-Security-Policy: style-src 'self'For a custom theme, generate its file with themeStylesheet(myTheme) at build time. Server-rendered markup never relies on inline style attributes, which a strict policy would block.
Wyllet loads no scripts and no fonts of its own. The rest of your policy only needs what wallets use: img-src with data: and https: (wallet and chain icons, ENS avatars), and, with WalletConnect, connect-src for https: and wss: and frame-src https://verify.walletconnect.org https://verify.walletconnect.com. Both setups are tested in CI under a strict policy in Chrome, Firefox and Safari.
<WylletButton size="lg" display="compact" label="Enter app" />
// display: 'full' (avatar + name + balance) | 'compact' | 'avatar'<WylletEmbed /> // the real panel inline, e.g. a connect card on a sign-in pageconst { mounted, account, chain, open } = useWylletButton()
<button onClick={(e) => open(e.currentTarget)}>
{account ? account.displayName : 'Connect'}
</button>// Control the panel
const { open, close, openConnect, openAccount, openTab, view, isConnected } = useWyllet()
openTab('activity')
// Activity timeline + transaction tracking
const { activity, addTransaction, log, clearActivity } = useWylletActivity()
const hash = await writeContractAsync({ ... })
addTransaction({ hash, description: 'Mint 1 NFT' }) // live toast + timeline entry, resumes after reload
log({ title: 'Staked 10 ETH', status: 'success' }) // your own entries
// Toasts
const toast = useWylletToast()
toast.success('Saved')
await toast.promise(bridge(), { loading: 'Bridging…', success: 'Bridged!', error: 'Bridge failed' })
// SIWE status
const { status, signIn, signOut } = useWylletAuth()<ConnectGate>
<Dashboard /> {/* rendered only when connected, on a supported chain, and signed in */}
</ConnectGate>
<ConnectGate requireAuth={false} fallback={(reason) => <MyPrompt reason={reason} />}>…</ConnectGate>const auth: WylletAuthAdapter = {
getNonce: () => fetch('/api/nonce').then((r) => r.text()),
verify: ({ message, signature }) =>
fetch('/api/verify', { method: 'POST', body: JSON.stringify({ message, signature }) }).then((r) => r.ok),
signOut: () => fetch('/api/logout', { method: 'POST' }).then(() => {}),
getSession: () => fetch('/api/me').then((r) => (r.ok ? r.json() : null)), // optional: restore on load
}
<WylletProvider auth={auth} />Messages are EIP-4361, built with viem/siwe; pass createMessage to customize them. Switching accounts or disconnecting signs the user out.
Networks and tokens are defined in code by the dApp, so every user sees the same curated set, in the order you choose.
import { sepolia } from 'viem/chains'
import { customChain, getDefaultConfig, popularChains, popularTestnets } from 'wyllet'
// Any EVM chain in one call: your L2, an appchain, a devnet.
const myChain = customChain({
id: 424242,
name: 'My Chain',
rpcUrl: 'https://rpc.mychain.xyz',
explorerUrl: 'https://scan.mychain.xyz',
nativeCurrency: { name: 'My Token', symbol: 'MYT', decimals: 18 },
icon: '/mychain.svg', // or `color: '#FF5500'` for a generated monogram
nativeIcon: '/myt.svg', // native currency icon in the Tokens tab
})
getDefaultConfig({
appName: 'My dApp',
chains: [myChain, ...popularChains, sepolia], // order = priority; the first chain is the default
})popularChains: Ethereum, Base, OP, Arbitrum, Polygon, BNB, Avalanche, zkSync, Linea, Scroll (built-in official icons for all except BNB, which shows a monogram; pass your own withchainIcons)popularTestnets: Sepolia, Base Sepolia, OP Sepolia, Arbitrum Sepolia, Polygon Amoy- Wallets that don't know your chain get an "add network" prompt automatically on first switch.
Arrange the Networks tab separately from the default chain:
<WylletProvider chainOrder={[8453, 424242]} /> // Base, then My Chain, then the rest in config orderimport { withDefaultTokens } from 'wyllet'
const tokens = withDefaultTokens([
{ chainId: 8453, address: '0x…', symbol: 'MYTKN', decimals: 18, icon: '/mytkn.svg', pinned: true },
{ chainId: 424242, address: '0x…', symbol: 'USDC', decimals: 6 },
])
<WylletProvider tokens={tokens} />tokens="default" |
Blue chips on Ethereum, Base, OP, Arbitrum and Polygon (the default) |
tokens={[...]} |
Only your tokens: a flat list with chainId on each entry, or a { [chainId]: Token[] } map |
withDefaultTokens([...]) |
Your tokens first, then the blue chips, with duplicates removed |
tokens={false} |
Native balance only |
Priority: list order is the display order, and pinned: true keeps a token at the top. appearance.tokenSort decides the rest:
'balance' (default) puts pinned tokens first, then held tokens, then the rest, each group in your list order. 'list' puts pinned tokens first, then keeps exactly your list order.
Optional: allowTokenImport lets each user add extra ERC-20s by pasting a contract address. Wyllet reads the name, symbol and decimals onchain, saves them per browser, and users can remove them later. Every listed ERC-20 also gets an "Add to wallet" button.
const { tokens, importToken, removeToken, canImport } = useWylletTokens() // connected chain (or pass a chainId): your list, then user imports
const { chains } = useWylletChains() // ordered by chainOrder
<TokenIcon symbol="USDC" chainId={8453} size={28} />
<ChainIcon chainId={424242} />import { burnerWallet, metaMaskWallet, phantomWallet, walletConnectWallet } from 'wyllet'
getDefaultConfig({ ..., wallets: [metaMaskWallet(), phantomWallet(), walletConnectWallet(), burnerWallet()] })defaultWallets() = MetaMask, Phantom, Rabby, OKX and WalletConnect, each with its official icon from the wallet's own brand kit.
Any other installed browser wallet is detected automatically (EIP-6963) and shown with the icon it announces itself.
Also available, without bundled artwork (they show a neutral placeholder unless installed). Pass the official icon yourself:
coinbaseWallet({ overrides: { icon: '/brand/coinbase-wallet.svg' } }) // also rainbowWallet(), trustWallet()Dev only: burnerWallet() (in-browser key in localStorage, warns if used on a deployed site).
How a wallet connects when picked:
- If its extension is installed (EIP-6963
rdnsmatch), Wyllet connects directly. - If it has a dedicated connector, Wyllet connects directly.
- If it supports WalletConnect, Wyllet shows a QR code on desktop or a deep link on mobile.
- Otherwise, Wyllet shows a "Get {wallet}" screen with install links (wallets with no way to connect and no download links are left out).
Installed wallets you didn't list still appear under Detected.
With a walletConnectProjectId, the WalletConnect option becomes All wallets: a searchable grid of ~570 wallets from the WalletConnect registry, with each wallet's official logo and its own app link.
- Desktop: picking a wallet shows a QR code branded with its logo, to scan from inside that wallet's app.
- Phone: picking a wallet opens that exact app. A bare WalletConnect link would only open the phone's default wallet.
- Installed: if the wallet's browser extension is installed, it connects directly.
Using your own createConfig? Pass walletConnectProjectId to <WylletProvider> as well.
// Custom wallet
const acme: Wallet = {
id: 'acme',
name: 'Acme',
icon: 'https://acme.xyz/icon.png',
rdns: 'xyz.acme',
walletConnect: { mobileUri: (uri) => `acme://wc?uri=${encodeURIComponent(uri)}` },
downloadUrls: { browserExtension: 'https://acme.xyz/download' },
}
// Swap how a preset connects
import { coinbaseWallet as coinbaseSdk } from 'wagmi/connectors'
coinbaseWallet({ connector: () => coinbaseSdk({ appName: 'My dApp' }) })Using your own createConfig? Pass wallets and appInfo to <WylletProvider>, include walletConnect({ showQrModal: false, … }) for QR support, and add burner() to your connectors if you use burnerWallet().
<WylletProvider messages={{ connect: 'Conectar', connectTo: 'Conectar a {app}' }} />Every key is in the exported en object.
| Prop | |
|---|---|
theme / colorMode |
A theme or { light, dark } pair; 'auto' | 'light' | 'dark' |
appearance |
Presentation and behavior (see above) |
shortcut |
e.g. 'mod+k', toggles the panel |
tokens |
'default' | false | TokenList | TokenEntry[], shown to everyone |
chainOrder |
Chain ids shown first in the Networks tab |
walletConnectProjectId |
For hand-written wagmi configs (taken from getDefaultConfig otherwise) |
allowTokenImport |
Let users import their own tokens (default false) |
auth |
SIWE adapter |
wallets, appInfo |
Override what getDefaultConfig registered |
messages, classNames, chainIcons, avatar, disclaimer |
Copy and visuals. The terms notice shows when appInfo has both termsUrl and privacyUrl |
ens |
Resolve ENS names and avatars (default true; needs mainnet) |
injectStyles |
false to load wyllet/styles.css yourself |
injectTheme |
false to load a wyllet/themes/*.css file (or themeStylesheet() output) yourself; theme and colorMode then have no effect |
nonce |
CSP nonce added to the <style> elements Wyllet injects |
onConnect / onDisconnect |
Lifecycle callbacks |
pnpm install
pnpm dev # playground at http://localhost:5173 (hot-reloads library source)
pnpm test # vitest
pnpm build # packages/wyllet → dist (ESM + CJS + types + styles.css + themes)
pnpm typecheck
pnpm test:e2e # Playwright: Chrome, Firefox, Safari and phone profiles, strict CSP, scripted wallet
pnpm bench # bundle sizes (with budget) and loading timings, see BENCHMARKS.mdSet VITE_WC_PROJECT_ID in apps/playground/.env.local to enable QR and mobile wallets. The burner wallet works with no setup.
packages/wyllet/src
├── activity/ activity timeline + transaction watcher
├── auth/ SIWE adapter types + controller
├── chains/ brand marks, popularChains, customChain()
├── components/ WylletButton, ConnectGate, TokenIcon, ChainIcon, Avatar, Toaster
│ └── Panel/ Surface (drawer/popover/modal/sheet), ConnectView, AccountView, IdentityCard, tabs
├── config/ getDefaultConfig + wallet/app registry
├── core/ WylletProvider, context, tiny external store
├── hooks/ public hooks + wallet resolution
├── i18n/ default strings
├── styles/ base stylesheet (driven entirely by CSS variables)
├── theme/ presets, createTheme, CSS variable generation
├── toast/ toast store
├── tokens/ default lists, withDefaultTokens, user-imported tokens, icons
├── utils/ formatting, shortcuts, storage
└── wallets/ wallet presets, icons, burner connector
MIT
Wallet, chain and token names and logos are trademarks of their respective owners and are used only to identify supported wallets, networks and tokens. Bundled artwork comes from each project's official brand kit; see packages/wyllet/assets/SOURCES.md.

