Gamification UI in React: XP, streaks, leaderboards
Build XP bars, streaks, daily rewards, achievements, leaderboards and loot reveals in React, and learn when each one helps users and when it turns into noise.
Published by Mekai Games
Gamification UI makes progress visible. A level, a streak or a rank tells users how far they have come and gives them a reason to come back tomorrow. The same patterns can also bury a product in badges and pop-ups that nobody reads. This guide covers the common patterns, when each one helps and when it turns into noise, and which component in the Game UI category covers it.
XP Bar
Game UIStreak
Game UIDaily Rewards
Game UIAchievement Toast
Game UILeaderboard
Game UILoot Reveal
Game UIXP and levels
Experience points turn many small actions into one number that only goes up. Levels cut that number into steps, so a user with 240 XP reads it as level 3 with a bar that is 40% full. This works when actions repeat and have roughly equal value: lessons, workouts, reviews, quests. It becomes noise when XP rewards things users do anyway, like opening the app, or when the number has no link to anything they care about.
XP Bar takes a single xp prop and derives the rest. The level is Math.floor(xp / perLevel) + 1, the bar shows progress inside the current level, and the badge springs and bursts when the level goes up. It only celebrates levels gained after it mounts, so a returning user with 340 XP sees level 4 without a burst on page load.
Here the free XP Bar is wired to state: the client shows the gain at once and the server sets the real total.
"use client";
import { useState } from "react";
import { PixelButton } from "@/components/ui/pixel-button";
import { XpBar } from "@/components/ui/xp-bar";
const LESSON_XP = 35;
export function LessonProgress({ initialXp }: { initialXp: number }) {
const [xp, setXp] = useState(initialXp);
const [saving, setSaving] = useState(false);
async function completeLesson() {
setSaving(true);
setXp((current) => current + LESSON_XP); // show the gain right away
try {
const res = await fetch("/api/xp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "lesson-complete" }),
});
if (!res.ok) throw new Error("XP not saved");
const data: { xp: number } = await res.json();
setXp(data.xp); // the server total wins
} catch {
setXp((current) => current - LESSON_XP);
} finally {
setSaving(false);
}
}
return (
<div className="flex max-w-sm flex-col gap-4">
<XpBar xp={xp} perLevel={100} />
<PixelButton tone="gold" onClick={completeLesson} disabled={saving}>
Complete lesson
</PixelButton>
</div>
);
}The client sends the event, not the amount, so nobody can post { amount: 500 }. /api/xp stands in for your own endpoint.
XP Bar uses a flat curve: every level costs perLevel XP. If your levels get more expensive as they go, edit the lines in the file that derive the level and the progress within it.
Streaks and daily rewards
A streak counts consecutive days with at least one action. It helps when the habit itself has value, like practicing a language or logging a workout. It turns into pressure when losing a 200-day streak to one missed day feels like a punishment, which is why many apps add a grace day. That rule lives in your backend; the UI only shows the result.
Streak shows a Monday to Sunday row and lights the first days tiles, with a flame that pops whenever days changes. Daily Rewards is a seven-day calendar: days before day show as collected, later days as locked, and today's tile pulses until the user claims it. Claiming pops the tile, bursts coins and calls onClaim(day, reward). Both are Pro components. Daily Rewards usage:
import { DailyRewards } from "@/components/ui/daily-rewards";
<DailyRewards
day={user.loginDay}
rewards={[
{ kind: "coins", amount: 100 },
{ kind: "gems", amount: 5 },
// …seven in total
]}
onClaim={(day, reward) => grant(reward)}
/>Daily Rewards keeps the claim in component state only, so after a reload today's tile is claimable again. Record the claim on your server when onClaim fires, reject a second claim for the same day, and render a come-back-tomorrow message instead of the panel once today is taken.
For streaks, store dates, not counts. Save each active day as a calendar date in the user's time zone and compute the streak from that list. A streak computed in UTC breaks the first time a user in Tokyo works out at 8 in the morning, which is still the previous day in UTC.
Achievements
An achievement marks a moment: first lesson finished, first 10 km run, first week without a missed day. It helps when it is rare and tied to real progress. It becomes noise when users unlock five badges in their first minute, because after that nobody reads them.
Achievement Toast slides in with a springy trophy badge and a single light sweep. Pass { key, title, name } to show one, a new key to replay, and null to hide it. It sits in an aria-live region, so screen readers announce it. It has no timer of its own: set achievement back to null after a few seconds, and queue unlocks that arrive together.
Leaderboards
A ranking gives users someone to beat. It works in small groups where people know each other or have a fair chance: a class, a team, a friends list, a weekly league. A global list where most users sit at rank 40,000 does the opposite, because the gap is too big to close.
Leaderboard sorts the players you pass by score, highlights the row marked isYou, and uses a layout animation so rows slide to their new place when scores change. Pass previousRanks, keyed by player id, to show arrows for who moved up or down. Two details from the source: the values in previousRanks are zero-based positions, and the rank number on each row is its position in the array you pass. A slice from the middle of a global table would show ranks 1 to 5, so pass a whole small group or edit the rank cell.
Loot, spins and reward reveals
A reveal stretches the moment between an action and its reward, so a small reward feels earned. Use it for rewards that happen a few times a day at most; on every action it gets in the way. Paid chance-based rewards are restricted in some countries, so check before you charge for them.
Loot Reveal shakes a chest, opens it and shows an item with a rarity glow. luck multiplies the odds of epic and legendary drops, and onOpen receives the rarity. Spin Wheel spins with a long slowdown, ticks its pointer at each segment boundary and calls onResult with the winning segment.
Buttons, lives, cooldowns and dialogue
A few smaller pieces fill in the rest of a gamified screen.
- Pixel Button, free: a chunky 8-bit button with a hard shadow that presses down. It accepts every native button attribute.
- Health Bar, free: drops instantly on a hit while a chip bar trails behind, and glows on heal. Outside games, it works as lives in a quiz.
- Cooldown Button: a radial sweep and a countdown after each use, with an optional hotkey. For a resend-code button, rate-limit on the server too: the wait only exists in the browser.
- Dialogue Box: a speaker tag, a portrait and text that types out line by line, for onboarding narrated by a mascot.
Where the data lives
Every component here is UI only. None of them stores XP, streak dates, claimed rewards or rankings, and none of them talks to a server. Your app owns that state and passes it in as props; the components animate the change. What your backend keeps:
- XP: a total per user, awarded on the server from events.
- Streaks: active dates in the user's time zone, plus any grace-day rule.
- Daily rewards: each claim with its date, with duplicates rejected.
- Leaderboards: ranks from the database, plus the previous positions for the movement arrows.
To try the free pieces, add them with the shadcn CLI. Installation covers the setup. Pro components come from the private GitHub repository you are invited to after purchase.
npx shadcn@latest add https://ui.mekaigames.com/r/xp-bar.json
npx shadcn@latest add https://ui.mekaigames.com/r/pixel-button.json
npx shadcn@latest add https://ui.mekaigames.com/r/health-bar.jsonStart with one pattern that matches something your users already do, check whether they come back more often, and add the next one only if they do.
Components in this guide
More guides
- Animated hero backgrounds in Next.jsAdd an animated background behind a Next.js App Router hero: choose DOM, canvas or WebGL, stack content on top, keep text readable and spare the battery.
- Text animations in React: which one to usePick a React text animation by its job: entrance, scroll reveal, CTA highlight, stats or decoding state, with screen reader text and reduced motion built in.
- Respecting prefers-reduced-motion in ReactRead prefers-reduced-motion in React with Motion or CSS, decide what reduced should mean, pause canvas and WebGL loops, and test the setting in Chrome DevTools.
- Install React components from a shadcn registryHow the shadcn CLI installs components from a custom registry URL, what lands in your project, and how to add, update and edit Mekai UI components.