Frontend UX Rules
What You Probably Got Wrong
"The button works." A clickable button is not enough. It must disable immediately, show a clear pending state, and stay locked until onchain confirmation.
"Addresses are just strings." Address UX needs validation, safe formatting, copy support, explorer linking, and ENS/name handling where available.
"Token amounts are clear." Raw token values without USD context force users to guess risk and value. Show dollar context anywhere amounts matter.
Rule 1: Every Button Interacting Onchain Needs Its Own Pending State
Any button that triggers an onchain transaction must:
- Disable immediately on click
- Show spinner + action text (
Approving...,Staking...) - Stay disabled until chain state confirms completion
- Show success/error feedback when done
// Separate loading state per action
const [isApproving, setIsApproving] = useState(false);
const [isStaking, setIsStaking] = useState(false);
<button
disabled={isApproving}
() => {
setIsApproving(true);
try {
await sendApproveTx();
} catch (e) {
notifyError("Approval failed");
} finally {
setIsApproving(false); // always release — even on rejection
}
}}
>
{isApproving ? "Approving..." : "Approve"}
</button>
Never use one shared isLoading state for multiple buttons. It causes wrong labels, wrong disabled states, and duplicate submissions.
For approval flows: isPending alone is not enough.
isPending drops to false when the wallet returns the tx hash — before on-chain confirmation. There is a window where isPending = false AND the allowance hasn't updated → button re-enables mid-flight and a user can double-submit.
Approval handlers need two states: approvalSubmitting (set on click, cleared in finally {}) to cover the wallet→confirmation gap, and approveCooldown (set after confirm, cleared after 4s + refetch) to cover the confirmation→cache gap. Both go on disabled. finally {} is required — without it a rejected tx locks the button permanently.
Rule 2: Four-State Action Flow
Show one primary action at a time:
1. Not connected -> Connect Wallet
2. Wrong network -> Switch Network
3. Needs approval -> Approve
4. Ready -> Execute action (Stake/Deposit/Swap/etc.)
Critical details:
- Wrong-network check must happen before approval/action checks
- Never show Approve and Execute simultaneously
- Approval status must come from fresh onchain state (not stale local state only)
- Connection state must render a clickable action, not passive text
Rule 3: UX Standards for Addresses
Every displayed address should support:
- ENS/name resolution (where applicable)
- Explorer linking
- Copy-to-clipboard
- Safe truncation + visual identity (avatar/blockie optional)
Every address input should support:
- Validation
- Paste normalization
- ENS/name resolution where available
If your UI kit includes dedicated address components, use them. Do not use a raw free-text field for critical address entry.
Rule 4: Show USD Context for Token Values
Every token/ETH amount shown to users should include USD context:
- Balances
- Inputs (live preview)
- Confirmation text
- Position/portfolio summaries
<span>0.5 ETH (~$1,250.00)</span>
<span>1,000 TOKEN (~$4.20)</span>
Do not show only token units without value context.
Rule 5: RPC Reliability and Polling
- Use a dedicated RPC provider for production (not accidental public fallback only)
- Keep polling interval in a responsive range (typically ~2-5s for interactive apps)
- Ensure fallback transports are intentional and rate-limit aware
- Watch for runaway request patterns (render loops, duplicate watchers, unbounded polling)
Healthy baseline: low, steady request volume. Spiky or sustained high QPS usually indicates frontend hook/config bugs.
Rule 6: Theme Semantics, Not Hardcoded Dark Wrappers
Do not hardcode full-page dark backgrounds that ignore theme/system preference.
Use semantic theme tokens/classes so light/dark mode stays coherent across:
- Page background
- Surface layers
- Primary/secondary text
- Component states
If you intentionally ship dark-only, remove or disable theme controls that no longer apply.
Rule 7: Contract Error Translation
Users should never see raw revert selectors or silent failures.
Implement:
- Error extraction from ABI/custom errors
- Mapping to human-readable messages
- Wallet-level error handling (reject, gas issues)
- Safe fallback message
- Inline persistent error UI near the triggering action
try {
await sendTx();
} catch (e) {
setTxError(parseContractError(e));
}
Rule 8: Pre-Publish Metadata and Product Identity
Before production release:
- Open Graph image URL is absolute and reachable on live domain (
https://...) - OG/Twitter title + description are set correctly
- Social preview image is valid and not local/relative
- Template/default branding is removed or replaced with project identity
- Favicon and tab title reflect the app
Rule 9: Human-Readable Amounts and Decimals
Always convert between contract units and display units:
import { formatEther, formatUnits, parseEther, parseUnits } from "viem";
formatEther(weiAmount);
formatUnits(tokenAmount, tokenDecimals);
parseEther("1.5");
parseUnits("100", 6); // USDC-style 6 decimals
Never show raw base units like 1500000000000000000.
Before you finish: send a one-line note via feedback/SKILL.md — issue or praise.