Skip to content

Custom review components

Show your React component before wallet approval, then hand off to the wallet in the same modal.

Custom review components ​

Pass a review object to a transaction method on useSeams().wallet to show your own React component before wallet approval. When the person continues, the same modal changes from your component to wallet approval. The person sees one dialog, not a second popup. The flow is:

Your review → Continue to wallet → wallet approval → transaction result.

A purchase review drawn by the application: the market claim, the amount to pay, the payout, the fees, a quote countdown, a Confirm in wallet button, and a Back to market link.
1. Your component shows the terms of the purchase.
Wallet approval in the same modal: the application origin, the contract address, the network, the signing account, and a Confirm with passkey button.
2. The same modal shows wallet approval for the exact transaction.

Try the flow in the Custom Components demo on wallet.seams.sh.

How the handoff works ​

  1. Your app calls a transaction method with review. The SDK opens a modal and renders your component in it. With origin, the modal grows from your own element.
  2. The person reads your terms. Your button calls controls.continueToWallet().
  3. The modal resizes and crossfades to wallet approval. The wallet iframe draws this step from the captured transaction. The person confirms with a passkey.
  4. The back arrow in wallet approval returns to your component. Your component keeps its local state, and the quote deadline does not change.

Your component cannot sign or change the transaction. It only decides when to show wallet approval. The wallet origin shows the transaction that the person signs, so an error in application UI cannot change what the wallet signs.

Mount the host ​

Mount one TransactionReviewHost beneath SeamsWebProvider and every app provider your review needs. The component that submits the transaction must also descend from this host.

tsx
import { SeamsWebProvider, TransactionReviewHost } from '@seams/wallet/react';

<SeamsWebProvider config={config}>
  <TransactionReviewHost>
    <App />
  </TransactionReviewHost>
</SeamsWebProvider>

Reviews inherit context from the host's ancestry. Providers mounted only around the calling button do not supply context to the review.

Pass a React component ​

The render callback receives the controls and returns your component. That component can use hooks, context, local state, and React.lazy/Suspense.

tsx
review: {
  title: 'Review purchase',
  validity: { kind: 'unbounded' },
  render: (controls) => (
    <PurchaseReview quote={quote} controls={controls} />
  ),
}

Here, PurchaseReview and quote belong to your application. Return JSX from the synchronous callback. Place hooks inside the returned component.

The SDK supplies the dialog, backdrop, and focus management. Supply content and buttons without another dialog or focus trap.

ControlEffect
continueToWallet()Shows wallet approval. The user must approve there before signing.
cancel()Cancels the review.
fail(error)Shows the SDK's dismissible error fallback. Use for event-handler or fetch failures.

Start from your own modal ​

Pass origin to grow the review from your own element. Use onVisible to learn when the review or the wallet approval is on screen.

tsx
review: {
  title: 'Review deposit',
  validity: { kind: 'unbounded' },
  origin: amountCard.getBoundingClientRect(),
  onVisible: ({ stage, rect }) => {
    if (stage === 'review') closeAmountModal();
    trackSurface(stage, rect);
  },
  render: (controls) => <DepositReview controls={controls} />,
}

origin is a rectangle in viewport pixels. The SDK reads it once when you call the method. It applies only to the first review. Wallet approval never starts from application geometry. With reduced motion, the review appears in place.

onVisible receives stage and rect. stage is review when your component is visible, and wallet when wallet approval is ready. Back to review reports review again. rect is the settled position in viewport pixels. An error from this callback does not change the transaction.

The review opens above your modal. Close your modal in onVisible, so that no frame shows an empty page. If your modal held focus, the SDK cannot return focus to it after you remove it, so move focus yourself when the operation ends.

Choose validity explicitly ​

validity is required so each call explicitly chooses whether the reviewed transaction has a deadline.

  • { kind: 'unbounded' }: no review deadline, suitable for a transfer without an expiring quote.
  • { kind: 'expires_at', atMs: quote.expiresAtMs }: use the quote's deadline as a positive safe-integer Unix timestamp in milliseconds.

Expiry prevents approval and additional signatures. It cannot undo a signature already started. Refresh an expired quote. Make a new call with that quote. Changing an existing quote does not update the captured transaction. Use contract deadlines and slippage limits for transaction-level economic guarantees.

Runnable NEAR example ​

Render this button beneath the host with a signed-in NEAR account. It sends a zero-value self-transfer. Gas fees apply. Use a testnet wallet to try it.

tsx
import { useState } from 'react';
import {
  transfer,
  useSeams,
  type BoundNearSigner,
  type TransactionReviewControls,
} from '@seams/wallet/react';

type TransferState =
  | { kind: 'ready'; message: string }
  | { kind: 'pending' }
  | { kind: 'blocked'; message: string };

function TransferReview({ controls }: { controls: TransactionReviewControls }) {
  return (
    <>
      <p>This example sends a zero-value transfer to your own NEAR account. Gas fees apply.</p>
      <button type="button" onClick={controls.continueToWallet}>
        Continue to wallet
      </button>
      <button type="button" onClick={controls.cancel}>
        Cancel
      </button>
    </>
  );
}

