Componentticket-stub1.08 KB gzipZero dependencies

Trophy Shelf

Trophy shelf with earned/empty slots and drop-in animation.

Preview

Live Preview
Trophy2026

1/1 earned

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add trophy-shelf

Or install the full npm package:

npm install bigbullui
import { TrophyShelf } from "@/components/ui/trophy-shelf";

<TrophyShelf />

Usage guidance

Use Trophy Shelf when a ticket stub surface needs Trophy shelf with earned/empty slots and drop-in animation. Keep props minimal and prefer composition with sibling stubs.

Avoid Trophy Shelf for long form flows or dense data grids where a dedicated layout block fits better.

Source code

trophy-shelf.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export interface TrophyShelfProps extends React.HTMLAttributes<HTMLDivElement> {
  trophies: { id: string; label: string; year: string; earned?: boolean }[];
}

/** Trophy shelf display: earned awards + shine overlay + open slots. */
export function TrophyShelf({ trophies, className, ...props }: TrophyShelfProps) {
  return (
    <div className={cn("w-full max-w-sm", className)} {...props}>
      <style>{`@keyframes trIn { 0% { transform: translateY(-14px) scale(0.8); opacity: 0; } 70% { transform: translateY(2px) scale(1.05); opacity: 1; } 100% { transform: translateY(0) scale(1); opacity: 1; } }`}</style>
      <div className="rounded-lg border-2 border-dashed border-border bg-card p-4">
        <div className="flex items-end justify-around gap-2">
          {trophies.map((t, idx) => (
            <div key={t.id} className="flex flex-col items-center gap-1" style={{ width: `${100 / trophies.length}%` }}>
              <span
                className={cn("text-3xl", !t.earned && "opacity-25 grayscale")}
                style={t.earned ? { animation: `trIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both`, animationDelay: `${idx * 120}ms` } : undefined}
                aria-hidden="true"
              >
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="size-7">
                  <path d="M8 4h8v4a4 4 0 0 1-8 0V4Z" />
                  <path d="M8 5.5H5.5a2 2 0 0 0 2 4H8M16 5.5h2.5a2 2 0 0 1-2 4H16" />
                  <path d="M12 12v3.5" />
                  <path d="M9 19.5h6" />
                  <path d="M9.5 15.5h5V19h-5v-3.5Z" />
                  <path d="M7.5 21.5h9" strokeDasharray="2 1.6" />
                </svg>
              </span>
              <span className={cn("h-1 w-full rounded-full", t.earned ? "bg-accent" : "bg-border/40")} aria-hidden="true" />
              <span className={cn("text-center text-[9px] font-medium leading-tight", t.earned ? "text-foreground" : "text-muted-foreground")}>{t.label}</span>
              <span className="font-mono text-[8px] text-muted-foreground">{t.year}</span>
            </div>
          ))}
        </div>
        <p className="mt-2 text-center font-mono text-[9px] uppercase tracking-widest text-muted-foreground">
          {trophies.filter((t) => t.earned).length}/{trophies.length} earned
        </p>
      </div>
    </div>
  );
}

Props

PropTypeDescription
trophies{ id: string; label: string; year: string; earned?: boolean }[]