Skip to content
ilinxa/pro-ui

Team Trophy Shelf

alphav0.2.1

Gallery of earned team badges with honest locked slots, an optional count header, and a brief skippable reveal for new badges.

Category: GamificationUpdated: 2026-08-11Created: 2026-07-01Author: ilinxa

Context

The "what has this team accomplished?" surface the progress bar can't express — recognition + shared pride, not status. Takes Badge[] (awardedAt is the single earned/locked discriminator) + the owning team; lays earned tokens and locked slots in a responsive grid, shows an awarded-date tooltip on hover, and plays a diff-driven award reveal when a controlled badges update flips a badge's awardedAt on (SSR-safe — nothing animates on load; respects prefers-reduced-motion). Ships as a shadcn-style compound — headless TeamTrophyShelfRoot + flat parts (Grid, Header, Empty) + the standalone Tier-C TeamMilestoneBadge token + a React.lazy BadgeAwardOverlay — so the bare token falls out for free and animateAward=false / the bare-token path never load the award chunk. D-16 (celebration ownership): a host routing badge events to team-feedback-loop sets animateAward=false so the moment isn't celebrated twice; neither component triggers the other. Cooperative-only and team-scoped by design (D-08): no per-individual, inter-team, or public affordance. Portable: zero next/*, no app context, SSR-safe, imports no other registry component. Second component of the gamification-system.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/team-trophy-shelf

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/team-trophy-shelf-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

The trophy shelf

Earned + locked slots, header count, awarded-date on hover. Emits badges.viewed on first view.

Team Aurora trophies

4 / 9

Award reveal (diff-driven, non-blocking)

Flip a locked badge → earned to play the < 1s in-place reveal. Toggle animateAward or your OS reduced-motion setting to suppress it.

Team Aurora trophies

4 / 9
  • Kickoff
  • First playable build
  • Internal playtest
  • Vertical slice
  • Beta milestone
  • Content complete
  • Localization pass finished
  • Launch candidate
  • Shipped

States

Empty · all-earned · earned-only (showLocked=false)

Empty (no badges)

Team Aurora trophies

0 / 0

No trophies yet

Milestones your team completes will land here.

All earned

Team Aurora trophies

9 / 9
  • Kickoff
  • First playable build
  • Internal playtest
  • Vertical slice
  • Beta milestone
  • Content complete
  • Localization pass finished
  • Launch candidate
  • Shipped
Earned only (showLocked=false)

Team Aurora trophies

4 / 9
  • Kickoff
  • First playable build
  • Internal playtest
  • Vertical slice

Bare token, inline

TeamMilestoneBadge alone — no shelf chrome, no award overlay in the bundle.

  • Kickoff
  • First playable build
  • Internal playtest
  • Vertical slice

Composed / lighter (custom layout, no header)

Hand-assembled Root + Grid with animateAward={false} — the lazy award chunk never loads.

Team trophies

  • Kickoff
  • First playable build
  • Internal playtest
  • Vertical slice
  • Beta milestone
  • Content complete
  • Localization pass finished
  • Launch candidate
  • Shipped

Demo source

demo.tsxtsx
"use client"; import * as React from "react"; import { TeamTrophyShelf } from "./team-trophy-shelf";import { TeamMilestoneBadge } from "./parts/team-milestone-badge";import { TeamTrophyShelfGrid } from "./parts/team-trophy-shelf-grid";import { TeamTrophyShelfRoot } from "./parts/team-trophy-shelf-root";import {  EMPTY_BADGES,  TEAM_AURORA,  TEAM_AURORA_ALL_EARNED,  TEAM_AURORA_BADGES,} from "./dummy-data";import type { Badge } from "./types"; function Section({  title,  hint,  children,}: {  title: string;  hint?: string;  children: React.ReactNode;}) {  return (    <section className="flex flex-col gap-4 rounded-lg border border-border bg-card p-5">      <div className="flex flex-col gap-0.5">        <h3 className="text-sm font-semibold text-foreground">{title}</h3>        {hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}      </div>      {children}    </section>  );} /** Flips a locked badge → earned at runtime so the in-place reveal plays. */function AwardRevealDemo() {  const [badges, setBadges] = React.useState<Badge[]>(() =>    TEAM_AURORA_BADGES.map((b) => ({ ...b })),  );  const [animate, setAnimate] = React.useState(true);   const nextLocked = badges.find((b) => b.awardedAt == null);  const award = () => {    if (!nextLocked) return;    setBadges((prev) =>      prev.map((b) =>        b.id === nextLocked.id ? { ...b, awardedAt: "2026-04-01T12:00:00Z" } : b,      ),    );  };  const reset = () =>    setBadges(TEAM_AURORA_BADGES.map((b) => ({ ...b })));   return (    <div className="flex flex-col gap-3">      <div className="flex flex-wrap items-center gap-2">        <button          type="button"          onClick={award}          disabled={!nextLocked}          className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground disabled:opacity-50"        >          Award next milestone        </button>        <button          type="button"          onClick={reset}          className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-foreground"        >          Reset        </button>        <label className="ml-1 flex items-center gap-1.5 text-xs text-muted-foreground">          <input            type="checkbox"            checked={animate}            onChange={(e) => setAnimate(e.target.checked)}          />          animateAward        </label>      </div>      <TeamTrophyShelf team={TEAM_AURORA} badges={badges} animateAward={animate} />    </div>  );} export default function TeamTrophyShelfDemo() {  return (    <div className="mx-auto flex w-full max-w-2xl flex-col gap-5">      <Section        title="The trophy shelf"        hint="Earned + locked slots, header count, awarded-date on hover. Emits badges.viewed on first view."      >        <TeamTrophyShelf          team={TEAM_AURORA}          badges={TEAM_AURORA_BADGES}          onEvent={(e) => console.info("[demo] gamification event", e)}          onBadgeOpen={(b) => console.info("[demo] open badge", b.id)}        />      </Section>       <Section        title="Award reveal (diff-driven, non-blocking)"        hint="Flip a locked badge → earned to play the < 1s in-place reveal. Toggle animateAward or your OS reduced-motion setting to suppress it."      >        <AwardRevealDemo />      </Section>       <Section title="States" hint="Empty · all-earned · earned-only (showLocked=false)">        <div className="flex flex-col gap-6">          <div className="flex flex-col gap-1.5">            <span className="text-xs text-muted-foreground">Empty (no badges)</span>            <TeamTrophyShelf team={TEAM_AURORA} badges={EMPTY_BADGES} />          </div>          <div className="flex flex-col gap-1.5">            <span className="text-xs text-muted-foreground">All earned</span>            <TeamTrophyShelf team={TEAM_AURORA} badges={TEAM_AURORA_ALL_EARNED} />          </div>          <div className="flex flex-col gap-1.5">            <span className="text-xs text-muted-foreground">Earned only (showLocked=false)</span>            <TeamTrophyShelf              team={TEAM_AURORA}              badges={TEAM_AURORA_BADGES}              showLocked={false}              size="sm"            />          </div>        </div>      </Section>       <Section        title="Bare token, inline"        hint="TeamMilestoneBadge alone — no shelf chrome, no award overlay in the bundle."      >        <ul className="flex flex-wrap gap-4">          {TEAM_AURORA_BADGES.slice(0, 4).map((badge) => (            <li key={badge.id}>              <TeamMilestoneBadge badge={badge} size="sm" />            </li>          ))}        </ul>      </Section>       <Section        title="Composed / lighter (custom layout, no header)"        hint="Hand-assembled Root + Grid with animateAward={false} — the lazy award chunk never loads."      >        <TeamTrophyShelfRoot          team={TEAM_AURORA}          badges={TEAM_AURORA_BADGES}          animateAward={false}        >          <div className="rounded-xl border border-border p-4">            <h4 className="mb-3 font-mono text-xs uppercase tracking-wide text-muted-foreground">              Team trophies            </h4>            <TeamTrophyShelfGrid />          </div>        </TeamTrophyShelfRoot>      </Section>    </div>  );} 

