shadimage
trophy-ui
uiachievement-listsource: ok · hit

Achievement List

Vertical React achievement list with icons, descriptions, and optional progress for dense achievement UI. Complements grids and cards in gamification dashboards. Open source, built on shadcn/ui + Tailwind.

npx shadcn add "https://ui.trophy.so/r/achievement-list.json"
free

Where it goes

kanbandashboard-shell

Use cases

dashboard

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Files1 · source cached 10/8/2026, 9:40:12 PM

registry/trophy/ui/achievement-list.tsx
"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Trophy } from "lucide-react"

import { cn } from "@/lib/utils"

interface Achievement {
  id: string
  name: string
  description?: string | null
  trigger: "metric" | "api" | "streak"
  badgeUrl?: string | null
  progress?: number
  rarity?: number
}

interface UserAchievement extends Achievement {
  achievedAt: string | null
}

const achievementListVariants = cva("flex flex-col", {
  variants: {
    columns: {
      2: "",
      3: "",
      4: "",
      auto: "",
    },
    gap: {
      sm: "gap-2",
      default: "gap-3",
      lg: "gap-4",
    },
  },
  defaultVariants: {
    columns: "auto",
    gap: "default",
  },
})

interface AchievementListProps
  extends
    React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof achievementListVariants> {
  achievements: UserAchievement[]
  badgeSize?: "sm" | "default" | "lg"
  lockedStyle?: "grayscale" | "silhouette" | "hidden"
  onAchievementClick?: (achievement: UserAchievement) => void
}

const badgeSizeMap = {
  sm: "h-10 w-10",
  default: "h-12 w-12",
  lg: "h-14 w-14",
} as const

const iconSizeMap = {
  sm: "h-5 w-5",
  default: "h-6 w-6",
  lg: "h-7 w-7",
} as const

const progressSizeMap = {
  sm: 42,
  default: 48,
  lg: 56,
} as const

const AchievementList = React.forwardRef<HTMLDivElement, AchievementListProps>(
  (
    {
      className,
      columns,
      gap,
      achievements,
      badgeSize = "default",
      lockedStyle = "grayscale",
      onAchievementClick,
      ...props
    },
    ref
  ) => {
    return (
      <div ref={ref} className={cn(className)} {...props}>
        <div
          role="list"
          aria-label="Achievements"
          className={achievementListVariants({ columns, gap })}
        >
          {achievements.map((achievement) => {
            const isUnlocked = achievement.achievedAt !== null
            const hasProgress =
              isUnlocked && typeof achievement.progress === "number"

            if (!isUnlocked && lockedStyle === "hidden") {
              return null
            }

            const progress = hasProgress
              ? Math.min(100, Math.max(0, achievement.progress ?? 0))
              : 0
            const progressSize = progressSizeMap[badgeSize]
            const progressStroke = 3
            const progressRadius = (progressSize - progressStroke) / 2
            const circumference = 2 * Math.PI * progressRadius
            const dashOffset = circumference - (progress / 100) * circumference

            return (
              <div
                key={achievement.id}
                role={onAchievementClick ? "button" : "listitem"}
                tabIndex={onAchievementClick ? 0 : undefined}
                onClick={() => onAchievementClick?.(achievement)}
                onKeyDown={
                  onAchievementClick
                    ? (e) => {
                        if (e.key === "Enter" || e.key === " ") {
                          e.preventDefault()
                          onAchievementClick(achievement)
                        }
                      }
                    : undefined
                }
                className={cn(
                  "bg-background flex items-center gap-4 rounded-2xl border px-4 py-3",
                  onAchievementClick && "cursor-pointer"
                )}
              >
                {achievement.badgeUrl ? (
                  <img
                    src={achievement.badgeUrl}
                    alt={achievement.name}
                    className={cn(
                      badgeSizeMap[badgeSize],
                      "shrink-0 rounded-xl object-cover",
                      !isUnlocked && lockedStyle === "grayscale" && "grayscale",
                      !isUnlocked &&
                        lockedStyle === "silhouette" &&
                        "opacity-30 brightness-0"
                    )}
                  />
                ) : (
                  <div
                    aria-hidden="true"
                    className={cn(
                      badgeSizeMap[badgeSize],
                      "flex shrink-0 items-center justify-center rounded-xl",
                      isUnlocked
                        ? "bg-muted text-muted-foreground"
                        : "bg-primary text-primary-foreground"
                    )}
                  >
                    <Trophy className={iconSizeMap[badgeSize]} />
                  </div>
                )}

                <div className="min-w-0 flex-1">
                  <p
                    className={cn(
                      "truncate text-base font-semibold",
                      !isUnlocked && "text-muted-foreground"
                    )}
                  >
                    {achievement.name}
                  </p>
                  <p className="text-muted-foreground truncate text-sm">
                    {achievement.description ?? "Complete the required steps"}
                  </p>
                </div>

                {hasProgress ? (
                  <div
                    className="relative shrink-0"
                    style={{ width: progressSize, height: progressSize }}
                  >
                    <svg
                      aria-hidden="true"
                      className="absolute inset-0 h-full w-full"
                      viewBox={`0 0 ${progressSize} ${progressSize}`}
                    >
                      <circle
                        cx={progressSize / 2}
                        cy={progressSize / 2}
                        r={progressRadius}
                        fill="none"
                        stroke="hsl(var(--muted))"
                        strokeWidth={progressStroke}
                      />
                      <circle
                        cx={progressSize / 2}
                        cy={progressSize / 2}
                        r={progressRadius}
                        fill="none"
                        stroke="var(--primary)"
                        strokeLinecap="round"
                        strokeWidth={progressStroke}
                        strokeDasharray={circumference}
                        strokeDashoffset={dashOffset}
                        transform={`rotate(-90 ${progressSize / 2} ${progressSize / 2})`}
                      />
                    </svg>
                    <div className="text-foreground absolute inset-0 grid place-items-center text-sm font-semibold">
                      {Math.round(progress)}%
                    </div>
                  </div>
                ) : null}
              </div>
            )
          })}
        </div>
      </div>
    )
  }
)
AchievementList.displayName = "AchievementList"

export { AchievementList, achievementListVariants }
export type { AchievementListProps, Achievement, UserAchievement }

More from trophy-ui

Browse all components in this library →