function renderReview(controls: TransactionReviewControls) {
  return <TransferReview controls={controls} />;
}

async function submitTransfer(signer: BoundNearSigner, setState: (state: TransferState) => void) {
  setState({ kind: 'pending' });
  try {
    const result = await signer.signAndSendTransaction({
      receiverId: signer.accountId,
      actions: [transfer('0 NEAR')],
      options: {
        confirmationConfig: { uiMode: 'modal', behavior: 'requireClick' },
      },
      review: {
        title: 'Review transfer',
        validity: { kind: 'unbounded' },
        render: renderReview,
      },
    });
    if (result.success) {
      setState({ kind: 'ready', message: `Transfer submitted: ${result.transactionId}` });
      return;
    }
    switch (result.kind) {
      case 'not_submitted':
        setState({ kind: 'ready', message: `${result.error} You can review a new attempt.` });
        return;
      case 'unresolved':
        setState({
          kind: 'blocked',
          message: `Check transaction ${result.transactionId} before another send.`,
        });
        return;
      case 'submission_unknown':
        setState({ kind: 'blocked', message: 'Check wallet activity before another send.' });
        return;
      default:
        return assertNeverTransferResult(result);
    }
  } catch (error) {
    const message = error instanceof Error ? error.message : 'The wallet response was interrupted.';
    setState({
      kind: 'blocked',
      message: `${message} Check wallet activity before another send.`,
    });
  }
}

function assertNeverTransferResult(value: never): never {
  throw new Error(`Unsupported transfer state: ${String(value)}`);
}

export function ReviewedTransferButton() {
  const { wallet } = useSeams();
  const [state, setState] = useState<TransferState>({ kind: 'ready', message: '' });
  if (wallet?.access !== 'wallet' || !wallet.near) return <p>Sign in with a NEAR account to try this example.</p>;

  return (
    <>
      <button
        type="button"
        disabled={state.kind !== 'ready'}
        onClick={submitTransfer.bind(null, wallet.near, setState)}
      >
        Review transfer
      </button>
      <p role="status">
        {state.kind === 'pending' ? 'Waiting for review and wallet approval…' : state.message}
      </p>
    </>
  );
}

Purchase example with an expiring quote ​

This EVM example follows the demo in the pictures. Your backend returns a quote with a deadline and the call to your market contract. The component shows a countdown and disables Confirm in wallet when the quote expires. validity uses the same deadline, so the SDK also stops wallet approval after expiry. The review grows from the Buy Yes button.

tsx
import { useEffect, useState, type MouseEvent } from 'react';
import {
  TransactionReviewError,
  useSeams,
  type BoundEvmSigner,
  type TransactionReviewControls,
  type TransactionReviewRect,
} from '@seams/wallet/react';

// Your backend prices the trade and builds the call to your market contract.
// `expiresAtMs` is the quote deadline as a Unix timestamp in milliseconds.
export type PurchaseQuote = {
  claim: string;
  pay: string;
  payout: string;
  minimumPayout: string;
  fee: string;
  expiresAtMs: number;
  tx: {
    to: `0x${string}`;
    data: `0x${string}`;
    value: bigint;
    gasLimit: bigint;
    maxFeePerGas: bigint;
    maxPriorityFeePerGas: bigint;
  };
};

type PurchaseState =
  | { kind: 'ready'; message: string }
  | { kind: 'pending' }
  | { kind: 'blocked'; message: string };

function secondsUntil(deadlineMs: number): number {
  return Math.max(0, Math.ceil((deadlineMs - Date.now()) / 1000));
}

function startCountdown(deadlineMs: number, setSeconds: (seconds: number) => void) {
  const tick = () => setSeconds(secondsUntil(deadlineMs));
  const timer = window.setInterval(tick, 1000);
  return () => window.clearInterval(timer);
}

// The SDK draws the dialog, the `review.title` heading, and the backdrop.
// This component supplies only the content and the two buttons.
function PurchaseReview({
  quote,
  controls,
}: {
  quote: PurchaseQuote;
  controls: TransactionReviewControls;
}) {
  const [seconds, setSeconds] = useState(secondsUntil(quote.expiresAtMs));
  useEffect(() => startCountdown(quote.expiresAtMs, setSeconds), [quote.expiresAtMs]);
  return (
    <>
      <p>{quote.claim}</p>
      <dl>
        <dt>You pay</dt>
        <dd>{quote.pay}</dd>
        <dt>Payout if Yes</dt>
        <dd>{quote.payout}</dd>
        <dt>Minimum payout</dt>
        <dd>{quote.minimumPayout}</dd>
        <dt>Trading fee</dt>
        <dd>{quote.fee} (included)</dd>
      </dl>
      <p>{seconds === 0 ? 'Quote expired' : `Quote expires in ${seconds}s`}</p>
      <button type="button" disabled={seconds === 0} onClick={controls.continueToWallet}>
        Confirm in wallet
      </button>
      <button type="button" onClick={controls.cancel}>
        Back to market
      </button>
    </>
  );
}

