Skip to main content

Overview

This guide shows you how to build a custom multi-chain bridging interface from scratch using Dynamic SDK hooks. You’ll learn how to:
  • Connect a source wallet on one chain (e.g., EVM, Solana, Bitcoin, Sui)
  • Connect a destination wallet on another chain
  • Select and switch between different chains
  • Manage multiple wallet connections simultaneously
  • Access wallet instances to perform bridge transactions

Setup

Configure your DynamicContextProvider with the required wallet connectors for all chains you want to support:
providers.tsx
import { DynamicContextProvider } from "@dynamic-labs/sdk-react-core";
import { EthereumWalletConnectors } from "@dynamic-labs/ethereum";
import { SolanaWalletConnectors } from "@dynamic-labs/solana";
import { BitcoinWalletConnectors } from "@dynamic-labs/bitcoin";
import { SuiWalletConnectors } from "@dynamic-labs/sui";
import { TronWalletConnectors } from "@dynamic-labs/tron";
import { AlgorandWalletConnectors } from "@dynamic-labs/algorand";
import { StarknetWalletConnectors } from "@dynamic-labs/starknet";
import { AptosWalletConnectors } from "@dynamic-labs/aptos";
import { CosmosWalletConnectors } from "@dynamic-labs/cosmos";
import { FlowWalletConnectors } from "@dynamic-labs/flow";

<DynamicContextProvider
  settings={{
    environmentId: "YOUR_ENV_ID",
    walletConnectors: [
      EthereumWalletConnectors, // EVM chains
      SolanaWalletConnectors, // Solana (SVM)
      BitcoinWalletConnectors, // Bitcoin
      SuiWalletConnectors, // Sui
      AptosWalletConnectors, // Aptos
      StarknetWalletConnectors, // Starknet
      AlgorandWalletConnectors, // Algorand
      CosmosWalletConnectors, // Cosmos
      FlowWalletConnectors, // Flow
      TronWalletConnectors, // Tron
    ],
  }}
>
  <App />
</DynamicContextProvider>
This setup configures the Dynamic SDK with all the wallet connectors needed for multi-chain bridging. Each connector enables support for its respective blockchain ecosystem, allowing users to connect wallets from different chains simultaneously.

Core Hooks

Use these Dynamic SDK hooks:
  • useDynamicContext - SDK state and wallet management (sdkHasLoaded, removeWallet, primaryWallet)
  • useUserWallets - Get all connected wallets
  • useSwitchWallet - Switch the primary wallet
  • useWalletOptions - Get available wallets and connect (selectWalletOption, getFilteredWalletOptions)
  • FilterChain - Filter wallets by chain type
  • ChainEnum - Chain type constants

Chain Constants

First, create a constants file to define chain display names and supported chains:
consts.ts
import { ChainEnum } from "./dynamic";

export type Chain = {
  id: ChainEnum;
  displayName: string;
};

export const CHAIN_DISPLAY_NAMES: Record<ChainEnum, string> = {
  [ChainEnum.Evm]: "EVM",
  [ChainEnum.Eth]: "Ethereum",
  [ChainEnum.Sol]: "Solana",
  [ChainEnum.Btc]: "Bitcoin",
  [ChainEnum.Sui]: "Sui",
  [ChainEnum.Tron]: "Tron",
  [ChainEnum.Algo]: "Algorand",
  [ChainEnum.Stark]: "Starknet",
  [ChainEnum.Cosmos]: "Cosmos",
  [ChainEnum.Flow]: "Flow",
  [ChainEnum.Aptos]: "Aptos",
  [ChainEnum.Spark]: "Spark",
  [ChainEnum.Eclipse]: "Eclipse",
  [ChainEnum.Ton]: "TON",
};

