| name | frontend-ux |
| description | Frontend UX rules for Ethereum dApps that prevent the most common AI agent UI bugs. Mandatory patterns for onchain buttons, token approval flows, address display, USD values, RPC configuration, and pre-publish metadata. Built around Scaffold-ETH 2 but the patterns apply to any Ethereum frontend. Use when building any dApp frontend. |
Frontend UX Rules
What You Probably Got Wrong
"The button works." Working is not the standard. Does it disable during the transaction? Does it show a spinner? Does it stay disabled until the chain confirms? Does it show an error if the user rejects? AI agents skip all of this, every time.
"I used wagmi hooks." Wrong hooks. Scaffold-ETH 2 wraps wagmi with useTransactor which waits for transaction confirmation — not just wallet signing. Raw wagmi's writeContractAsync resolves the moment the user clicks Confirm in MetaMask, BEFORE the tx is mined. Your button re-enables while the transaction is still pending.
"I showed the address." As raw hex? That's not showing it. <Address/> gives you ENS resolution, blockie avatars, copy-to-clipboard, and block explorer links. Raw 0x1234...5678 is unacceptable.
Rule 1: Every Onchain Button — Loader + Disable
⚠️ THIS IS THE #1 BUG AI AGENTS SHIP. The user clicks Approve, signs in their wallet, comes back to the app, and the Approve button is clickable again — so they click it again, send a duplicate transaction, and now two approvals are pending. The button MUST be disabled and show a spinner from the moment they click until the transaction confirms onchain. Not until the wallet closes. Not until the signature is sent. Until the BLOCK CONFIRMS.
ANY button that triggers a blockchain transaction MUST:
- Disable immediately on click
- Show a spinner ("Approving...", "Staking...", etc.)
- Stay disabled until the state update confirms the action completed
- Show success/error feedback when done
const [isApproving, setIsApproving] = useState(false);
const [isStaking, setIsStaking] = useState(false);
<button
disabled={isApproving}
onClick={async () => {
setIsApproving(true);
try {
await writeContractAsync({ functionName: "approve", args: [...] });
} catch (e) {
console.error(e);
notification.error("Approval failed");
} finally {
setIsApproving(false);
}
}}
>
{isApproving ? "Approving..." : "Approve"}
</button>
❌ NEVER use a single shared isLoading for multiple buttons. Each button gets its own loading state. A shared state causes the WRONG loading text to appear when UI conditionally switches between buttons.
Scaffold Hooks Only — Never Raw Wagmi
const { writeContractAsync } = useWriteContract();
await writeContractAsync({...});
const { writeContractAsync } = useScaffoldWriteContract("MyContract");
await writeContractAsync({...});
Why: useScaffoldWriteContract uses useTransactor internally, which waits for block confirmation. Raw wagmi doesn't — your UI will show "success" while the transaction is still in the mempool.
Rule 2: Four-State Flow — Connect → Network → Approve → Action
When a user needs to interact with the app, there are FOUR states. Show exactly ONE big, obvious button at a time:
1. Not connected? → Big "Connect Wallet" button (NOT text saying "connect your wallet to play")
2. Wrong network? → Big "Switch to Base" button
3. Not enough approved? → "Approve" button (with loader per Rule 1)
4. Enough approved? → "Stake" / "Deposit" / action button
NEVER show a text prompt like "Connect your wallet to play" or "Please connect to continue." Show a button. The user should always have exactly one thing to click.
const { data: allowance } = useScaffoldReadContract({
contractName: "Token",
functionName: "allowance",
args: [address, contractAddress],
});
const needsApproval = !allowance || allowance < amount;
const wrongNetwork = chain?.id !== targetChainId;
const notConnected = !address;
{notConnected ? (
<RainbowKitCustomConnectButton />
) : wrongNetwork ? (
<button onClick={switchNetwork} disabled={isSwitching}>
{isSwitching ? "Switching..." : "Switch to Base"}
</button>
) : needsApproval ? (
<button onClick={handleApprove} disabled={isApproving}>
{isApproving ? "Approving..." : "Approve $TOKEN"}
</button>
) : (
<button onClick={handleStake} disabled={isStaking}>
{isStaking ? "Staking..." : "Stake"}
</button>
)}
Critical details:
- Always read allowance via a hook so the UI updates automatically when the approval tx confirms
- Never rely on local state alone for allowance tracking
- Wrong network check comes FIRST — if the user clicks Approve while on the wrong network, everything breaks
- Never show Approve and Action simultaneously — one button at a time
Rule 3: Address Display — Always <Address/>
EVERY time you display an Ethereum address, use scaffold-eth's <Address/> component:
import { Address } from "~~/components/scaffold-eth";
<Address address={userAddress} />
<span>{userAddress}</span>
<p>0x1234...5678</p>
<Address/> handles ENS resolution, blockie avatars, copy-to-clipboard, truncation, and block explorer links. Raw hex is unacceptable.
Address Input — Always <AddressInput/>
EVERY time the user needs to enter an Ethereum address, use <AddressInput/>:
import { AddressInput } from "~~/components/scaffold-eth";
<AddressInput value={recipient} onChange={setRecipient} placeholder="Recipient address" />
<input type="text" value={recipient} onChange={e => setRecipient(e.target.value)} />
<AddressInput/> provides ENS resolution (type "vitalik.eth" → resolves to address), blockie avatar preview, validation, and paste handling.
The pair: <Address/> for DISPLAY, <AddressInput/> for INPUT. Always.
Show Your Contract Address
Every dApp should display its deployed contract address at the bottom of the main page using <Address/>. Users want to verify the contract on a block explorer. This builds trust and is standard practice.
<div className="text-center mt-8 text-sm opacity-70">
<p>Contract:</p>
<Address address={deployedContractAddress} />
</div>
Rule 4: USD Values Everywhere
EVERY token or ETH amount displayed should include its USD value.
EVERY token or ETH input should show a live USD preview.
<span>1,000 TOKEN (~$4.20)</span>
<span>0.5 ETH (~$1,250.00)</span>
<input value={amount} onChange={...} />
<span className="text-sm text-gray-500">
≈ ${(parseFloat(amount || "0") * tokenPrice).toFixed(2)} USD
</span>
<span>1,000 TOKEN</span>
Where to get prices:
- ETH price: SE2 built-in hook —
useNativeCurrencyPrice()
- Custom tokens: DexScreener API (
https://api.dexscreener.com/latest/dex/tokens/TOKEN_ADDRESS), onchain Uniswap quoter, or Chainlink oracle
This applies to both display AND input:
- Displaying a balance? Show USD next to it.
- User entering an amount to send/stake/swap? Show live USD preview below the input.
- Transaction confirmation? Show USD value of what they're about to do.
Rule 5: No Duplicate Titles
DO NOT put the app name as an <h1> at the top of the page body. The SE2 header already displays the app name. Repeating it wastes space and looks amateur.
<Header /> {}
<main>
<h1>🦞 My dApp</h1> {}
<p>Description of the app</p>
...
</main>
<Header /> {}
<main>
<div className="grid grid-cols-2 gap-4">
{/* Stats, balances, actions — no redundant title */}
</div>
</main>
Rule 6: RPC Configuration
NEVER use public RPCs (mainnet.base.org, etc.) — they rate-limit and cause random failures in production.
In scaffold.config.ts, ALWAYS set:
rpcOverrides: {
[chains.base.id]: process.env.NEXT_PUBLIC_BASE_RPC || "https://mainnet.base.org",
},
pollingInterval: 3000,
Keep the API key in .env.local — never hardcode it in config files that get committed to Git.
⚠️ SE2's wagmiConfig.tsx adds a bare http() (no URL) as a fallback transport. Viem resolves bare http() to the chain's default public RPC (e.g. mainnet.base.org for Base). Even with rpcOverrides set in scaffold config, the public RPC will still get hit because viem's fallback() fires transports in parallel. You must remove the bare http() from the fallback array in services/web3/wagmiConfig.tsx so only your configured RPCs are used. If you don't, your app will spam the public RPC with every poll cycle and get 429 rate-limited in production.
Monitor RPC usage: Sensible = 1 request every 3 seconds. If you see 15+ requests/second, you have a bug:
- Hooks re-rendering in loops
- Duplicate hook calls
- Missing dependency arrays
watch: true on hooks that don't need it
Rule 7: Use DaisyUI Semantic Colors — Never Hardcode Dark Backgrounds
AI agents love dark UIs and will hardcode them. This is always wrong:
<div className="min-h-screen bg-[#0a0a0a] text-white">
Why this is a problem: SE2 ships with DaisyUI configured for both light and dark themes (usually via globals.css or tailwind.config.ts). It also includes a <SwitchTheme/> toggle in the header. When you hardcode a dark background, you break all of this:
- Light-mode users (macOS/iOS/Windows system setting) get a black page
- The theme toggle does nothing — the page ignores
data-theme
prefers-color-scheme: light is silently ignored
Always use DaisyUI semantic color variables:
<div className="min-h-screen bg-base-200 text-base-content">
If you genuinely want dark-only, you must commit to it fully — don't half-do it:
<html data-theme="dark">
Quick scan for the mistake:
grep -rn 'bg-\[#0\|bg-black\|bg-gray-9\|bg-zinc-9\|bg-neutral-9\|bg-slate-9' packages/nextjs/app/
Any match on a root page wrapper → fix it.
Rule 8: Fix SE2's Pill-Shaped Form Inputs
SE2's DaisyUI theme sets --radius-field: 9999rem — fully pill-shaped inputs. Single-line inputs look fine, but textareas, multi-line inputs, and selects look broken — text clips against the extreme border radius.
AI agents never fix this. They see the DaisyUI class, assume it's correct, and ship pill-shaped textareas.
Fix it in the theme, not per-element. In packages/nextjs/styles/globals.css, change both themes:
--radius-field: 9999rem;
--radius-field: 0.5rem;
That's it — one line per theme, every input, select, and textarea inherits it globally. Do NOT add rounded-md to individual elements — that fights the theme system and breaks when the theme changes.
Quick check:
grep "radius-field" packages/nextjs/styles/globals.css
If it says 9999rem, fix it.
Rule 9: Contract Error Translation
When a contract reverts, the user must see a human-readable explanation. Not a hex selector. Not a silent button reset. Not a console.error.
The principle: Read your contract's ABI. Find every custom error. Map each one to plain English. Display it inline below the button that triggered it.
Steps:
- Extract all errors from your ABI — your contract's custom errors AND inherited ones (OpenZeppelin, etc.)
- Write a mapping function that takes a caught error and returns a user-facing string
- Include wallet-level errors — user rejected, insufficient gas
- Add a fallback — if you can't parse it, still show something ("Transaction failed")
- Display inline — a persistent alert below the button, not a toast. Clear it when the user edits an input.
try { await writeTx(...) }
catch (e) { console.error(e) }
try { await writeTx(...) }
catch (e) { setTxError(parseContractError(e)) }
{txError && (
<div className="mt-3 alert alert-error text-sm">
<span>{txError}</span>
</div>
)}
How to find your errors:
cat deployedContracts.ts | grep -o '"name":"[^"]*Error[^"]*"' | sort -u
grep -rn 'error ' contracts/src/ | grep -v '//'
Every error in that list needs a human-readable string in your frontend. If you inherit OpenZeppelin, their errors (ERC20InsufficientBalance, OwnableUnauthorizedAccount, etc.) are in YOUR ABI too — don't forget them.
Rule 10: Pre-Publish Checklist
BEFORE deploying frontend to production, EVERY item must pass:
Open Graph / Twitter Cards (REQUIRED):
export const metadata: Metadata = {
title: "Your App Name",
description: "Description of the app",
openGraph: {
title: "Your App Name",
description: "Description of the app",
images: [{ url: "https://YOUR-LIVE-DOMAIN.com/thumbnail.png" }],
},
twitter: {
card: "summary_large_image",
title: "Your App Name",
description: "Description of the app",
images: ["https://YOUR-LIVE-DOMAIN.com/thumbnail.png"],
},
};
⚠️ The OG image URL MUST be:
- Absolute URL starting with
https://
- The LIVE production domain (NOT
localhost, NOT relative path)
- NOT an environment variable that could be unset
- Actually reachable (test by visiting the URL in a browser)
Remove ALL Scaffold-ETH 2 default identity:
Full checklist:
externalContracts.ts — Before You Build
ALL external contracts (tokens, protocols, anything you didn't deploy) MUST be added to packages/nextjs/contracts/externalContracts.ts with address and ABI BEFORE building the frontend.
export default {
8453: {
USDC: {
address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
abi: [...],
},
},
} as const;
Why BEFORE: Scaffold hooks (useScaffoldReadContract, useScaffoldWriteContract) only work with contracts registered in deployedContracts.ts (auto-generated) or externalContracts.ts (manual). If you write frontend code referencing a contract that isn't registered, it silently fails.
Never edit deployedContracts.ts — it's auto-generated by yarn deploy. Put your external contracts in externalContracts.ts.
Human-Readable Amounts
Always convert between contract units and display units:
import { formatEther, formatUnits } from "viem";
formatEther(weiAmount);
formatUnits(usdcAmount, 6);
import { parseEther, parseUnits } from "viem";
parseEther("1.5");
parseUnits("100", 6);
Never show raw wei/units to users. 1500000000000000000 means nothing. 1.5 ETH (~$3,750) means everything.
Resources