Achievement
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardAchievement(el) on the mounted element instead.
<button type="button" class="card-achievement" aria-pressed="false">
<span class="card-achievement__badge" aria-hidden="true">
<span class="card-achievement__rays"></span>
<span class="card-achievement__spark" style="--a:0deg"></span>
<span class="card-achievement__spark" style="--a:45deg"></span>
<span class="card-achievement__spark" style="--a:90deg"></span>
<span class="card-achievement__spark" style="--a:135deg"></span>
<span class="card-achievement__spark" style="--a:180deg"></span>
<span class="card-achievement__spark" style="--a:225deg"></span>
<span class="card-achievement__spark" style="--a:270deg"></span>
<span class="card-achievement__spark" style="--a:315deg"></span>
<span class="card-achievement__coin">
<span class="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" stroke-width="2"></rect>
<path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="2"></path>
</svg>
</span>
<span class="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"></path>
</svg>
</span>
</span>
</span>
<span class="card-achievement__text">
<span class="card-achievement__title">Night Owl</span>
<br/>
<span class="card-achievement__desc">Ship something after midnight</span>
<span class="card-achievement__xp">
<span class="card-achievement__bar">
<span></span>
</span>
<span class="card-achievement__gain">Locked</span>
</span>
</span>
</button>@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; }
}function initCardAchievement(root) {
var title = root.querySelector('.card-achievement__title');
var gain = root.querySelector('.card-achievement__gain');
if (!title || !gain) return;
function click() {
var won = root.getAttribute('aria-pressed') !== 'true';
root.setAttribute('aria-pressed', String(won));
title.textContent = won ? 'Night Owl unlocked' : 'Night Owl';
gain.textContent = won ? '+250 XP' : 'Locked';
}
root.addEventListener('click', click);
return function () { root.removeEventListener('click', click); };
}
document.querySelectorAll('.card-achievement').forEach(initCardAchievement);Goal: Create a "Achievement" card.
Visual behavior: 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.
Technique:
- The coin has two backface-hidden sides in preserve-3d; aria-pressed="true" rotates it rotateY(180deg) on a springy curve.
- Rays are a repeating-conic-gradient masked to a ring that scales and turns once; eight sparks fly out along their own angle (--a) and shrink.
- The XP bar width transitions to the new level after a short delay, and the title and gain text switch.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Achievement" card.
Visual behavior: 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.
Technique:
- The coin has two backface-hidden sides in preserve-3d; aria-pressed="true" rotates it rotateY(180deg) on a springy curve.
- Rays are a repeating-conic-gradient masked to a ring that scales and turns once; eight sparks fly out along their own angle (--a) and shrink.
- The XP bar width transitions to the new level after a short delay, and the title and gain text switch.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.