// Update CHAIN_DISPLAY_NAMES and SUPPORTED_CHAINS to customize which chains appear in the selector
export const SUPPORTED_CHAINS: Chain[] = [
  ChainEnum.Evm,
  ChainEnum.Sol,
  ChainEnum.Btc,
  ChainEnum.Sui,
  ChainEnum.Tron,
  ChainEnum.Algo,
  ChainEnum.Stark,
  ChainEnum.Cosmos,
  ChainEnum.Flow,
  ChainEnum.Aptos,
].map((chain) => ({
  id: chain,
  displayName: CHAIN_DISPLAY_NAMES[chain],
}));

Chain Selector Component

Create a chain selector component to allow users to choose source and destination chains:
ChainSelector.tsx
"use client";

import { FC } from "react";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ChevronDown } from "lucide-react";
import { ChainEnum } from "@/lib/dynamic";
import { CHAIN_DISPLAY_NAMES } from "@/lib/consts";
import type { Chain } from "@/lib/consts";

export type ChainSelectorProps = {
  selectedChain: ChainEnum | string;
  availableChains: Chain[];
  onSelectChain: (chainId: ChainEnum) => void;
  label: string;
  disabled?: boolean;
};

export const ChainSelector: FC<ChainSelectorProps> = ({
  selectedChain,
  availableChains,
  onSelectChain,
  label,
  disabled = false,
}) => {
  const selectedChainInfo = availableChains.find((c) => c.id === selectedChain);

  return (
    <div className="space-y-2">
      <div className="text-sm font-medium text-muted-foreground">{label}</div>
      <DropdownMenu>
        <DropdownMenuTrigger asChild disabled={disabled}>
          <button
            type="button"
            className="w-full text-sm flex items-center justify-between p-3 rounded-lg border hover:bg-muted transition-colors text-left cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
            disabled={disabled}
          >
            <div className="font-medium">
              {selectedChainInfo?.displayName ||
                CHAIN_DISPLAY_NAMES[selectedChain as ChainEnum] ||
                selectedChain}
            </div>
            <ChevronDown className="h-4 w-4 text-muted-foreground" />
          </button>
        </DropdownMenuTrigger>
        <DropdownMenuContent
          align="start"
          className="w-(--radix-dropdown-menu-trigger-width)"
        >
          {availableChains.map((chain) => (
            <DropdownMenuItem
              key={chain.id}
              onClick={() => onSelectChain(chain.id)}
              className={chain.id === selectedChain ? "bg-muted" : ""}
            >
              <div className="flex-1">
                <div className="font-medium">{chain.displayName}</div>
                {chain.id === selectedChain && (
                  <div className="text-xs text-muted-foreground">Current</div>
                )}
              </div>
            </DropdownMenuItem>
          ))}
        </DropdownMenuContent>
      </DropdownMenu>
    </div>
  );
};
The ChainSelector component provides a dropdown interface for selecting chains. It displays chain names from a predefined list and can be configured to filter available options, making it reusable for both source and destination chain selection. To customize which chains appear, update the CHAIN_DISPLAY_NAMES and SUPPORTED_CHAINS constants in src/lib/consts.ts.

Wallet Modal Component

Create a modal component for connecting wallets:
WalletModal.tsx
"use client";

import { FC, useMemo } from "react";
import { useWalletOptions, FilterChain } from "@dynamic-labs/sdk-react-core";
import { X } from "lucide-react";

export type WalletModalProps = {
  chain: string;
  isOpen: boolean;
  onClose: () => void;
  title?: string;
};

