{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "achievement-list",
  "title": "Achievement List",
  "description": "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.",
  "dependencies": [
    "class-variance-authority",
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/trophy/ui/achievement-list.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { Trophy } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\ninterface Achievement {\n  id: string\n  name: string\n  description?: string | null\n  trigger: \"metric\" | \"api\" | \"streak\"\n  badgeUrl?: string | null\n  progress?: number\n  rarity?: number\n}\n\ninterface UserAchievement extends Achievement {\n  achievedAt: string | null\n}\n\nconst achievementListVariants = cva(\"flex flex-col\", {\n  variants: {\n    columns: {\n      2: \"\",\n      3: \"\",\n      4: \"\",\n      auto: \"\",\n    },\n    gap: {\n      sm: \"gap-2\",\n      default: \"gap-3\",\n      lg: \"gap-4\",\n    },\n  },\n  defaultVariants: {\n    columns: \"auto\",\n    gap: \"default\",\n  },\n})\n\ninterface AchievementListProps\n  extends\n    React.HTMLAttributes<HTMLDivElement>,\n    VariantProps<typeof achievementListVariants> {\n  achievements: UserAchievement[]\n  badgeSize?: \"sm\" | \"default\" | \"lg\"\n  lockedStyle?: \"grayscale\" | \"silhouette\" | \"hidden\"\n  onAchievementClick?: (achievement: UserAchievement) => void\n}\n\nconst badgeSizeMap = {\n  sm: \"h-10 w-10\",\n  default: \"h-12 w-12\",\n  lg: \"h-14 w-14\",\n} as const\n\nconst iconSizeMap = {\n  sm: \"h-5 w-5\",\n  default: \"h-6 w-6\",\n  lg: \"h-7 w-7\",\n} as const\n\nconst progressSizeMap = {\n  sm: 42,\n  default: 48,\n  lg: 56,\n} as const\n\nconst AchievementList = React.forwardRef<HTMLDivElement, AchievementListProps>(\n  (\n    {\n      className,\n      columns,\n      gap,\n      achievements,\n      badgeSize = \"default\",\n      lockedStyle = \"grayscale\",\n      onAchievementClick,\n      ...props\n    },\n    ref\n  ) => {\n    return (\n      <div ref={ref} className={cn(className)} {...props}>\n        <div\n          role=\"list\"\n          aria-label=\"Achievements\"\n          className={achievementListVariants({ columns, gap })}\n        >\n          {achievements.map((achievement) => {\n            const isUnlocked = achievement.achievedAt !== null\n            const hasProgress =\n              isUnlocked && typeof achievement.progress === \"number\"\n\n            if (!isUnlocked && lockedStyle === \"hidden\") {\n              return null\n            }\n\n            const progress = hasProgress\n              ? Math.min(100, Math.max(0, achievement.progress ?? 0))\n              : 0\n            const progressSize = progressSizeMap[badgeSize]\n            const progressStroke = 3\n            const progressRadius = (progressSize - progressStroke) / 2\n            const circumference = 2 * Math.PI * progressRadius\n            const dashOffset = circumference - (progress / 100) * circumference\n\n            return (\n              <div\n                key={achievement.id}\n                role={onAchievementClick ? \"button\" : \"listitem\"}\n                tabIndex={onAchievementClick ? 0 : undefined}\n                onClick={() => onAchievementClick?.(achievement)}\n                onKeyDown={\n                  onAchievementClick\n                    ? (e) => {\n                        if (e.key === \"Enter\" || e.key === \" \") {\n                          e.preventDefault()\n                          onAchievementClick(achievement)\n                        }\n                      }\n                    : undefined\n                }\n                className={cn(\n                  \"bg-background flex items-center gap-4 rounded-2xl border px-4 py-3\",\n                  onAchievementClick && \"cursor-pointer\"\n                )}\n              >\n                {achievement.badgeUrl ? (\n                  <img\n                    src={achievement.badgeUrl}\n                    alt={achievement.name}\n                    className={cn(\n                      badgeSizeMap[badgeSize],\n                      \"shrink-0 rounded-xl object-cover\",\n                      !isUnlocked && lockedStyle === \"grayscale\" && \"grayscale\",\n                      !isUnlocked &&\n                        lockedStyle === \"silhouette\" &&\n                        \"opacity-30 brightness-0\"\n                    )}\n                  />\n                ) : (\n                  <div\n                    aria-hidden=\"true\"\n                    className={cn(\n                      badgeSizeMap[badgeSize],\n                      \"flex shrink-0 items-center justify-center rounded-xl\",\n                      isUnlocked\n                        ? \"bg-muted text-muted-foreground\"\n                        : \"bg-primary text-primary-foreground\"\n                    )}\n                  >\n                    <Trophy className={iconSizeMap[badgeSize]} />\n                  </div>\n                )}\n\n                <div className=\"min-w-0 flex-1\">\n                  <p\n                    className={cn(\n                      \"truncate text-base font-semibold\",\n                      !isUnlocked && \"text-muted-foreground\"\n                    )}\n                  >\n                    {achievement.name}\n                  </p>\n                  <p className=\"text-muted-foreground truncate text-sm\">\n                    {achievement.description ?? \"Complete the required steps\"}\n                  </p>\n                </div>\n\n                {hasProgress ? (\n                  <div\n                    className=\"relative shrink-0\"\n                    style={{ width: progressSize, height: progressSize }}\n                  >\n                    <svg\n                      aria-hidden=\"true\"\n                      className=\"absolute inset-0 h-full w-full\"\n                      viewBox={`0 0 ${progressSize} ${progressSize}`}\n                    >\n                      <circle\n                        cx={progressSize / 2}\n                        cy={progressSize / 2}\n                        r={progressRadius}\n                        fill=\"none\"\n                        stroke=\"hsl(var(--muted))\"\n                        strokeWidth={progressStroke}\n                      />\n                      <circle\n                        cx={progressSize / 2}\n                        cy={progressSize / 2}\n                        r={progressRadius}\n                        fill=\"none\"\n                        stroke=\"var(--primary)\"\n                        strokeLinecap=\"round\"\n                        strokeWidth={progressStroke}\n                        strokeDasharray={circumference}\n                        strokeDashoffset={dashOffset}\n                        transform={`rotate(-90 ${progressSize / 2} ${progressSize / 2})`}\n                      />\n                    </svg>\n                    <div className=\"text-foreground absolute inset-0 grid place-items-center text-sm font-semibold\">\n                      {Math.round(progress)}%\n                    </div>\n                  </div>\n                ) : null}\n              </div>\n            )\n          })}\n        </div>\n      </div>\n    )\n  }\n)\nAchievementList.displayName = \"AchievementList\"\n\nexport { AchievementList, achievementListVariants }\nexport type { AchievementListProps, Achievement, UserAchievement }\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}