Integrate Rewards Display
Build an interface for Morpho reward APY and claimable balances, including program details, live updates, empty states, and accessibility.
This tutorial shows you how to build user interfaces that display reward information from Merkl. A well-designed rewards display helps users understand their earnings and encourages engagement with your platform.
What to Display
A complete rewards integration should show:
- Vault/Market Level Rewards - The APR users can earn
- User Claimable Rewards - How much users have earned and can claim
- Combined APY/APR - Total yield including base + rewards
- Reward Token Information - What tokens are being distributed
- Claim Status - What's claimable now vs. pending
Displaying Vault Rewards
Fetching and Calculating Total APY
Reward APRs come from Merkl, not the Morpho API.
React Component Example
The user-claimable-rewards example below fetches Merkl data.
Visual Design Tips
Highlight Total Yield:
<div className="total-apy">
<span className="label">Total APY</span>
<span className="value">{totalApy.toFixed(2)}%</span>
</div>Show Breakdown on Hover/Click:
<Tooltip content={
<div>
<div>Base APY: {baseApy}%</div>
<div>MORPHO Rewards: +{morphoApr}%</div>
<div>Partner Rewards: +{partnerApr}%</div>
</div>
}>
<span className="apy">{totalApy}%</span>
</Tooltip>Use Badges for Rewards:
{rewards.length > 0 && (
<span className="rewards-badge">
+{rewardsApr.toFixed(1)}% Rewards
</span>
)}Displaying User Claimable Rewards
Fetching User Rewards
async function getUserAllClaimableRewards(userAddress: string, chainId: number) {
const merklRewards = await fetchMerklClaimable(userAddress, chainId);
return { merkl: merklRewards };
}
async function fetchMerklClaimable(userAddress: string, chainId: number) {
const response = await fetch(
`https://api.merkl.xyz/v4/users/${userAddress.toLowerCase()}/rewards?chainId=${chainId}`
);
const data = await response.json();
// The endpoint returns an array of per-chain objects; flatten to a list of rewards.
const rewards = data.flatMap((entry) => entry.rewards);
const claimable: Array<{
token: string;
amount: bigint;
symbol: string;
decimals: number;
}> = [];
for (const reward of rewards) {
const unclaimed = BigInt(reward.amount) - BigInt(reward.claimed);
claimable.push({
token: reward.token.address,
amount: unclaimed,
symbol: reward.token.symbol,
decimals: reward.token.decimals,
});
}
return claimable;
}
React Component for User Rewards
import { formatUnits } from "viem";
function UserRewardsDisplay({ userAddress, chainId }: {
userAddress: string;
chainId: number;
}) {
const [rewards, setRewards] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getUserAllClaimableRewards(userAddress, chainId)
.then(setRewards)
.finally(() => setLoading(false));
}, [userAddress, chainId]);
if (loading) return <div>Loading rewards...</div>;
if (!rewards || rewards.merkl.length === 0) {
return <div>No claimable rewards</div>;
}
return (
<div className="user-rewards">
<h3>Your Claimable Rewards</h3>
{rewards.merkl.map((reward) => (
<div key={reward.token} className="reward-row">
<span className="token">{reward.symbol}</span>
<span className="amount">
{formatUnits(reward.amount, reward.decimals)} {reward.symbol}
</span>
</div>
))}
{/* Show separate counts for transparency */}
<div className="source-breakdown">
<small>
Merkl: {rewards.merkl.length} rewards
</small>
</div>
{/* Claim button */}
<button onClick={() => handleClaimAll(userAddress, chainId)}>
Claim All Rewards
</button>
</div>
);
}Displaying Rewards in Tables
For portfolio or dashboard views:
function VaultPortfolio({ userAddress, chainId }: {
userAddress: string;
chainId: number;
}) {
const [vaults, setVaults] = useState<any[]>([]);
return (
<table>
<thead>
<tr>
<th>Vault</th>
<th>Your Deposit</th>
<th>Base APY</th>
<th>Rewards APR</th>
<th>Total APY</th>
<th>Claimable</th>
</tr>
</thead>
<tbody>
{vaults.map((vault) => (
<tr key={vault.address}>
<td>{vault.name}</td>
<td>
{vault.userDeposit} {vault.asset.symbol}
</td>
<td>{vault.baseApy.toFixed(2)}%</td>
<td className="rewards-apr">
+{vault.rewardsApr.toFixed(2)}%
</td>
<td className="total-apy">
<strong>{vault.totalApy.toFixed(2)}%</strong>
</td>
<td>
{vault.claimable > 0 ? (
<button>Claim</button>
) : (
<span>-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
);
}Showing Reward Program Details
Provide transparency about active programs:
function RewardProgramsCard({ vaultAddress, chainId }) {
const [programs, setPrograms] = useState<any[]>([]);
// Fetch active programs for this vault
useEffect(() => {
fetchActivePrograms(vaultAddress, chainId).then(setPrograms);
}, [vaultAddress, chainId]);
return (
<div className="reward-programs">
<h4>Active Reward Programs</h4>
{programs.map((program) => (
<div key={program.id} className="program-card">
<div className="header">
<img src={program.tokenIcon} alt={program.token} />
<span>{program.token} Rewards</span>
</div>
<div className="details">
<div>APR: {program.apr.toFixed(2)}%</div>
<div>Ends: {formatDate(program.endDate)}</div>
<div>Source: Merkl</div>
</div>
</div>
))}
</div>
);
}Real-Time Updates
Polling for Updates
function useRewardsPolling(userAddress: string, chainId: number) {
const [rewards, setRewards] = useState<any>(null);
useEffect(() => {
const fetchRewards = () => {
getUserAllClaimableRewards(userAddress, chainId).then(setRewards);
};
// Initial fetch
fetchRewards();
// Poll every 5 minutes for Merkl updates
const interval = setInterval(fetchRewards, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [userAddress, chainId]);
return rewards;
}Showing Accruing Rewards
For a more dynamic experience:
function AccruingRewardsDisplay({ baseAmount, ratePerSecond }) {
const [accrued, setAccrued] = useState(baseAmount);
useEffect(() => {
const interval = setInterval(() => {
setAccrued((prev) => prev + ratePerSecond);
}, 1000);
return () => clearInterval(interval);
}, [ratePerSecond]);
return (
<div className="accruing-rewards">
<span>Accruing: </span>
<span className="animated-number">
{accrued.toFixed(6)} MORPHO
</span>
</div>
);
}Handling Edge Cases
No Rewards Available
{rewards.length === 0 ? (
<div className="no-rewards">
<p>No active reward programs for this vault</p>
<p>Check back later or explore other vaults</p>
</div>
) : (
<RewardsList rewards={rewards} />
)}Failed to Fetch Rewards
{error ? (
<div className="error-state">
<p>Unable to load rewards data</p>
<button onClick={retry}>Retry</button>
</div>
) : (
<RewardsDisplay />
)}Dust Amounts
Don't show tiny amounts that aren't worth claiming:
const DUST_THRESHOLD = 0.01; // $0.01 USD
function filterDust(rewards: any[], tokenPrices: Record<string, number>) {
return rewards.filter((reward) => {
const usdValue =
parseFloat(formatUnits(reward.amount, 18)) * tokenPrices[reward.token];
return usdValue > DUST_THRESHOLD;
});
}Accessibility Considerations
- Use semantic HTML:
<table>for tabular data,<button>for actions - Provide labels: Screen readers should understand what APY/APR values mean
- Keyboard navigation: Ensure all interactive elements are keyboard-accessible
- Loading states: Clear indication when data is loading
<div role="status" aria-live="polite">
{loading ? "Loading rewards..." : `${rewards.length} rewards available`}
</div>Resources
- Design Inspiration: app.morpho.org
Next Steps
- Implement claiming: Claim Rewards tutorial