export const WalletModal: FC<WalletModalProps> = ({
  chain,
  isOpen,
  onClose,
  title,
}) => {
  const { selectWalletOption, getFilteredWalletOptions } = useWalletOptions();

  if (!isOpen) return null;

  const chainWallets = getFilteredWalletOptions(
    FilterChain(chain as Parameters<typeof FilterChain>[0])
  ).sort((a, b) => {
    // Sort installed wallets first
    if (a.isInstalledOnBrowser && !b.isInstalledOnBrowser) return -1;
    if (!a.isInstalledOnBrowser && b.isInstalledOnBrowser) return 1;
    return 0;
  });

  const handleWalletSelect = async (walletKey: string) => {
    await selectWalletOption(walletKey, true, true);
    onClose();
  };

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
      onClick={onClose}
    >
      <div
        className="bg-background rounded-lg shadow-lg p-6 max-w-md w-full max-h-[80vh] overflow-y-auto"
        onClick={(e) => e.stopPropagation()}
      >
        <div className="flex items-center justify-between mb-4">
          <h2 className="text-xl font-semibold">
            {title || `Connect ${chain} Wallet`}
          </h2>
          <button
            onClick={onClose}
            className="p-1 hover:bg-muted rounded cursor-pointer"
            type="button"
          >
            <X className="h-5 w-5" />
          </button>
        </div>

        <div className="space-y-2">
          {chainWallets.length === 0 ? (
            <p className="text-muted-foreground text-sm">
              No wallets available for {chain}
            </p>
          ) : (
            chainWallets.map((wallet) => (
              <button
                key={wallet.key}
                onClick={() => handleWalletSelect(wallet.key)}
                className="w-full flex items-center gap-3 p-3 rounded-lg border hover:bg-muted transition-colors text-left cursor-pointer"
                type="button"
              >
                {wallet.metadata.icon && (
                  <img
                    src={wallet.metadata.icon}
                    alt={wallet.name}
                    className="h-8 w-8 rounded"
                  />
                )}
                <div className="flex-1">
                  <div className="font-medium">{wallet.name}</div>
                  {wallet.isInstalledOnBrowser && (
                    <div className="text-xs text-muted-foreground">
                      Installed
                    </div>
                  )}
                </div>
              </button>
            ))
          )}
        </div>
      </div>
    </div>
  );
};
The WalletModal component displays available wallets for a specific chain when opened. It filters wallets by chain type using FilterChain, sorts them to show installed wallets first, and handles wallet connection via selectWalletOption. The modal closes automatically after a wallet is selected.

Complete Implementation

Here’s the complete bridge component that matches the real implementation:
HeadlessBridgeWidget.tsx
"use client";

import { FC, useCallback, useState, useMemo, useEffect } from "react";
import {
  useDynamicContext,
  useUserWallets,
  useSwitchWallet,
  ChainEnum,
} from "@dynamic-labs/sdk-react-core";
import { WalletModal } from "./WalletModal";
import { ChainSelector } from "./ChainSelector";
import { SUPPORTED_CHAINS } from "@/lib/consts";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
  DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { ChevronDown } from "lucide-react";

