trophy-ui
uiachievement-badgesource: ok · hit
Achievement Badge
Open source React achievement badge component with locked/unlocked states, progress rings and rarity support. Use for achievement UI design and collections. Built on shadcn/ui + Tailwind.
npx shadcn add "https://ui.trophy.so/r/achievement-badge.json"free
Where it goes
stats
Interaction
animated-counter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Files1 · source cached 10/15/2026, 8:35:59 AM
registry/trophy/ui/achievement-badge.tsx
"use client"
import * as React from "react"
import { Trophy } from "lucide-react"
import { cn } from "@/lib/utils"
interface Achievement {
id: string
name: string
trigger: "metric" | "api" | "streak"
badgeUrl?: string | null
progress?: number
rarity?: number
}
interface UserAchievement extends Achievement {
/** ISO date when earned, or `null` if locked */
achievedAt: string | null
}
interface AchievementBadgeProps extends React.HTMLAttributes<HTMLDivElement> {
achievement: UserAchievement
badgeSize?: "sm" | "default" | "lg" | "xl"
onAchievementClick?: (achievement: UserAchievement) => void
}
const badgeSizeMap = {
sm: "h-12 w-12",
default: "h-16 w-16",
lg: "h-20 w-20",
xl: "h-28 w-28",
} as const
const iconSizeMap = {
sm: "h-8 w-8",
default: "h-10 w-10",
lg: "h-12 w-12",
xl: "h-16 w-16",
} as const
const progressRingSizeMap = {
sm: 72,
default: 88,
lg: 104,
xl: 136,
} as const
const AchievementBadge = React.forwardRef<
HTMLDivElement,
AchievementBadgeProps
>(
(
{
className,
achievement,
badgeSize = "default",
onAchievementClick,
...props
},
ref
) => {
const isUnlocked = achievement.achievedAt !== null
const hasProgress = isUnlocked && typeof achievement.progress === "number"
const progress = hasProgress
? Math.min(100, Math.max(0, achievement.progress ?? 0))
: 0
const hasRarity = typeof achievement.rarity === "number"
const rarity = hasRarity
? Math.min(100, Math.max(1, Math.round(achievement.rarity ?? 1)))
: null
const ringSize = progressRingSizeMap[badgeSize]
const ringStrokeWidth = 4
const ringRadius = (ringSize - ringStrokeWidth) / 2
const ringCircumference = 2 * Math.PI * ringRadius
const ringDashoffset =
ringCircumference - (progress / 100) * ringCircumference
const statusLabel = isUnlocked ? "Earned" : "Locked"
const itemLabel = `${achievement.name} - ${statusLabel}`
return (
<div
ref={ref}
role={onAchievementClick ? "button" : "listitem"}
aria-label={onAchievementClick ? itemLabel : undefined}
tabIndex={onAchievementClick ? 0 : undefined}
onClick={() => onAchievementClick?.(achievement)}
onKeyDown={
onAchievementClick
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onAchievementClick(achievement)
}
}
: undefined
}
className={cn(
"bg-card flex flex-col items-center justify-center gap-2 rounded-lg border p-4",
onAchievementClick && "cursor-pointer",
!isUnlocked && "opacity-50",
className
)}
{...props}
>
<div
className="relative flex items-center justify-center"
style={{
width: hasProgress ? `${ringSize}px` : undefined,
height: hasProgress ? `${ringSize}px` : undefined,
}}
>
{hasProgress ? (
<svg
aria-hidden="true"
className="absolute inset-0 h-full w-full"
viewBox={`0 0 ${ringSize} ${ringSize}`}
>
<circle
cx={ringSize / 2}
cy={ringSize / 2}
r={ringRadius}
fill="none"
stroke="var(--primary)"
strokeLinecap="round"
strokeWidth={ringStrokeWidth}
strokeDasharray={ringCircumference}
strokeDashoffset={ringDashoffset}
transform={`rotate(-90 ${ringSize / 2} ${ringSize / 2})`}
/>
</svg>
) : null}
{achievement.badgeUrl ? (
<img
src={achievement.badgeUrl}
alt={`${achievement.name} badge - ${statusLabel}`}
className={cn(
badgeSizeMap[badgeSize],
"relative z-10 rounded-full object-cover",
!isUnlocked && "grayscale"
)}
/>
) : (
<div
aria-hidden="true"
className={cn(
badgeSizeMap[badgeSize],
"relative z-10 flex items-center justify-center rounded-full",
isUnlocked
? "bg-muted text-muted-foreground"
: "bg-primary text-primary-foreground"
)}
>
<Trophy className={iconSizeMap[badgeSize]} />
</div>
)}
</div>
{rarity !== null ? (
<span className="text-muted-foreground text-xs font-medium">
{rarity}% of users
</span>
) : null}
<span
className={cn(
"text-center text-sm leading-tight font-bold",
!isUnlocked && "text-muted-foreground"
)}
>
{achievement.name}
</span>
</div>
)
}
)
AchievementBadge.displayName = "AchievementBadge"
export { AchievementBadge }
export type { AchievementBadgeProps, Achievement, UserAchievement }