Team Trophy Shelf
alphav0.2.1Gallery of earned team badges with honest locked slots, an optional count header, and a brief skippable reveal for new badges.
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
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/team-trophy-shelfAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/team-trophy-shelf-fixturesCLI 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 / 9Award 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)
Team Aurora trophies
0 / 0No trophies yet
Milestones your team completes will land here.
Team Aurora trophies
9 / 9- Kickoff
- First playable build
- Internal playtest
- Vertical slice
- Beta milestone
- Content complete
- Localization pass finished
- Launch candidate
- Shipped
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
"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-loopoverlay, setanimateAward={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
badgesupdate. Respectsprefers-reduced-motion. - Portable + team-scoped: no
next/*, no per-member or inter-team affordance; only the shadcntooltip/badge/separatorprimitives + 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