export const HeadlessBridgeWidget: FC<{
  className?: string;
  sourceChain?: ChainEnum | string;
  destinationChain?: ChainEnum | string;
  availableChains?: typeof SUPPORTED_CHAINS;
}> = ({
  className,
  sourceChain: initialSourceChain = ChainEnum.Evm,
  destinationChain: initialDestinationChain = ChainEnum.Sol,
  availableChains = SUPPORTED_CHAINS,
}) => {
  const { sdkHasLoaded, removeWallet, primaryWallet } = useDynamicContext();
  const connectedWallets = useUserWallets();
  const switchWallet = useSwitchWallet();
  const [sourceChain, setSourceChain] = useState<ChainEnum | string>(
    initialSourceChain
  );
  const [destinationChain, setDestinationChain] = useState<ChainEnum | string>(
    initialDestinationChain
  );
  const [selectedChain, setSelectedChain] = useState<string | null>(null);
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [isSwitching, setIsSwitching] = useState(false);
  const [selectedDestinationWalletId, setSelectedDestinationWalletId] =
    useState<string | null>(null);

  const sourceWallets = useMemo(() => {
    return connectedWallets.filter(({ chain }) => chain === sourceChain);
  }, [connectedWallets, sourceChain]);

  const destinationWallets = useMemo(() => {
    return connectedWallets.filter(({ chain }) => chain === destinationChain);
  }, [connectedWallets, destinationChain]);

  const sourceWallet = useMemo(() => {
    // Source chain wallet should always be the primary wallet if available
    if (primaryWallet && primaryWallet.chain === sourceChain) {
      return primaryWallet;
    }
    return sourceWallets[0];
  }, [sourceWallets, primaryWallet, sourceChain]);

  const destinationWallet = useMemo(() => {
    // For destination, use selected wallet or first available
    if (selectedDestinationWalletId) {
      const found = destinationWallets.find(
        (w) => w.id === selectedDestinationWalletId
      );
      if (found) return found;
    }
    return destinationWallets[0];
  }, [destinationWallets, selectedDestinationWalletId]);

  // Ensure source chain wallet is always primary when it changes
  useEffect(() => {
    if (sourceWallet && sourceWallet.id !== primaryWallet?.id) {
      switchWallet(sourceWallet.id).catch(() => {
        // Silently handle errors
      });
    }
  }, [sourceWallet, primaryWallet?.id, switchWallet]);

  const handleConnectSource = useCallback(() => {
    setSelectedChain(sourceChain);
    setIsSwitching(false);
    setIsModalOpen(true);
  }, [sourceChain]);

  const handleConnectDestination = useCallback(() => {
    setSelectedChain(destinationChain);
    setIsSwitching(false);
    setIsModalOpen(true);
  }, [destinationChain]);

  const handleSwitchSource = useCallback(() => {
    setSelectedChain(sourceChain);
    setIsSwitching(true);
    setIsModalOpen(true);
  }, [sourceChain]);

  const handleSwitchDestination = useCallback(() => {
    setSelectedChain(destinationChain);
    setIsSwitching(true);
    setIsModalOpen(true);
  }, [destinationChain]);

  const handleDisconnectSource = useCallback(() => {
    if (sourceWallet) {
      removeWallet(sourceWallet.id);
    }
  }, [sourceWallet, removeWallet]);

  const handleDisconnectDestination = useCallback(() => {
    if (destinationWallet) {
      removeWallet(destinationWallet.id);
    }
  }, [destinationWallet, removeWallet]);

  const handleSelectSourceWallet = useCallback(
    async (walletId: string) => {
      // Switching source wallet makes it the primary wallet
      await switchWallet(walletId);
    },
    [switchWallet]
  );

  const handleSelectDestinationWallet = useCallback((walletId: string) => {
    // For destination, just track which one to display (don't make it primary)
    setSelectedDestinationWalletId(walletId);
  }, []);

  const handleCloseModal = useCallback(() => {
    setIsModalOpen(false);
    setSelectedChain(null);
    setIsSwitching(false);
  }, []);

  const handleSourceChainChange = useCallback((chain: ChainEnum) => {
    setSourceChain(chain);
  }, []);

  const handleDestinationChainChange = useCallback((chain: ChainEnum) => {
    setDestinationChain(chain);
  }, []);

  // Filter available chains to exclude the other selected chain
  const availableSourceChains = useMemo(() => {
    return availableChains.filter((c) => c.id !== destinationChain);
  }, [availableChains, destinationChain]);

  const availableDestinationChains = useMemo(() => {
    return availableChains.filter((c) => c.id !== sourceChain);
  }, [availableChains, sourceChain]);

  const renderWalletDropdown = (
    chain: string,
    wallets: ReturnType<typeof useUserWallets>,
    currentWallet: ReturnType<typeof useUserWallets>[0] | undefined,
    onConnect: () => void,
    onSwitch: () => void,
    onDisconnect: () => void,
    onSelectWallet: (walletId: string) => void,
    canDisconnect: boolean
  ) => {
    if (!currentWallet) {
      return (
        <button
          onClick={onConnect}
          type="button"
          className="w-full px-4 py-2 bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors cursor-pointer"
        >
          Connect {chain}
        </button>
      );
    }

    const hasMultipleWallets = wallets.length > 1;

    return (
      <div className="space-y-2">
        {hasMultipleWallets ? (
          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <button
                type="button"
                className="w-full text-sm flex items-center justify-between p-3 rounded-lg border hover:bg-muted transition-colors text-left cursor-pointer"
              >
                <div className="flex-1">
                  <div className="font-medium">{chain}</div>
                  <div className="text-muted-foreground">
                    {currentWallet.address.slice(0, 6)}...
                    {currentWallet.address.slice(-4)}
                  </div>
                </div>
                <ChevronDown className="h-4 w-4 text-muted-foreground" />
              </button>
            </DropdownMenuTrigger>
            <DropdownMenuContent
              align="start"
              className="w-(--radix-dropdown-menu-trigger-width)"
            >
              {wallets.map((wallet) => (
                <DropdownMenuItem
                  key={wallet.id}
                  onClick={() => onSelectWallet(wallet.id)}
                  className={wallet.id === currentWallet?.id ? "bg-muted" : ""}
                >
                  <div className="flex-1">
                    <div className="font-medium">
                      {wallet.address.slice(0, 6)}...{wallet.address.slice(-4)}
                    </div>
                    {wallet.id === currentWallet?.id && (
                      <div className="text-xs text-muted-foreground">
                        Current
                      </div>
                    )}
                  </div>
                </DropdownMenuItem>
              ))}
              <DropdownMenuSeparator />
              <DropdownMenuItem onClick={onSwitch}>
                Connect Different Wallet
              </DropdownMenuItem>
              {canDisconnect && (
                <DropdownMenuItem onClick={onDisconnect} variant="destructive">
                  Disconnect
                </DropdownMenuItem>
              )}
            </DropdownMenuContent>
          </DropdownMenu>
        ) : (
          <div className="space-y-2">
            <div className="text-sm flex-1 p-3 rounded-lg border">
              <div className="font-medium">{chain}</div>
              <div className="text-muted-foreground">
                {currentWallet.address.slice(0, 6)}...
                {currentWallet.address.slice(-4)}
              </div>
            </div>
            <div className="flex gap-2">
              <button
                onClick={onSwitch}
                type="button"
                className="flex-1 text-xs text-primary hover:text-primary/80 px-2 py-1 rounded border border-primary/20 hover:border-primary/40 transition-colors cursor-pointer"
              >
                Connect Different Wallet
              </button>
              {canDisconnect && (
                <button
                  onClick={onDisconnect}
                  type="button"
                  className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded border border-red-500/20 hover:border-red-500/40 transition-colors cursor-pointer"
                >
                  Disconnect
                </button>
              )}
            </div>
          </div>
        )}
      </div>
    );
  };

  return (
    <>
      <div className={className} data-testid="headless-bridge-widget">
        <div className="space-y-4">
          {/* Source Chain */}
          <div className="space-y-2">
            <ChainSelector
              selectedChain={sourceChain}
              availableChains={availableSourceChains}
              onSelectChain={handleSourceChainChange}
              label="Source Chain"
            />
            {renderWalletDropdown(
              sourceChain,
              sourceWallets,
              sourceWallet,
              handleConnectSource,
              handleSwitchSource,
              handleDisconnectSource,
              handleSelectSourceWallet,
              connectedWallets.length > 1
            )}
          </div>

          {/* Destination Chain */}
          <div className="space-y-2">
            <ChainSelector
              selectedChain={destinationChain}
              availableChains={availableDestinationChains}
              onSelectChain={handleDestinationChainChange}
              label="Destination Chain"
            />
            {renderWalletDropdown(
              destinationChain,
              destinationWallets,
              destinationWallet,
              handleConnectDestination,
              handleSwitchDestination,
              handleDisconnectDestination,
              handleSelectDestinationWallet,
              connectedWallets.length > 1
            )}
          </div>
        </div>
      </div>
      {selectedChain && (
        <WalletModal
          chain={selectedChain}
          isOpen={isModalOpen}
          onClose={handleCloseModal}
          title={isSwitching ? `Switch ${selectedChain} Wallet` : undefined}
        />
      )}
    </>
  );
};
This is the main bridge widget component that orchestrates multi-chain wallet management. The component tracks source and destination chains, connected wallets, and modal state. It separates wallets by chain type for source and destination, ensuring the source wallet is always the primary wallet (for transaction signing), while destination wallets can be any connected wallet. The component prevents selecting the same chain for both source and destination, and conditionally renders either a connect button, a simple wallet display, or a dropdown menu based on wallet connection state and count. It automatically makes the source wallet primary when it changes via a useEffect hook. The component uses memoization (useMemo) for performance optimization and callbacks (useCallback) to prevent unnecessary re-renders.

