Achievement

No dependencies
Code format
React + Tailwind
import { useState } from 'react';

const css = `
  @keyframes card-achievement-rays { from { transform: rotate(0deg) scale(0.4); opacity: 0; } 30% { opacity: 1; } to { transform: rotate(90deg) scale(1.3); opacity: 0; } }
  @keyframes card-achievement-spark { from { transform: rotate(var(--a)) translateY(0) scale(1); opacity: 1; } to { transform: rotate(var(--a)) translateY(-46px) scale(0); opacity: 0; } }
  .card-achievement {
    display: flex;
    align-items: center;
    gap: 14px;
    width: 250px;
    padding: 14px;
    border: none;
    border-radius: 18px;
    background: light-dark(#f3f4f7, #1a1c1f);
    box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
    font: inherit;
    text-align: left;
    cursor: pointer;
  }
  .card-achievement:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
  .card-achievement__badge { position: relative; flex: none; width: 56px; height: 56px; perspective: 300px; }
  .card-achievement__coin { position: absolute; inset: 0; transform-style: preserve-3d; transition: transform 0.7s cubic-bezier(0.34, 1.4, 0.64, 1); }
  .card-achievement[aria-pressed='true'] .card-achievement__coin { transform: rotateY(180deg); }
  .card-achievement__side {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    border-radius: 50%;
    backface-visibility: hidden;
  }
  .card-achievement__side--locked { background: light-dark(#ebedf0, #232529); color: light-dark(#6d7179, #7d8087); box-shadow: inset 0 0 0 2px light-dark(#dbdde1, #2e3035); }
  .card-achievement__side--won {
    transform: rotateY(180deg);
    background: radial-gradient(circle at 35% 30%, light-dark(#373a40, #f1f2f3), light-dark(#5f626a, #a7abb2) 70%);
    color: light-dark(#fbfcfd, #121417);
    box-shadow: inset 0 0 0 3px color-mix(in oklab, light-dark(#fbfcfd, #121417) 25%, transparent), 0 0 18px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 35%, transparent);
  }
  .card-achievement__side svg { width: 24px; height: 24px; }
  .card-achievement__rays {
    position: absolute;
    inset: -20px;
    border-radius: 50%;
    background: repeating-conic-gradient(color-mix(in oklab, light-dark(#373a40, #f1f2f3) 35%, transparent) 0 8deg, transparent 8deg 30deg);
    mask-image: radial-gradient(circle, transparent 30%, #000 40%, transparent 70%);
    opacity: 0;
    pointer-events: none;
  }
  .card-achievement[aria-pressed='true'] .card-achievement__rays { animation: card-achievement-rays 1s ease-out 0.3s; }
  .card-achievement__spark { position: absolute; left: 50%; top: 50%; width: 4px; height: 4px; margin: -2px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); opacity: 0; }
  .card-achievement[aria-pressed='true'] .card-achievement__spark { animation: card-achievement-spark 0.7s ease-out 0.35s; }
  .card-achievement__text { flex: 1; min-width: 0; }
  .card-achievement__title { color: light-dark(#373a40, #f1f2f3); font-size: 13px; font-weight: 600; }
  .card-achievement__desc { color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
  .card-achievement__xp { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
  .card-achievement__bar { flex: 1; height: 5px; border-radius: 3px; background: light-dark(#ebedf0, #232529); overflow: hidden; }
  .card-achievement__bar span { display: block; height: 100%; width: 40%; background: light-dark(#373a40, #f1f2f3); transition: width 0.9s cubic-bezier(0.22, 1, 0.36, 1) 0.4s; }
  .card-achievement[aria-pressed='true'] .card-achievement__bar span { width: 82%; }
  .card-achievement__gain { color: light-dark(#42454c, #e5e6e9); font-size: 10px; font-weight: 600; font-variant-numeric: tabular-nums; }
  @media (prefers-reduced-motion: reduce) {
    .card-achievement__coin, .card-achievement__bar span { transition-duration: 0.01s; }
    .card-achievement[aria-pressed='true'] .card-achievement__rays,
    .card-achievement[aria-pressed='true'] .card-achievement__spark { animation: none; }
  }
`;

const SPARKS = [0, 45, 90, 135, 180, 225, 270, 315];

export function AchievementCard() {
  const [won, setWon] = useState(false);
  return (
    <>
      <style>
        {css}
      </style>
      <button type="button" className="card-achievement" aria-pressed={won} onClick={() => setWon((w) => !w)}>
        <span className="card-achievement__badge" aria-hidden="true">
          <span className="card-achievement__rays" />
          {SPARKS.map((a) => (
            <span key={a} className="card-achievement__spark" style={{ '--a': `${a}deg` } as React.CSSProperties} />
          ))}
          <span className="card-achievement__coin">
            <span className="card-achievement__side card-achievement__side--locked">
              <svg viewBox="0 0 24 24" fill="none">
                <rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" strokeWidth="2" />
                <path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" strokeWidth="2" />
              </svg>
            </span>
            <span className="card-achievement__side card-achievement__side--won">
              <svg viewBox="0 0 24 24" fill="currentColor">
                <path d="M15 3a8 8 0 1 0 6 13A9 9 0 0 1 15 3Z" />
              </svg>
            </span>
          </span>
        </span>
        <span className="card-achievement__text">
          <span className="card-achievement__title">{won ? 'Night Owl unlocked' : 'Night Owl'}</span>
          <br />
          <span className="card-achievement__desc">Ship something after midnight</span>
          <span className="card-achievement__xp">
            <span className="card-achievement__bar">
              <span />
            </span>
            <span className="card-achievement__gain">{won ? '+250 XP' : 'Locked'}</span>
          </span>
        </span>
      </button>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/card-achievement
Source on GitHub

About this component

Click to unlock an achievement: the badge flips over from a lock to a glowing medal, rays burst behind it and sparks fly out, and the XP bar fills with the points gained. Click again to reset.

Dependencies
None
Difficulty
Intermediate
Triggers
click, 3d, spring, state change
Techniques
preserve 3d, conic gradient, keyframes, css variables
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

The whole card is one real <button> with aria-pressed. Under prefers-reduced-motion the flip and fill are instant and there are no rays or sparks.

More cards

See all
MOVIN
Bank Card
Revenue$48.2k
Users9.1k
Uptime99.98%
Response182 ms
Bento
Movin Air · MV 214
SFO08:40
JFK17:05
GateB12
Seat14A
Board08:10
Boarding Pass
Chat