@octanejs/rainbowkit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dominic Gannaway
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # @octanejs/rainbowkit
2
+
3
+ An Octane-native wallet connection UI preserving RainbowKit's defining provider,
4
+ connect-control, modal-hook, and theme contracts.
5
+
6
+ ```bash
7
+ pnpm add @octanejs/rainbowkit @octanejs/wagmi @octanejs/tanstack-query viem
8
+ ```
9
+
10
+ ```tsrx
11
+ import { QueryClient, QueryClientProvider } from '@octanejs/tanstack-query';
12
+ import { WagmiProvider, createConfig, http } from '@octanejs/wagmi';
13
+ import { injected } from '@octanejs/wagmi/connectors';
14
+ import { mainnet } from '@octanejs/wagmi/chains';
15
+ import {
16
+ ConnectButton,
17
+ RainbowKitProvider,
18
+ lightTheme,
19
+ } from '@octanejs/rainbowkit';
20
+ import '@octanejs/rainbowkit/styles.css';
21
+
22
+ const config = createConfig({
23
+ chains: [mainnet],
24
+ connectors: [injected()],
25
+ transports: { [mainnet.id]: http() },
26
+ });
27
+ const queryClient = new QueryClient();
28
+
29
+ function App() @{
30
+ <WagmiProvider config={config}>
31
+ <QueryClientProvider client={queryClient}>
32
+ <RainbowKitProvider theme={lightTheme()}>
33
+ <ConnectButton />
34
+ </RainbowKitProvider>
35
+ </QueryClientProvider>
36
+ </WagmiProvider>
37
+ }
38
+ ```
39
+
40
+ ## Important Wagmi divergence
41
+
42
+ This package is a compatibility adapter for RainbowKit 2.2.11's public UI
43
+ contracts, not a repackaging of its React implementation. Upstream RainbowKit
44
+ 2.2.11 declares `wagmi ^2.9.0`; this package intentionally targets
45
+ `@octanejs/wagmi` v3. It does not satisfy upstream RainbowKit's peer range and
46
+ must not be presented as drop-in dependency parity.
47
+
48
+ The adapter derives the familiar disconnected, connecting, connected, wrong
49
+ network, and modal-open states directly from Wagmi v3. The supported cohort is
50
+ `RainbowKitProvider`, `ConnectButton`, `ConnectButton.Custom`, `WalletButton`,
51
+ `useConnectModal`, `useAccountModal`, `useChainModal`, and the familiar light,
52
+ dark, and midnight theme factories. `rainbowTheme` is an explicitly documented
53
+ Octane-only purple/rounded preset.
54
+
55
+ ## Wallet catalogue and accessibility
56
+
57
+ Wallet choices come from the connectors configured on the enclosing
58
+ `WagmiProvider`. Applications configure injected/EIP-6963 and WalletConnect
59
+ connectors themselves, including a WalletConnect project id. Pass `wallets` to
60
+ `RainbowKitProvider` to order named entries or explain a configured wallet that
61
+ is unavailable:
62
+
63
+ ```tsrx
64
+ <RainbowKitProvider
65
+ wallets={[
66
+ { id: 'injected', name: 'Browser wallet' },
67
+ {
68
+ id: 'walletConnect',
69
+ name: 'WalletConnect',
70
+ unavailableReason: 'WalletConnect projectId is required.',
71
+ },
72
+ ]}
73
+ >
74
+ <ConnectButton />
75
+ </RainbowKitProvider>
76
+ ```
77
+
78
+ Entries match the canonical connector `uid` when `connectorUid` is supplied,
79
+ with explicit id/name matching as the convenience fallback. They are
80
+ deduplicated by that canonical identity while preserving configured/discovery
81
+ order.
82
+
83
+ The native dialog has an accessible name and description, initial focus, tab
84
+ containment, Escape and outside dismissal, opener focus restoration, live
85
+ status messages, and document-scoped scroll/background containment. The
86
+ included stylesheet provides the baseline responsive, touch-target, and
87
+ reduced-motion presentation; applications remain responsible for validating
88
+ their final composed CSS.
89
+
90
+ ## Deliberately unsupported
91
+
92
+ The first cohort omits RainbowKit's wallet factory catalogue, authentication
93
+ adapter, recent transactions, ENS/avatar resolution, cool mode, locale
94
+ translations, pixel-identical themes, account avatars/balances, and React-only
95
+ internals. Unsupported upstream props are not accepted by the public types.
96
+ Supply vendor connector SDKs directly through Wagmi.
97
+
98
+ SSR emits the deterministic disconnected control. Live connector state remains
99
+ authoritative after hydration; persisted state never authorizes wallet actions.
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@octanejs/rainbowkit",
3
+ "version": "0.0.1",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "octane": {
10
+ "hookSlots": {
11
+ "manual": [
12
+ "src"
13
+ ]
14
+ }
15
+ },
16
+ "description": "Octane-native RainbowKit connection UI contracts over @octanejs/wagmi v3.",
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/octanejs/octane.git",
23
+ "directory": "packages/rainbowkit"
24
+ },
25
+ "main": "src/index.ts",
26
+ "module": "src/index.ts",
27
+ "types": "src/index.ts",
28
+ "files": [
29
+ "src",
30
+ "README.md",
31
+ "status.json"
32
+ ],
33
+ "exports": {
34
+ ".": "./src/index.ts",
35
+ "./styles.css": "./src/styles.css"
36
+ },
37
+ "dependencies": {
38
+ "@wagmi/core": "3.6.4",
39
+ "@octanejs/wagmi": "0.0.1"
40
+ },
41
+ "peerDependencies": {
42
+ "@octanejs/tanstack-query": "0.1.20",
43
+ "octane": "0.1.21"
44
+ },
45
+ "devDependencies": {
46
+ "@testing-library/jest-dom": "^6.9.1",
47
+ "@tsrx/react": "^0.2.56",
48
+ "@wagmi/connectors": "8.0.25",
49
+ "react": "^19.2.7",
50
+ "react-dom": "^19.2.7",
51
+ "viem": "2.55.10",
52
+ "vitest": "^4.1.10",
53
+ "octane": "0.1.21",
54
+ "@octanejs/tanstack-query": "0.1.20"
55
+ },
56
+ "scripts": {
57
+ "test": "vitest run --root ../.. --project rainbowkit --project rainbowkit-ssr"
58
+ }
59
+ }
@@ -0,0 +1,117 @@
1
+ import { useChains, useConnect, useConnection, useDisconnect } from '@octanejs/wagmi';
2
+ import { isChildrenBlock, useContext, type OctaneNode } from 'octane';
3
+ import { useAccountModal, useChainModal, useConnectModal } from './hooks';
4
+ import { RainbowKitContext } from './internal';
5
+
6
+ export type ConnectButtonRendererProps = {
7
+ account?: { address: string; displayName: string };
8
+ accountModalOpen: boolean;
9
+ authenticationStatus?: undefined;
10
+ chain?: { id: number; name?: string; unsupported: boolean };
11
+ chainModalOpen: boolean;
12
+ connectModalOpen: boolean;
13
+ connectionStatus: 'connected' | 'connecting' | 'disconnected';
14
+ mounted: boolean;
15
+ openAccountModal?: (event?: Event) => void;
16
+ openChainModal?: (event?: Event) => void;
17
+ openConnectModal?: (event?: Event) => void;
18
+ };
19
+
20
+ export type ConnectButtonProps = {
21
+ chainStatus?: 'name' | 'none';
22
+ label?: string;
23
+ };
24
+
25
+ function formatAddress(address: string): string {
26
+ return `${address.slice(0, 6)}…${address.slice(-4)}`;
27
+ }
28
+
29
+ function useConnectButtonState(): ConnectButtonRendererProps {
30
+ const connection = useConnection();
31
+ const connect = useConnect();
32
+ const rainbowKit = useContext(RainbowKitContext);
33
+ const chains = useChains();
34
+ const accountModal = useAccountModal();
35
+ const chainModal = useChainModal();
36
+ const connectModal = useConnectModal();
37
+ const chain = chains.find((candidate) => candidate.id === connection.chainId);
38
+ const connected = connection.status === 'connected' && Boolean(connection.address);
39
+ const mounted = rainbowKit?.mounted ?? false;
40
+ return {
41
+ account: mounted && connected
42
+ ? {
43
+ address: connection.address!,
44
+ displayName: formatAddress(connection.address!),
45
+ }
46
+ : undefined,
47
+ accountModalOpen: accountModal.accountModalOpen,
48
+ authenticationStatus: undefined,
49
+ chain: mounted && connected
50
+ ? { id: connection.chainId!, name: chain?.name, unsupported: chain === undefined }
51
+ : undefined,
52
+ chainModalOpen: chainModal.chainModalOpen,
53
+ connectModalOpen: connectModal.connectModalOpen,
54
+ connectionStatus: !mounted
55
+ ? 'disconnected'
56
+ : connected
57
+ ? 'connected'
58
+ : connect.isPending || rainbowKit?.connecting
59
+ ? 'connecting'
60
+ : 'disconnected',
61
+ mounted,
62
+ openAccountModal: mounted ? accountModal.openAccountModal : undefined,
63
+ openChainModal: mounted ? chainModal.openChainModal : undefined,
64
+ openConnectModal: mounted ? connectModal.openConnectModal : undefined,
65
+ };
66
+ }
67
+
68
+ export function ConnectButton(props: ConnectButtonProps = {}) @{
69
+ const state = useConnectButtonState();
70
+ const disconnect = useDisconnect();
71
+ @if (!state.account) {
72
+ <button
73
+ type="button"
74
+ className="rk-connect-button"
75
+ disabled={!state.mounted || state.connectionStatus === 'connecting'}
76
+ onClick={state.openConnectModal}
77
+ >
78
+ {props.label ?? 'Connect Wallet'}
79
+ </button>
80
+ } @else {
81
+ <div>
82
+ @if (state.chain?.unsupported) {
83
+ <button type="button" className="rk-connect-button" onClick={state.openChainModal}>
84
+ Wrong network
85
+ </button>
86
+ } @else {
87
+ @if (props.chainStatus !== 'none') {
88
+ <button type="button" className="rk-connect-button" onClick={state.openChainModal}>
89
+ {state.chain?.name ?? `Chain ${state.chain?.id}`}
90
+ </button>
91
+ }
92
+ }
93
+ <button
94
+ type="button"
95
+ className="rk-connect-button"
96
+ onClick={state.openAccountModal}
97
+ >{state.account.displayName}</button>
98
+ <button
99
+ type="button"
100
+ className="rk-connect-button"
101
+ disabled={disconnect.isPending}
102
+ onClick={() => disconnect.mutate(undefined)}
103
+ >
104
+ Disconnect
105
+ </button>
106
+ </div>
107
+ }
108
+ }
109
+
110
+ ConnectButton.Custom = function ConnectButtonCustom(props: {
111
+ children: OctaneNode | ((state: ConnectButtonRendererProps) => OctaneNode);
112
+ }) {
113
+ const state = useConnectButtonState();
114
+ return typeof props.children === 'function' && !isChildrenBlock(props.children)
115
+ ? props.children(state)
116
+ : props.children;
117
+ };
package/src/hooks.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { useContext } from 'octane';
2
+ import { RainbowKitContext } from './internal';
3
+
4
+ export function useConnectModal(): {
5
+ connectModalOpen: boolean;
6
+ openConnectModal?: (event?: Event) => void;
7
+ } {
8
+ const context = useContext(RainbowKitContext);
9
+ return {
10
+ connectModalOpen: context?.activeModal?.kind === 'connect',
11
+ openConnectModal:
12
+ context?.mounted && !context.connected
13
+ ? (event) =>
14
+ context.openModal('connect', event?.currentTarget as HTMLElement | null | undefined)
15
+ : undefined,
16
+ };
17
+ }
18
+
19
+ export function useAccountModal(): {
20
+ accountModalOpen: boolean;
21
+ openAccountModal?: (event?: Event) => void;
22
+ } {
23
+ const context = useContext(RainbowKitContext);
24
+ return {
25
+ accountModalOpen: context?.activeModal?.kind === 'account',
26
+ openAccountModal:
27
+ context?.mounted && context.connected
28
+ ? (event) =>
29
+ context.openModal('account', event?.currentTarget as HTMLElement | null | undefined)
30
+ : undefined,
31
+ };
32
+ }
33
+
34
+ export function useChainModal(): {
35
+ chainModalOpen: boolean;
36
+ openChainModal?: (event?: Event) => void;
37
+ } {
38
+ const context = useContext(RainbowKitContext);
39
+ return {
40
+ chainModalOpen: context?.activeModal?.kind === 'chain',
41
+ openChainModal:
42
+ context?.mounted && context.connected
43
+ ? (event) =>
44
+ context.openModal('chain', event?.currentTarget as HTMLElement | null | undefined)
45
+ : undefined,
46
+ };
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { RainbowKitProvider, type RainbowKitProviderProps } from './provider.tsrx';
2
+ export {
3
+ ConnectButton,
4
+ type ConnectButtonProps,
5
+ type ConnectButtonRendererProps,
6
+ } from './connect-button.tsrx';
7
+ export { WalletButton, type WalletButtonProps } from './wallet-button.tsrx';
8
+ export { useAccountModal, useChainModal, useConnectModal } from './hooks';
9
+ export { darkTheme, lightTheme, midnightTheme, rainbowTheme, type Theme } from './theme';
10
+ export type { WalletDescriptor } from './internal';
@@ -0,0 +1,158 @@
1
+ import { createContext } from 'octane';
2
+ import type { Connector } from '@wagmi/core';
3
+
4
+ export type ModalKind = 'account' | 'chain' | 'connect';
5
+ export type ModalToken = string;
6
+
7
+ export type WalletDescriptor = {
8
+ id: string;
9
+ name: string;
10
+ connectorUid?: string;
11
+ unavailableReason?: string;
12
+ };
13
+
14
+ export type WalletEntry = WalletDescriptor & {
15
+ connector?: Connector;
16
+ identity: string;
17
+ };
18
+
19
+ export type RainbowKitContextValue = {
20
+ activeModal: { kind: ModalKind; token: ModalToken } | null;
21
+ beginConnecting: () => symbol;
22
+ closeModal: (token?: ModalToken) => void;
23
+ connected: boolean;
24
+ connecting: boolean;
25
+ endConnecting: (token: symbol) => void;
26
+ mounted: boolean;
27
+ openModal: (kind: ModalKind, opener?: HTMLElement | null) => void;
28
+ wallets: readonly WalletEntry[];
29
+ };
30
+
31
+ export const RainbowKitContext = createContext<RainbowKitContextValue | null>(null);
32
+
33
+ type DocumentModalState = {
34
+ stack: ModalToken[];
35
+ elements: Map<ModalToken, HTMLElement>;
36
+ originalIsolation: Map<HTMLElement, { ariaHidden: string | null; inert: boolean }>;
37
+ bodyLockCount: number;
38
+ originalBodyOverflow: string;
39
+ };
40
+
41
+ const documentStates = new WeakMap<Document, DocumentModalState>();
42
+ const tokenDocuments = new Map<ModalToken, Document>();
43
+
44
+ function stateFor(document: Document): DocumentModalState {
45
+ let state = documentStates.get(document);
46
+ if (!state) {
47
+ state = {
48
+ stack: [],
49
+ elements: new Map(),
50
+ originalIsolation: new Map(),
51
+ bodyLockCount: 0,
52
+ originalBodyOverflow: '',
53
+ };
54
+ documentStates.set(document, state);
55
+ }
56
+ return state;
57
+ }
58
+
59
+ function rememberIsolation(state: DocumentModalState, element: HTMLElement): void {
60
+ if (!state.originalIsolation.has(element)) {
61
+ state.originalIsolation.set(element, {
62
+ ariaHidden: element.getAttribute('aria-hidden'),
63
+ inert: element.inert,
64
+ });
65
+ }
66
+ }
67
+
68
+ function restoreIsolation(state: DocumentModalState): void {
69
+ for (const [element, original] of state.originalIsolation) {
70
+ element.inert = original.inert;
71
+ if (original.ariaHidden === null) element.removeAttribute('aria-hidden');
72
+ else element.setAttribute('aria-hidden', original.ariaHidden);
73
+ }
74
+ }
75
+
76
+ function syncModalIsolation(document: Document): void {
77
+ const state = stateFor(document);
78
+ restoreIsolation(state);
79
+ const topToken = state.stack.at(-1);
80
+ const dialog = topToken ? state.elements.get(topToken) : undefined;
81
+ if (!dialog) {
82
+ if (state.stack.length === 0) state.originalIsolation.clear();
83
+ return;
84
+ }
85
+
86
+ let child: HTMLElement = dialog;
87
+ let parent = child.parentElement;
88
+ while (parent) {
89
+ rememberIsolation(state, child);
90
+ child.inert = false;
91
+ child.removeAttribute('aria-hidden');
92
+ for (const sibling of Array.from(parent.children)) {
93
+ if (sibling === child) continue;
94
+ const element = sibling as HTMLElement;
95
+ rememberIsolation(state, element);
96
+ element.inert = true;
97
+ element.setAttribute('aria-hidden', 'true');
98
+ }
99
+ if (parent === document.body) break;
100
+ child = parent;
101
+ parent = parent.parentElement;
102
+ }
103
+ }
104
+
105
+ export function connectorIdentity(connector: Connector): string {
106
+ return connector.uid || `${connector.id}:${connector.name}`;
107
+ }
108
+
109
+ export function pushModal(token: ModalToken, document: Document): void {
110
+ removeModal(token);
111
+ tokenDocuments.set(token, document);
112
+ stateFor(document).stack.push(token);
113
+ }
114
+
115
+ export function removeModal(token: ModalToken): void {
116
+ const document = tokenDocuments.get(token);
117
+ if (!document) return;
118
+ const state = stateFor(document);
119
+ const index = state.stack.indexOf(token);
120
+ if (index >= 0) state.stack.splice(index, 1);
121
+ state.elements.delete(token);
122
+ tokenDocuments.delete(token);
123
+ syncModalIsolation(document);
124
+ }
125
+
126
+ export function isTopModal(token: ModalToken): boolean {
127
+ const document = tokenDocuments.get(token);
128
+ return document ? stateFor(document).stack.at(-1) === token : false;
129
+ }
130
+
131
+ export function registerModal(token: ModalToken, element: HTMLElement): void {
132
+ const document = element.ownerDocument;
133
+ tokenDocuments.set(token, document);
134
+ stateFor(document).elements.set(token, element);
135
+ syncModalIsolation(document);
136
+ }
137
+
138
+ export function focusTopModal(document: Document): boolean {
139
+ const state = stateFor(document);
140
+ const token = state.stack.at(-1);
141
+ const element = token ? state.elements.get(token) : undefined;
142
+ if (element && !element.contains(document.activeElement)) element.focus();
143
+ return Boolean(element);
144
+ }
145
+
146
+ export function lockBody(document: Document): void {
147
+ const state = stateFor(document);
148
+ if (state.bodyLockCount++ === 0) {
149
+ state.originalBodyOverflow = document.body.style.overflow;
150
+ document.body.style.overflow = 'hidden';
151
+ }
152
+ }
153
+
154
+ export function unlockBody(document: Document): void {
155
+ const state = stateFor(document);
156
+ if (state.bodyLockCount === 0) return;
157
+ if (--state.bodyLockCount === 0) document.body.style.overflow = state.originalBodyOverflow;
158
+ }
@@ -0,0 +1,406 @@
1
+ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'octane';
2
+ import { useConnect, useConnection, useDisconnect, useSwitchChain } from '@octanejs/wagmi';
3
+ import type { OctaneNode } from 'octane';
4
+ import type { Connector } from '@wagmi/core';
5
+ import {
6
+ RainbowKitContext,
7
+ connectorIdentity,
8
+ focusTopModal,
9
+ isTopModal,
10
+ lockBody,
11
+ pushModal,
12
+ registerModal,
13
+ removeModal,
14
+ unlockBody,
15
+ type ModalKind,
16
+ type ModalToken,
17
+ type WalletDescriptor,
18
+ type WalletEntry,
19
+ } from './internal';
20
+ import { lightTheme, type Theme } from './theme';
21
+ import './styles.css';
22
+
23
+ export type RainbowKitProviderProps = {
24
+ children?: OctaneNode;
25
+ theme?: Theme;
26
+ wallets?: readonly WalletDescriptor[];
27
+ };
28
+
29
+ function themeStyle(theme: Theme): Record<string, string> {
30
+ return {
31
+ '--rk-accent': theme.colors.accentColor,
32
+ '--rk-accent-foreground': theme.colors.accentColorForeground,
33
+ '--rk-connect-background': theme.colors.connectButtonBackground,
34
+ '--rk-connect-text': theme.colors.connectButtonText,
35
+ '--rk-modal-background': theme.colors.modalBackground,
36
+ '--rk-modal-text': theme.colors.modalText,
37
+ '--rk-font-body': theme.fonts.body,
38
+ '--rk-action-radius': theme.radii.actionButton,
39
+ '--rk-connect-radius': theme.radii.connectButton,
40
+ '--rk-modal-radius': theme.radii.modal,
41
+ };
42
+ }
43
+
44
+ function walletEntries(
45
+ connectors: readonly Connector[],
46
+ descriptors: readonly WalletDescriptor[],
47
+ ): readonly WalletEntry[] {
48
+ const byIdentity = new Map(connectors.map(
49
+ (connector) => [connectorIdentity(connector), connector],
50
+ ));
51
+ const consumed = new Set<string>();
52
+ const entries: WalletEntry[] = [];
53
+ for (const descriptor of descriptors) {
54
+ const fallback = connectors.find(
55
+ (connector) => connector.id === descriptor.id || connector.name === descriptor.name,
56
+ );
57
+ const connector = descriptor.connectorUid
58
+ ? byIdentity.get(descriptor.connectorUid) ?? fallback
59
+ : fallback;
60
+ const identity =
61
+ connector ? connectorIdentity(connector) : descriptor.connectorUid || descriptor.id;
62
+ if (consumed.has(identity)) continue;
63
+ consumed.add(identity);
64
+ entries.push({ ...descriptor, connector, identity });
65
+ }
66
+ for (const connector of connectors) {
67
+ const identity = connectorIdentity(connector);
68
+ if (consumed.has(identity)) continue;
69
+ consumed.add(identity);
70
+ entries.push({ id: connector.id, name: connector.name, connector, identity });
71
+ }
72
+ return entries;
73
+ }
74
+
75
+ function errorMessage(error: unknown): string {
76
+ return error instanceof Error ? error.message : 'The wallet request failed.';
77
+ }
78
+
79
+ function Modal(props: {
80
+ kind: ModalKind;
81
+ token: ModalToken;
82
+ wallets: readonly WalletEntry[];
83
+ close: (token: ModalToken) => void;
84
+ titleId: string;
85
+ descriptionId: string;
86
+ beginConnecting: () => symbol;
87
+ endConnecting: (token: symbol) => void;
88
+ opener: HTMLElement | null;
89
+ }) @{
90
+ const connection = useConnection();
91
+ const connect = useConnect();
92
+ const disconnect = useDisconnect();
93
+ const switchChain = useSwitchChain();
94
+ const [pendingWalletIdentity, setPendingWalletIdentity] = useState<string | null>(null);
95
+ const dialogRef = useRef<HTMLElement | null>(null);
96
+ const focusScope = props.opener?.closest<HTMLElement>('[data-rk]');
97
+ const restoreFocus = () => {
98
+ const ownerDocument = props.opener?.ownerDocument ?? dialogRef.current?.ownerDocument;
99
+ if (!ownerDocument) return;
100
+ queueMicrotask(() => {
101
+ if (focusTopModal(ownerDocument)) return;
102
+ const fallback = focusScope?.querySelector<HTMLElement>(
103
+ '.rk-connect-button:not([disabled]), .rk-wallet-button:not([disabled])',
104
+ );
105
+ ((props.opener?.isConnected ? props.opener : fallback))?.focus();
106
+ });
107
+ };
108
+ const title =
109
+ props.kind === 'connect'
110
+ ? 'Connect a wallet'
111
+ : props.kind === 'chain'
112
+ ? 'Switch network'
113
+ : 'Account';
114
+ const description =
115
+ props.kind === 'connect'
116
+ ? 'Choose an available wallet.'
117
+ : props.kind === 'chain'
118
+ ? 'Choose a network for the connected wallet.'
119
+ : 'Manage the connected wallet.';
120
+
121
+ useLayoutEffect(() => {
122
+ const dialog = dialogRef.current;
123
+ if (!dialog) return;
124
+ const ownerDocument = dialog.ownerDocument;
125
+ pushModal(props.token, ownerDocument);
126
+ lockBody(ownerDocument);
127
+ registerModal(props.token, dialog);
128
+ const focusable = () => Array.from(
129
+ dialog.querySelectorAll<HTMLElement>(
130
+ 'button:not([disabled]), [href], input:not([disabled]), [tabindex]:not([tabindex="-1"])',
131
+ ),
132
+ );
133
+ focusable()[0]?.focus();
134
+ const onKeyDown = (event: KeyboardEvent) => {
135
+ if (!isTopModal(props.token)) return;
136
+ if (event.key === 'Escape') {
137
+ event.preventDefault();
138
+ props.close(props.token);
139
+ return;
140
+ }
141
+ if (event.key !== 'Tab') return;
142
+ const items = focusable();
143
+ if (items.length === 0) {
144
+ event.preventDefault();
145
+ dialog.focus();
146
+ return;
147
+ }
148
+ const first = items[0]!;
149
+ const last = items.at(-1)!;
150
+ if (
151
+ !dialog.contains(ownerDocument.activeElement) ||
152
+ !items.includes(ownerDocument.activeElement as HTMLElement)
153
+ ) {
154
+ event.preventDefault();
155
+ ((event.shiftKey ? last : first)).focus();
156
+ } else if (event.shiftKey && ownerDocument.activeElement === first) {
157
+ event.preventDefault();
158
+ last.focus();
159
+ } else if (!event.shiftKey && ownerDocument.activeElement === last) {
160
+ event.preventDefault();
161
+ first.focus();
162
+ }
163
+ };
164
+ ownerDocument.addEventListener('keydown', onKeyDown);
165
+ return () => {
166
+ ownerDocument.removeEventListener('keydown', onKeyDown);
167
+ removeModal(props.token);
168
+ unlockBody(ownerDocument);
169
+ restoreFocus();
170
+ };
171
+ }, [props.token]);
172
+
173
+ const connectWallet = (entry: WalletEntry) => {
174
+ if (!entry.connector) return;
175
+ const pendingToken = props.beginConnecting();
176
+ setPendingWalletIdentity(entry.identity);
177
+ void connect.connectAsync({ connector: entry.connector }).then(() => {
178
+ props.close(props.token);
179
+ restoreFocus();
180
+ }).catch(() => {}).finally(() => {
181
+ setPendingWalletIdentity(null);
182
+ props.endConnecting(pendingToken);
183
+ });
184
+ };
185
+
186
+ <div
187
+ className="rk-overlay"
188
+ data-rk-overlay=""
189
+ onClick={(event) => {
190
+ if (event.target === event.currentTarget && isTopModal(props.token)) props.close(props.token);
191
+ }}
192
+ >
193
+ <section
194
+ className="rk-dialog"
195
+ ref={dialogRef}
196
+ role="dialog"
197
+ aria-modal="true"
198
+ aria-labelledby={props.titleId}
199
+ aria-describedby={props.descriptionId}
200
+ tabIndex={-1}
201
+ >
202
+ <header className="rk-dialog-header">
203
+ <h2 id={props.titleId}>{title}</h2>
204
+ <button
205
+ type="button"
206
+ className="rk-close"
207
+ aria-label="Close"
208
+ onClick={() => props.close(props.token)}
209
+ >
210
+ ×
211
+ </button>
212
+ </header>
213
+ <p id={props.descriptionId}>{description}</p>
214
+ <div role="status" aria-live="polite">
215
+ {connect.isPending
216
+ ? 'Waiting for wallet approval'
217
+ : switchChain.isPending
218
+ ? 'Switching network'
219
+ : disconnect.isPending
220
+ ? 'Disconnecting wallet'
221
+ : ''}
222
+ </div>
223
+ @if (props.kind === 'connect') {
224
+ <>
225
+ @if (props.wallets.length === 0) {
226
+ <p>No compatible wallets are available.</p>
227
+ } @else {
228
+ <ul className="rk-list">
229
+ @for (const wallet of props.wallets; key wallet.identity) {
230
+ <li>
231
+ <button
232
+ type="button"
233
+ className="rk-action"
234
+ disabled={connect.isPending || !wallet.connector}
235
+ onClick={() => connectWallet(wallet)}
236
+ >
237
+ {connect.isPending && pendingWalletIdentity === wallet.identity
238
+ ? `Waiting for ${wallet.name}`
239
+ : wallet.name}
240
+ </button>
241
+ @if (!wallet.connector) {
242
+ <p>{wallet.unavailableReason ?? 'Wallet is unavailable in this browser.'}</p>
243
+ }
244
+ </li>
245
+ }
246
+ </ul>
247
+ }
248
+ @if (connect.error) {
249
+ <p role="alert">{errorMessage(connect.error)}</p>
250
+ }
251
+ </>
252
+ } @else {
253
+ @if (props.kind === 'chain') {
254
+ <>
255
+ <ul className="rk-list">
256
+ @for (const chain of switchChain.chains; key chain.id) {
257
+ <li>
258
+ <button
259
+ type="button"
260
+ className="rk-action"
261
+ disabled={switchChain.isPending || connection.chainId === chain.id}
262
+ onClick={() => switchChain.mutate({ chainId: chain.id }, {
263
+ onSuccess: () => props.close(props.token),
264
+ })}
265
+ >{chain.name}</button>
266
+ </li>
267
+ }
268
+ </ul>
269
+ @if (switchChain.error) {
270
+ <p role="alert">{errorMessage(switchChain.error)}</p>
271
+ }
272
+ </>
273
+ } @else {
274
+ <>
275
+ <p>{connection.address ?? 'No connected account'}</p>
276
+ <button
277
+ type="button"
278
+ className="rk-action"
279
+ disabled={disconnect.isPending || connection.status !== 'connected'}
280
+ onClick={() => disconnect.mutate(undefined, {
281
+ onSuccess: () => props.close(props.token),
282
+ })}
283
+ >
284
+ Disconnect
285
+ </button>
286
+ @if (disconnect.error) {
287
+ <p role="alert">{errorMessage(disconnect.error)}</p>
288
+ }
289
+ </>
290
+ }
291
+ }
292
+ </section>
293
+ </div>
294
+ }
295
+
296
+ export function RainbowKitProvider(props: RainbowKitProviderProps) @{
297
+ const [activeModal, setActiveModal] = useState<
298
+ | {
299
+ kind: ModalKind;
300
+ token: ModalToken;
301
+ chainId: number | undefined;
302
+ opener: HTMLElement | null;
303
+ ownerDocument: Document | null;
304
+ }
305
+ | null
306
+ >(null);
307
+ const connection = useConnection();
308
+ const [mounted, setMounted] = useState(false);
309
+ const connectingTokens = useRef(new Set<symbol>());
310
+ const modalGeneration = useRef(0);
311
+ const [connectingCount, setConnectingCount] = useState(0);
312
+ const rootRef = useRef<HTMLDivElement | null>(null);
313
+ useEffect(() => setMounted(true), []);
314
+ const beginConnecting = useCallback(() => {
315
+ const token = Symbol('rainbowkit-connect');
316
+ connectingTokens.current.add(token);
317
+ setConnectingCount(connectingTokens.current.size);
318
+ return token;
319
+ }, []);
320
+ const endConnecting = useCallback((token: symbol) => {
321
+ if (connectingTokens.current.delete(token)) {
322
+ setConnectingCount(connectingTokens.current.size);
323
+ }
324
+ }, []);
325
+ const connectors = useConnect().connectors;
326
+ const wallets = useMemo(() => walletEntries(connectors, props.wallets ?? []), [
327
+ connectors,
328
+ props.wallets,
329
+ ]);
330
+ const selectedTheme = props.theme ?? lightTheme();
331
+ const providerId = useId();
332
+ const closeModal = useCallback((token?: ModalToken) => {
333
+ setActiveModal((current) => {
334
+ if (!current || token && current.token !== token) return current;
335
+ return null;
336
+ });
337
+ }, []);
338
+ useEffect(() => {
339
+ if (!activeModal) return;
340
+ const modalNoLongerApplies =
341
+ activeModal.kind === 'connect'
342
+ ? connection.status === 'connected'
343
+ : connection.status !== 'connected' ||
344
+ activeModal.kind === 'chain' && activeModal.chainId !== connection.chainId;
345
+ if (modalNoLongerApplies) closeModal(activeModal.token);
346
+ }, [activeModal, closeModal, connection.chainId, connection.status]);
347
+ const openModal = useCallback((kind: ModalKind, nextOpener?: HTMLElement | null) => {
348
+ const token = `${providerId}:${++modalGeneration.current}`;
349
+ const fallbackDocument = rootRef.current?.ownerDocument;
350
+ const ownerDocument =
351
+ nextOpener?.ownerDocument ?? fallbackDocument ??
352
+ (typeof document === 'undefined' ? null : document);
353
+ setActiveModal({
354
+ kind,
355
+ token,
356
+ chainId: connection.chainId,
357
+ opener: nextOpener ?? ownerDocument?.activeElement as HTMLElement | null | undefined ?? null,
358
+ ownerDocument,
359
+ });
360
+ }, [connection.chainId, providerId]);
361
+ const context = useMemo(
362
+ () => ({
363
+ activeModal,
364
+ beginConnecting,
365
+ closeModal,
366
+ connected: connection.status === 'connected',
367
+ connecting: connectingCount > 0 || connection.status === 'connecting' ||
368
+ connection.status === 'reconnecting',
369
+ endConnecting,
370
+ mounted,
371
+ openModal,
372
+ wallets,
373
+ }),
374
+ [
375
+ activeModal,
376
+ beginConnecting,
377
+ closeModal,
378
+ connectingCount,
379
+ connection.status,
380
+ endConnecting,
381
+ mounted,
382
+ openModal,
383
+ wallets,
384
+ ],
385
+ );
386
+
387
+ <div data-rk="" ref={rootRef} style={themeStyle(selectedTheme)}>
388
+ <RainbowKitContext.Provider value={context}>
389
+ {props.children}
390
+ @if (activeModal) {
391
+ <Modal
392
+ key={activeModal.token}
393
+ kind={activeModal.kind}
394
+ token={activeModal.token}
395
+ wallets={wallets}
396
+ close={closeModal}
397
+ titleId={`${providerId}-title`}
398
+ descriptionId={`${providerId}-description`}
399
+ beginConnecting={beginConnecting}
400
+ endConnecting={endConnecting}
401
+ opener={activeModal.opener}
402
+ />
403
+ }
404
+ </RainbowKitContext.Provider>
405
+ </div>
406
+ }
package/src/styles.css ADDED
@@ -0,0 +1,92 @@
1
+ [data-rk] {
2
+ font-family: var(--rk-font-body);
3
+ }
4
+
5
+ .rk-connect-button,
6
+ .rk-wallet-button,
7
+ .rk-action {
8
+ min-height: 44px;
9
+ border: 0;
10
+ padding: 0 16px;
11
+ font: inherit;
12
+ cursor: pointer;
13
+ }
14
+
15
+ .rk-connect-button,
16
+ .rk-wallet-button {
17
+ border-radius: var(--rk-connect-radius);
18
+ background: var(--rk-connect-background);
19
+ color: var(--rk-connect-text);
20
+ }
21
+
22
+ .rk-action {
23
+ border-radius: var(--rk-action-radius);
24
+ background: var(--rk-accent);
25
+ color: var(--rk-accent-foreground);
26
+ }
27
+
28
+ .rk-overlay {
29
+ position: fixed;
30
+ inset: 0;
31
+ z-index: 2147483647;
32
+ display: grid;
33
+ place-items: center;
34
+ background: rgb(0 0 0 / 55%);
35
+ padding: 16px;
36
+ }
37
+
38
+ .rk-dialog {
39
+ width: min(420px, 100%);
40
+ max-height: min(640px, calc(100vh - 32px));
41
+ overflow: auto;
42
+ border-radius: var(--rk-modal-radius);
43
+ background: var(--rk-modal-background);
44
+ color: var(--rk-modal-text);
45
+ padding: 20px;
46
+ box-shadow: 0 16px 64px rgb(0 0 0 / 30%);
47
+ }
48
+
49
+ .rk-dialog-header {
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: space-between;
53
+ gap: 16px;
54
+ }
55
+
56
+ .rk-close {
57
+ min-width: 44px;
58
+ min-height: 44px;
59
+ border: 0;
60
+ border-radius: 999px;
61
+ background: transparent;
62
+ color: inherit;
63
+ font-size: 20px;
64
+ cursor: pointer;
65
+ }
66
+
67
+ .rk-list {
68
+ display: grid;
69
+ grid-template-columns: 1fr;
70
+ gap: 8px;
71
+ padding: 0;
72
+ list-style: none;
73
+ }
74
+
75
+ .rk-list button {
76
+ width: 100%;
77
+ min-height: 44px;
78
+ text-align: left;
79
+ }
80
+
81
+ @media (prefers-reduced-motion: no-preference) {
82
+ .rk-dialog {
83
+ animation: rk-enter 120ms ease-out;
84
+ }
85
+ }
86
+
87
+ @keyframes rk-enter {
88
+ from {
89
+ opacity: 0;
90
+ transform: translateY(4px);
91
+ }
92
+ }
@@ -0,0 +1,68 @@
1
+ export type Theme = {
2
+ colors: {
3
+ accentColor: string;
4
+ accentColorForeground: string;
5
+ connectButtonBackground: string;
6
+ connectButtonText: string;
7
+ modalBackground: string;
8
+ modalText: string;
9
+ };
10
+ fonts: { body: string };
11
+ radii: { actionButton: string; connectButton: string; modal: string };
12
+ };
13
+
14
+ type ThemeOptions = {
15
+ accentColor?: string;
16
+ accentColorForeground?: string;
17
+ borderRadius?: 'large' | 'medium' | 'none' | 'small';
18
+ fontStack?: 'rounded' | 'system';
19
+ };
20
+
21
+ const radii = {
22
+ large: '20px',
23
+ medium: '12px',
24
+ none: '0',
25
+ small: '8px',
26
+ } as const;
27
+
28
+ function createTheme(dark: boolean, options: ThemeOptions = {}): Theme {
29
+ const radius = radii[options.borderRadius ?? 'large'];
30
+ return {
31
+ colors: {
32
+ accentColor: options.accentColor ?? '#0e76fd',
33
+ accentColorForeground: options.accentColorForeground ?? '#fff',
34
+ connectButtonBackground: dark ? '#1a1b1f' : '#fff',
35
+ connectButtonText: dark ? '#fff' : '#1a1b1f',
36
+ modalBackground: dark ? '#1a1b1f' : '#fff',
37
+ modalText: dark ? '#fff' : '#1a1b1f',
38
+ },
39
+ fonts: {
40
+ body:
41
+ options.fontStack === 'rounded'
42
+ ? 'ui-rounded, "SF Pro Rounded", system-ui, sans-serif'
43
+ : 'system-ui, sans-serif',
44
+ },
45
+ radii: { actionButton: radius, connectButton: radius, modal: radius },
46
+ };
47
+ }
48
+
49
+ export function lightTheme(options?: ThemeOptions): Theme {
50
+ return createTheme(false, options);
51
+ }
52
+
53
+ export function darkTheme(options?: ThemeOptions): Theme {
54
+ return createTheme(true, options);
55
+ }
56
+
57
+ export function midnightTheme(options?: ThemeOptions): Theme {
58
+ return createTheme(true, {
59
+ accentColor: '#fff',
60
+ accentColorForeground: '#1a1b1f',
61
+ ...options,
62
+ });
63
+ }
64
+
65
+ /** Octane extension with a purple rounded preset; not an upstream RainbowKit export. */
66
+ export function rainbowTheme(options?: ThemeOptions): Theme {
67
+ return lightTheme({ accentColor: '#7b3fe4', fontStack: 'rounded', ...options });
68
+ }
@@ -0,0 +1,57 @@
1
+ import { useConnect } from '@octanejs/wagmi';
2
+ import { createElement, isChildrenBlock, useContext } from 'octane';
3
+ import type { OctaneNode } from 'octane';
4
+ import { RainbowKitContext } from './internal';
5
+
6
+ export type WalletButtonProps = {
7
+ children?: | OctaneNode
8
+ | ((props: {
9
+ ready: boolean;
10
+ connect: () => void;
11
+ error: unknown | null;
12
+ }) => OctaneNode);
13
+ wallet: string;
14
+ };
15
+
16
+ export function WalletButton(props: WalletButtonProps) {
17
+ const context = useContext(RainbowKitContext);
18
+ const mutation = useConnect();
19
+ const entry = context?.wallets.find(
20
+ (candidate) =>
21
+ candidate.identity === props.wallet || candidate.id === props.wallet ||
22
+ candidate.name === props.wallet,
23
+ );
24
+ const connector = entry?.connector;
25
+ const ready = Boolean(context?.mounted && connector);
26
+ const connect = () => {
27
+ if (!context?.mounted || !connector) return;
28
+ const token = context.beginConnecting();
29
+ void mutation.connectAsync({ connector }).catch(() => {}).finally(
30
+ () => context.endConnecting(token),
31
+ );
32
+ };
33
+ if (props.children != null) {
34
+ return typeof props.children === 'function' && !isChildrenBlock(props.children)
35
+ ? props.children({ ready, connect, error: mutation.error })
36
+ : props.children;
37
+ }
38
+ return [
39
+ createElement(
40
+ 'button',
41
+ {
42
+ type: 'button',
43
+ className: 'rk-wallet-button',
44
+ disabled: !ready || context?.connecting || mutation.isPending,
45
+ onClick: connect,
46
+ },
47
+ entry?.name ?? props.wallet,
48
+ ),
49
+ mutation.error
50
+ ? createElement(
51
+ 'p',
52
+ { role: 'alert', className: 'rk-wallet-error' },
53
+ mutation.error instanceof Error ? mutation.error.message : 'The wallet request failed.',
54
+ )
55
+ : null,
56
+ ];
57
+ }
package/status.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "upstream": {
3
+ "package": "@rainbow-me/rainbowkit",
4
+ "version": "2.2.11"
5
+ },
6
+ "surface": "Octane-native RainbowKitProvider, ConnectButton and ConnectButton.Custom, WalletButton, connect/account/chain modal hooks, connector selection, account/chain actions, native accessible dialogs, and light/dark/midnight themes.",
7
+ "divergences": [
8
+ "IMPORTANT: upstream RainbowKit 2.2.11 declares wagmi ^2.9.0. This adapter intentionally consumes @octanejs/wagmi v3 and is not drop-in dependency or peer-range parity.",
9
+ "The React DOM and vanilla-extract implementation is replaced by native Octane TSRX, DOM events, focus/scroll containment, and CSS custom properties.",
10
+ "The wallet list merges optional configured descriptors with the enclosing Wagmi v3 connector list, deduplicated by canonical connector uid with explicit id/name fallback. Unavailable configured entries remain visible with a reason. RainbowKit wallet factories, vendor SDKs, and WalletConnect project configuration remain application-owned.",
11
+ "Authentication, recent transactions, ENS/avatar resolution, localization, cool mode, account avatars/balances, chain icons, and pixel-identical upstream themes are unsupported and their upstream props are not accepted.",
12
+ "rainbowTheme is an explicitly documented Octane-only purple/rounded preset; it is not an upstream RainbowKit export."
13
+ ],
14
+ "ssr": "The provider and controls emit deterministic disconnected markup without browser wallet access. Connector discovery and live Wagmi state become authoritative after hydration; no hydrated UI state authorizes wallet actions.",
15
+ "verified": "2026-07-29",
16
+ "provenance": {
17
+ "source": "https://github.com/rainbow-me/rainbowkit",
18
+ "license": "MIT"
19
+ },
20
+ "supportedExports": [
21
+ "RainbowKitProvider",
22
+ "ConnectButton",
23
+ "ConnectButton.Custom",
24
+ "WalletButton",
25
+ "useConnectModal",
26
+ "useAccountModal",
27
+ "useChainModal",
28
+ "lightTheme",
29
+ "darkTheme",
30
+ "midnightTheme",
31
+ "rainbowTheme"
32
+ ],
33
+ "unsupported": [
34
+ "wallet factory catalogue",
35
+ "authentication adapter",
36
+ "recent transactions",
37
+ "ENS and avatar resolution",
38
+ "localization",
39
+ "cool mode",
40
+ "pixel-identical themes",
41
+ "account avatars and balances",
42
+ "chain icons",
43
+ "React DOM internals"
44
+ ]
45
+ }