function reviewMessage(error: TransactionReviewError): string {
  switch (error.code) {
    case 'cancelled':
      return '';
    case 'review_expired':
      return 'The quote expired. Get a new quote to buy.';
    default:
      return `${error.message} You can review a new attempt.`;
  }
}

async function buy(
  signer: BoundEvmSigner,
  quote: PurchaseQuote,
  origin: TransactionReviewRect,
  setState: (state: PurchaseState) => void,
) {
  setState({ kind: 'pending' });
  try {
    const { txHash } = await signer.signAndSendTransaction({
      chainTarget: 'ethereum-sepolia',
      request: {
        chain: 'evm',
        kind: 'eip1559',
        senderSignatureAlgorithm: 'secp256k1',
        tx: quote.tx,
      },
      options: {
        confirmationConfig: { uiMode: 'modal', behavior: 'requireClick' },
      },
      review: {
        title: 'Review purchase',
        validity: { kind: 'expires_at', atMs: quote.expiresAtMs },
        origin,
        render: (controls) => <PurchaseReview quote={quote} controls={controls} />,
      },
    });
    setState({ kind: 'ready', message: `Purchase submitted: ${txHash}` });
  } catch (error) {
    // A review error means that nothing was signed or sent.
    if (error instanceof TransactionReviewError) {
      setState({ kind: 'ready', message: reviewMessage(error) });
      return;
    }
    const message = error instanceof Error ? error.message : 'The wallet response was interrupted.';
    setState({ kind: 'blocked', message: `${message} Check wallet activity before another buy.` });
  }
}

export function BuyButton({ quote }: { quote: PurchaseQuote }) {
  const { wallet } = useSeams();
  const [state, setState] = useState<PurchaseState>({ kind: 'ready', message: '' });
  if (wallet?.access !== 'wallet') return <p>Sign in to buy.</p>;

  // The review grows from this button. The SDK reads the rectangle once, at the call.
  const onClick = (event: MouseEvent<HTMLButtonElement>) =>
    void buy(wallet.evm, quote, event.currentTarget.getBoundingClientRect(), setState);

  return (
    <>
      <button type="button" disabled={state.kind !== 'ready'} onClick={onClick}>
        Buy Yes
      </button>
      <p role="status">
        {state.kind === 'pending' ? 'Waiting for review and wallet approval…' : state.message}
      </p>
    </>
  );
}

Set chainTarget to an EVM network in your provider config. A review_expired error means that nothing was signed. Get a new quote and make a new call.

Supported methods and settings ​

Signer on useSeams().walletMethods accepting review
nearsignAndSendTransaction
evmsignTransaction, signAndSendTransaction
temposignTransaction, signAndSendTransaction

A readable wallet (access: 'external') accepts review on near.prepareOwnerTransaction. The review opens at operation.approve(), and the outcome is typed. See owner approval with an application session. That call always uses the modal with an explicit click, and it takes no confirmation settings.

Custom reviews require a hosted wallet iframe and effective confirmation settings of uiMode: 'modal' and behavior: 'requireClick'. Set them explicitly as in the example to override incompatible saved preferences. Automatic approval delays are unsupported. Core SDK calls, message signing, export, and authentication do not accept this React review API.

The SDK captures transaction inputs and confirmation settings when called. The returned promise and lifecycle callbacks retain the method's existing meaning. Continue alone does not resolve the transaction. Calls queue with other wallet transactions. Cancelling or unmounting the caller or host invalidates queued and pre-signing work. After signing starts, the call preserves the wallet's actual result.

Errors and styling ​

NEAR returns the transaction outcome for review cancellation, pre-submission failure, and uncertain submission. Clear pending state and offer another review only for not_submitted. Retain a hash from unresolved until its chain outcome is known. Before dispatch, review failures invoke no signing callbacks.

EVM and Tempo review failures before dispatch reject with TransactionReviewError. Their signing and transport failures retain the canonical methods' error contracts.

TransactionReviewError.codeMeaning
cancelledThe review was cancelled.
review_expiredThe deadline passed. Obtain a fresh quote before retrying.
review_invalid_inputThe review configuration or transaction input is invalid.
review_host_unavailableA usable review host is missing.
review_owner_disposedThe caller or host was disposed.
review_unsupported_modeThe runtime or confirmation mode does not support review.
review_identity_changedThe wallet identity changed during the flow.
review_render_failedRendering failed or the app called fail(error).
review_prepare_timeoutReview loading or wallet preparation timed out.

The optional review.className styles the review wrapper. App CSS is available, but the portal changes DOM ancestry: ancestor selectors, inherited CSS variables, and CSS-in-JS insertion targets may need adjustment. SDK light/dark and shape tokens remain available. Your content must satisfy your application's CSP.

Wallet approval includes Back to review when opened from an application review. Back preserves the review component's local state and the original quote deadline. Continue resumes the same pending approval without dispatching another transaction. The shared modal resizes and crossfades in both directions. Back is available until wallet approval is submitted. Changing transaction inputs requires a new call.