Key Concepts

Primary Wallet vs Destination Wallet

  • Source Wallet: Should be the primary wallet (use switchWallet to set it). The source wallet is automatically set as primary when connected.
  • Destination Wallet: Can be any connected wallet (track with selectedDestinationWalletId). The destination wallet doesn’t need to be primary.

Chain Selection

The component prevents selecting the same chain for both source and destination:
const availableSourceChains = useMemo(() => {
  return availableChains.filter((c) => c.id !== destinationChain);
}, [availableChains, destinationChain]);
This filtering logic ensures users can’t select the same chain for both source and destination. The useMemo hook recalculates available chains only when the destination chain changes, preventing invalid bridge configurations.

Wallet Filtering

Filter wallets by chain using FilterChain:
const { getFilteredWalletOptions } = useWalletOptions();
const btcWallets = getFilteredWalletOptions(FilterChain(ChainEnum.Btc));
FilterChain is a utility function that creates a filter predicate for wallet options. When passed to getFilteredWalletOptions, it returns only wallets compatible with the specified chain type, making it easy to show chain-specific wallet options in your UI.

Connecting Wallets

Use selectWalletOption to connect a new wallet:
const { selectWalletOption } = useWalletOptions();
// selectWalletOption(key, createLinkedAccount, setAsPrimary)
await selectWalletOption(walletKey, true, true);
selectWalletOption connects a new wallet to the user’s account. The second parameter (createLinkedAccount: true) creates a linked account if needed, and the third parameter (setAsPrimary: true) makes the newly connected wallet the primary wallet, which is important for the source wallet in bridge transactions.

