Published 12/4/2025 · 6 min read
Let’s build a proper wallet connection system in Svelte. We’ll create a reactive store and a reusable component that handles:
- Detecting installed wallets
- Connecting/disconnecting
- Auto-reconnecting on page load
- Displaying connection state
Coming from Vue, Svelte’s stores will feel familiar - they’re like Pinia but simpler.
The Wallet Store
First, create a store that manages wallet state:
// src/lib/stores/wallet.ts
import { writable, derived } from "svelte/store";
import { Connection, PublicKey, clusterApiUrl } from "@solana/web3.js";
import { browser } from "$app/environment";
// Types
interface WalletState {
connected: boolean;
connecting: boolean;
publicKey: PublicKey | null;
wallet: PhantomProvider | null;
}
interface PhantomProvider {
isPhantom: boolean;
isConnected: boolean;
publicKey: PublicKey | null;
connect(opts?: {
onlyIfTrusted?: boolean;
}): Promise<{ publicKey: PublicKey }>;
disconnect(): Promise<void>;
signTransaction(tx: any): Promise<any>;
signAllTransactions(txs: any[]): Promise<any[]>;
signMessage(message: Uint8Array): Promise<{ signature: Uint8Array }>;
on(event: string, callback: (...args: any[]) => void): void;
off(event: string, callback: (...args: any[]) => void): void;
}
// Helper to get Phantom
function getPhantom(): PhantomProvider | null {
if (!browser) return null;
const phantom = (window as any).phantom?.solana;
return phantom?.isPhantom ? phantom : null;
}
// Create the store
function createWalletStore() {
const { subscribe, set, update } = writable<WalletState>({
connected: false,
connecting: false,
publicKey: null,
wallet: null,
});
// Event handlers
function handleConnect(publicKey: PublicKey) {
update((state) => ({
...state,
connected: true,
connecting: false,
publicKey,
}));
}
function handleDisconnect() {
set({
connected: false,
connecting: false,
publicKey: null,
wallet: null,
});
}
function handleAccountChanged(publicKey: PublicKey | null) {
if (publicKey) {
update((state) => ({ ...state, publicKey }));
} else {
handleDisconnect();
}
}
return {
subscribe,
// Initialize - call on app mount
init() {
if (!browser) return;
const phantom = getPhantom();
if (!phantom) return;
// Set up listeners
phantom.on("connect", handleConnect);
phantom.on("disconnect", handleDisconnect);
phantom.on("accountChanged", handleAccountChanged);
// Check if already connected
if (phantom.isConnected && phantom.publicKey) {
set({
connected: true,
connecting: false,
publicKey: phantom.publicKey,
wallet: phantom,
});
}
},
// Connect with popup
async connect() {
const phantom = getPhantom();
if (!phantom) {
window.open("https://phantom.app/", "_blank");
return;
}
update((state) => ({ ...state, connecting: true }));
try {
const { publicKey } = await phantom.connect();
set({
connected: true,
connecting: false,
publicKey,
wallet: phantom,
});
} catch (err) {
update((state) => ({ ...state, connecting: false }));
console.error("Failed to connect:", err);
}
},
// Try to reconnect silently
async reconnect() {
const phantom = getPhantom();
if (!phantom) return;
try {
const { publicKey } = await phantom.connect({ onlyIfTrusted: true });
set({
connected: true,
connecting: false,
publicKey,
wallet: phantom,
});
} catch {
// User hasn't approved - that's fine
}
},
// Disconnect
async disconnect() {
const phantom = getPhantom();
if (phantom) {
await phantom.disconnect();
}
handleDisconnect();
},
// Check if Phantom is installed
isPhantomInstalled() {
return getPhantom() !== null;
},
};
}
export const wallet = createWalletStore();
// Derived stores for convenience
export const connected = derived(wallet, ($wallet) => $wallet.connected);
export const publicKey = derived(wallet, ($wallet) => $wallet.publicKey);
export const walletAddress = derived(
wallet,
($wallet) => $wallet.publicKey?.toBase58() ?? null
);
// Connection store
export const connection = derived(
wallet,
() => new Connection(clusterApiUrl("devnet"), "confirmed")
);
The Wallet Button Component
Now create a reusable button component:
<!-- src/lib/components/WalletButton.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import { wallet, walletAddress } from '$lib/stores/wallet';
// Truncate address for display
function truncate(address: string): string {
return `${address.slice(0, 4)}...${address.slice(-4)}`;
}
onMount(() => {
wallet.init();
wallet.reconnect();
});
</script>
<div class="wallet-button">
{#if $wallet.connected && $walletAddress}
<div class="connected">
<span class="address">{truncate($walletAddress)}</span>
<button class="disconnect" on:click={() => wallet.disconnect()}>
Disconnect
</button>
</div>
{:else}
<button
class="connect"
on:click={() => wallet.connect()}
disabled={$wallet.connecting}
>
{#if $wallet.connecting}
Connecting...
{:else}
Connect Wallet
{/if}
</button>
{/if}
</div>
<style>
.wallet-button {
display: inline-block;
}
.connect {
background: linear-gradient(135deg, #9945FF 0%, #14F195 100%);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
}
.connect:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.connect:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.connected {
display: flex;
align-items: center;
gap: 0.75rem;
background: #1a1a2e;
padding: 0.5rem 0.75rem 0.5rem 1rem;
border-radius: 0.5rem;
border: 1px solid #333;
}
.address {
font-family: 'SF Mono', Monaco, monospace;
color: #14F195;
font-size: 0.9rem;
}
.disconnect {
background: transparent;
color: #888;
border: 1px solid #444;
padding: 0.4rem 0.75rem;
border-radius: 0.25rem;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
}
.disconnect:hover {
color: #fff;
border-color: #666;
}
</style>
Using the Component
In your page or layout:
<!-- src/routes/+page.svelte -->
<script>
import WalletButton from '$lib/components/WalletButton.svelte';
import { wallet, walletAddress } from '$lib/stores/wallet';
</script>
<header>
<h1>My Solana App</h1>
<WalletButton />
</header>
<main>
{#if $wallet.connected}
<p>Welcome! Your address is {$walletAddress}</p>
<!-- Show authenticated content -->
{:else}
<p>Please connect your wallet to continue.</p>
{/if}
</main>
Adding Balance Display
Let’s extend the component to show SOL balance:
<!-- src/lib/components/WalletButton.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import { wallet, walletAddress, connection } from '$lib/stores/wallet';
import { LAMPORTS_PER_SOL } from '@solana/web3.js';
let balance: number | null = null;
function truncate(address: string): string {
return `${address.slice(0, 4)}...${address.slice(-4)}`;
}
async function fetchBalance() {
if (!$wallet.publicKey) {
balance = null;
return;
}
try {
const lamports = await $connection.getBalance($wallet.publicKey);
balance = lamports / LAMPORTS_PER_SOL;
} catch (err) {
console.error('Failed to fetch balance:', err);
balance = null;
}
}
// Fetch balance when connected
$: if ($wallet.connected) {
fetchBalance();
} else {
balance = null;
}
onMount(() => {
wallet.init();
wallet.reconnect();
});
</script>
<div class="wallet-button">
{#if $wallet.connected && $walletAddress}
<div class="connected">
{#if balance !== null}
<span class="balance">{balance.toFixed(2)} SOL</span>
{/if}
<span class="address">{truncate($walletAddress)}</span>
<button class="disconnect" on:click={() => wallet.disconnect()}>
×
</button>
</div>
{:else}
<button
class="connect"
on:click={() => wallet.connect()}
disabled={$wallet.connecting}
>
{$wallet.connecting ? 'Connecting...' : 'Connect Wallet'}
</button>
{/if}
</div>
<style>
/* ... previous styles ... */
.balance {
color: #888;
font-size: 0.85rem;
padding-right: 0.5rem;
border-right: 1px solid #333;
}
</style>
Comparison to Vue
If you’re coming from Vue, here’s how Svelte compares:
| Vue 3 | Svelte |
|---|---|
ref(false) | writable(false) |
computed(() => ...) | derived(store, ...) |
watch(source, callback) | $: if (condition) { ... } |
onMounted(() => ...) | onMount(() => ...) |
<template v-if=""> | {#if }{/if} |
:class="{ active }" | class:active |
@click="handler" | on:click={handler} |
The mental model is very similar. Svelte just has less boilerplate.
Handling Multiple Wallets
To support multiple wallets (Phantom, Solflare, etc.), you’d extend the store:
// Detect all available wallets
function getAvailableWallets() {
const wallets = [];
if ((window as any).phantom?.solana?.isPhantom) {
wallets.push({ name: "Phantom", provider: (window as any).phantom.solana });
}
if ((window as any).solflare?.isSolflare) {
wallets.push({ name: "Solflare", provider: (window as any).solflare });
}
// Add more wallets...
return wallets;
}
For production apps, consider using the @solana/wallet-adapter libraries which handle all of this.
What You Learned
- How to create a Svelte store for wallet state
- Building a reusable wallet button component
- Auto-reconnecting on page load
- Fetching and displaying balances
- Reactive updates with
$:syntax
Next Up
We can connect wallets. Now let’s use them - signing messages and transactions in the browser.
Related Articles
- Form Handling: Moving from Vue to Svelte
A practical guide to translating Vue form patterns to Svelte, covering two-way binding, validation, async submission, and what actually works better in each framework.
- Building a Modal: Vue vs Svelte
A side-by-side comparison of building a modal component in Vue 3 and Svelte 5, exploring the differences in reactivity, props, and component patterns.