Usage

When to use

Reach for TeamTrophyShelf when a team board needs a durable gallery of this team's earned milestone badges— the artifacts of progress, alongside honest locked slots for what's ahead. It complements the progress bar (the live rate) with recognition + shared pride. Badges belong to the team, not individuals, and the shelf renders only on the team board — never a public or inter-team surface, never a ranking.

Data

awardedAt is the single discriminator: present → earned (drives the awarded date + the diff-based reveal); absent → a locked slot.

interface Badge {
  id: string
  label: string
  awardedAt?: string   // ISO 8601; undefined → not yet earned (locked)
  milestoneId?: string // the milestone that earned it (optional link)
}

Basic example

import { TeamTrophyShelf } from "@/components/team-trophy-shelf"

<TeamTrophyShelf
  team={{ id: team.id, name: team.name }}
  badges={badges}              // earned + not-yet-earned slots
  showLocked                    // show the journey, not just the wins
  onEvent={track}               // { type: "badges.viewed", teamId, badgeId? }
  onBadgeOpen={(b) => openMilestone(b.milestoneId)}
/>

When the host flips a badge's awardedAt from undefinedto a timestamp, that badge plays a brief (< 1s), skippable, non-blocking reveal in place — no toast, no modal, no blocked input.

The bare token + lighter builds

import { TeamMilestoneBadge } from "@/components/team-trophy-shelf"

// Inline next to a milestone — no shelf chrome, no award chunk.
<TeamMilestoneBadge badge={badge} size="sm" />

It ships as a shadcn-style compound: TeamTrophyShelfRoot + TeamTrophyShelfGrid / TeamTrophyShelfHeader / TeamTrophyShelfEmpty + the Tier-C TeamMilestoneBadge. The award burst (BadgeAwardOverlay) is React.lazy, so the bare-token path and animateAward={false} never pull its chunk.

Notes

  • D-16 (celebration ownership): if you also route badge events to a team-feedback-loop overlay, set animateAward={false}here so the moment isn't celebrated twice. Neither component triggers the other.
  • Always visible + honest: showLocked (default true) renders the journey ahead; empty renders an encouraging state, not a dead panel.
  • SSR-safe: no badge animates on load; the reveal only plays after a controlled badges update. Respects prefers-reduced-motion.
  • Portable + team-scoped: no next/*, no per-member or inter-team affordance; only the shadcn tooltip/ badge / separator primitives + lucide glyphs.

Features

  • Responsive gallery of a team's earned milestone badges + honest locked slots (showLocked)
  • awardedAt is the single earned/locked discriminator; awarded-date on hover (tooltip)
  • Diff-driven, SSR-safe award reveal (<1s, skippable, non-blocking, reduced-motion-aware) — nothing animates on load
  • Standalone TeamMilestoneBadge token — usable inline with zero shelf scaffolding
  • badges.viewed telemetry (once on first view; with badgeId on open)
  • Cooperative + team-scoped: team-owned only, no ranking / per-member / inter-team surface — ever
  • Compound: headless Root + flat parts + React.lazy award overlay; bare-token path & animateAward=false drop the award chunk
  • D-16 celebration ownership — set animateAward=false to defer the moment to team-feedback-loop

Tags

team-trophy-shelfgamificationbadgesmilestonesachievementsteamcooperativetelemetry

Dependencies

shadcn primitives: tooltip, badge, separator
npm peer deps: lucide-react@^1.11.0