Accessing Wallet Instances

EVM Wallet

import { isEvmWallet } from "@dynamic-labs/ethereum";

if (isEvmWallet(sourceWallet)) {
  const address = sourceWallet.address;
  // Get viem wallet client for transactions
  const walletClient = await sourceWallet.getWalletClient();
}

Solana Wallet

import { isSolanaWallet } from "@dynamic-labs/solana";

if (isSolanaWallet(destinationWallet)) {
  const connection = await destinationWallet.getConnection();
  const publicKey = destinationWallet.publicKey;
}

Bitcoin Wallet

import { isBitcoinWallet } from "@dynamic-labs/bitcoin";

if (isBitcoinWallet(sourceWallet)) {
  const address = sourceWallet.address;
  // Additional Bitcoin wallet methods
}

Sui Wallet

import { isSuiWallet } from "@dynamic-labs/sui";

if (isSuiWallet(destinationWallet)) {
  const suiClient = await destinationWallet.getSuiClient();
  const walletAccount = await destinationWallet.getWalletAccount();
  const network = await destinationWallet.getActiveNetwork();
}
You can check out detailed information on interacting with different chains in the Wallets section.

Next Steps: Integrating Bridge Providers

Now that you have a custom multi-chain bridging UI set up, you can integrate with bridge providers to enable actual token transfers between chains. Check out our integration guides: These guides will show you how to use the wallet instances from your bridge widget to execute bridge transactions with various providers.

See Also