Ridge walk, 6am. Double-click the photo.
Like
import { useState } from 'react';
const BASE = 1204;
const HEART = 'M12 20.5s-7.5-4.6-9.3-9.2C1.5 8 3.6 4.5 7.2 4.5c2 0 3.4 1.1 4.8 2.9 1.4-1.8 2.8-2.9 4.8-2.9 3.6 0 5.7 3.5 4.5 6.8-1.8 4.6-9.3 9.2-9.3 9.2z';
const css = `
.card-like { box-sizing: border-box; width: 240px; padding: 14px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.card-like__photo { position: relative; width: 212px; height: 112px; border-radius: 10px; overflow: hidden; cursor: pointer; user-select: none; -webkit-user-select: none; }
.card-like__photo svg.card-like__scene { display: block; width: 100%; height: 100%; }
.card-like__sky { fill: light-dark(#ebedf0, #232529); }
.card-like__far { fill: light-dark(#cbced3, #393c41); }
.card-like__near { fill: light-dark(#6d7179, #7d8087); }
.card-like__sun { fill: light-dark(#50535b, #c6c8cd); }
.card-like__big { position: absolute; left: 50%; top: 50%; width: 54px; height: 54px; margin: -27px 0 0 -27px; color: light-dark(#373a40, #f1f2f3); filter: drop-shadow(0 4px 10px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35))); animation: card-like-pop 0.9s cubic-bezier(0.3, 1.5, 0.5, 1) forwards; }
@keyframes card-like-pop {
0% { transform: scale(0); opacity: 0; }
25% { transform: scale(1.2); opacity: 1; }
40% { transform: scale(0.95); }
70% { transform: scale(1); opacity: 1; }
100% { transform: scale(1.1) translateY(-12px); opacity: 0; }
}
.card-like__bar { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.card-like__btn { position: relative; display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 50%; background: none; color: light-dark(#42454c, #e5e6e9); cursor: pointer; }
.card-like__btn:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 1px; }
.card-like__btn svg { width: 20px; height: 20px; }
.card-like__btn path { fill: transparent; stroke: currentColor; stroke-width: 1.8; transition: fill 0.2s; }
.card-like__btn[aria-pressed='true'] { color: light-dark(#373a40, #f1f2f3); }
.card-like__btn[aria-pressed='true'] path { fill: currentColor; }
.card-like__btn[aria-pressed='true'] svg { animation: card-like-beat 0.45s cubic-bezier(0.3, 1.8, 0.5, 1); }
@keyframes card-like-beat {
from { transform: scale(0.4); }
}
/* Six dots fly out from the heart when it fills. */
.card-like__spark { position: absolute; left: 50%; top: 50%; width: 4px; height: 4px; margin: -2px; border-radius: 50%; background: light-dark(#50535b, #c6c8cd); opacity: 0; }
.card-like__btn[aria-pressed='true'] .card-like__spark { animation: card-like-spark 0.5s ease-out; }
@keyframes card-like-spark {
0% { opacity: 1; transform: rotate(var(--a)) translateY(-6px) scale(1); }
100% { opacity: 0; transform: rotate(var(--a)) translateY(-18px) scale(0.4); }
}
.card-like__count { position: relative; display: inline-grid; overflow: hidden; height: 16px; font-size: 12px; font-weight: 600; line-height: 16px; color: light-dark(#373a40, #f1f2f3); font-variant-numeric: tabular-nums; }
.card-like__num { animation: card-like-roll 0.35s cubic-bezier(0.3, 1.3, 0.5, 1); }
.card-like__num--down { animation-name: card-like-roll-down; }
@keyframes card-like-roll { from { transform: translateY(100%); } }
@keyframes card-like-roll-down { from { transform: translateY(-100%); } }
.card-like__cap { margin: 2px 0 0; font-size: 11px; color: light-dark(#5f626a, #a7abb2); }
@media (prefers-reduced-motion: reduce) {
.card-like__big { animation: card-like-fade 0.9s forwards; }
@keyframes card-like-fade { 70% { opacity: 1; } 100% { opacity: 0; } }
.card-like__btn[aria-pressed='true'] svg, .card-like__btn[aria-pressed='true'] .card-like__spark, .card-like__num { animation: none; }
}
`;
export function LikeCard() {
const [liked, setLiked] = useState(false);
const [pop, setPop] = useState(0);
const n = BASE + (liked ? 1 : 0);
return (
<>
<style>
{css}
</style>
<article className="card-like">
{/* Double-click likes (never unlikes) and pops the big heart every time. */}
<div
className="card-like__photo"
onDoubleClick={() => {
setLiked(true);
setPop((p) => p + 1);
}}
aria-hidden="true"
>
<svg className="card-like__scene" viewBox="0 0 212 112">
<rect className="card-like__sky" width={212} height={112} />
<circle className="card-like__sun" cx={150} cy={44} r={13} />
<path className="card-like__far" d="M0 82 L36 58 L70 72 L112 46 L150 70 L186 54 L212 64 V112 H0 Z" />
<path className="card-like__near" d="M0 96 Q56 76 108 92 T212 86 V112 H0 Z" />
</svg>
{pop > 0 && (
<svg key={pop} className="card-like__big" viewBox="0 0 24 24">
<path d={HEART} fill="currentColor" />
</svg>
)}
</div>
<div className="card-like__bar">
<button type="button" className="card-like__btn" aria-pressed={liked} aria-label="Like" onClick={() => setLiked((l) => !l)}>
<svg key={String(liked)} viewBox="0 0 24 24" aria-hidden="true">
<path d={HEART} />
</svg>
{[0, 60, 120, 180, 240, 300].map((a) => (
<span key={a} className="card-like__spark" style={{ '--a': `${a}deg` } as React.CSSProperties} aria-hidden="true" />
))}
</button>
<span className="card-like__count">
<span key={n} className={`card-like__num${liked ? '' : ' card-like__num--down'}`}>
{n.toLocaleString('en-US')} likes
</span>
</span>
</div>
<p className="card-like__cap">Ridge walk, 6am. Double-click the photo.</p>
</article>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardLike(el) on the mounted element instead.
<article class="card-like">
<div class="card-like__photo" aria-hidden="true">
<svg class="card-like__scene" viewBox="0 0 212 112">
<rect class="card-like__sky" width="212" height="112"></rect>
<circle class="card-like__sun" cx="150" cy="44" r="13"></circle>
<path class="card-like__far" d="M0 82 L36 58 L70 72 L112 46 L150 70 L186 54 L212 64 V112 H0 Z"></path>
<path class="card-like__near" d="M0 96 Q56 76 108 92 T212 86 V112 H0 Z"></path>
</svg>
</div>
<div class="card-like__bar">
<button type="button" class="card-like__btn" aria-pressed="false" aria-label="Like">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 20.5s-7.5-4.6-9.3-9.2C1.5 8 3.6 4.5 7.2 4.5c2 0 3.4 1.1 4.8 2.9 1.4-1.8 2.8-2.9 4.8-2.9 3.6 0 5.7 3.5 4.5 6.8-1.8 4.6-9.3 9.2-9.3 9.2z"></path>
</svg>
<span class="card-like__spark" style="--a:0deg" aria-hidden="true"></span>
<span class="card-like__spark" style="--a:60deg" aria-hidden="true"></span>
<span class="card-like__spark" style="--a:120deg" aria-hidden="true"></span>
<span class="card-like__spark" style="--a:180deg" aria-hidden="true"></span>
<span class="card-like__spark" style="--a:240deg" aria-hidden="true"></span>
<span class="card-like__spark" style="--a:300deg" aria-hidden="true"></span>
</button>
<span class="card-like__count">
<span class="card-like__num card-like__num--down">1,204 likes</span>
</span>
</div>
<p class="card-like__cap">Ridge walk, 6am. Double-click the photo.</p>
</article>.card-like { box-sizing: border-box; width: 240px; padding: 14px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.card-like__photo { position: relative; width: 212px; height: 112px; border-radius: 10px; overflow: hidden; cursor: pointer; user-select: none; -webkit-user-select: none; }
.card-like__photo svg.card-like__scene { display: block; width: 100%; height: 100%; }
.card-like__sky { fill: light-dark(#ebedf0, #232529); }
.card-like__far { fill: light-dark(#cbced3, #393c41); }
.card-like__near { fill: light-dark(#6d7179, #7d8087); }
.card-like__sun { fill: light-dark(#50535b, #c6c8cd); }
.card-like__big { position: absolute; left: 50%; top: 50%; width: 54px; height: 54px; margin: -27px 0 0 -27px; color: light-dark(#373a40, #f1f2f3); filter: drop-shadow(0 4px 10px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35))); animation: card-like-pop 0.9s cubic-bezier(0.3, 1.5, 0.5, 1) forwards; }
@keyframes card-like-pop {
0% { transform: scale(0); opacity: 0; }
25% { transform: scale(1.2); opacity: 1; }
40% { transform: scale(0.95); }
70% { transform: scale(1); opacity: 1; }
100% { transform: scale(1.1) translateY(-12px); opacity: 0; }
}
.card-like__bar { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.card-like__btn { position: relative; display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 50%; background: none; color: light-dark(#42454c, #e5e6e9); cursor: pointer; }
.card-like__btn:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 1px; }
.card-like__btn svg { width: 20px; height: 20px; }
.card-like__btn path { fill: transparent; stroke: currentColor; stroke-width: 1.8; transition: fill 0.2s; }
.card-like__btn[aria-pressed='true'] { color: light-dark(#373a40, #f1f2f3); }
.card-like__btn[aria-pressed='true'] path { fill: currentColor; }
.card-like__btn[aria-pressed='true'] svg { animation: card-like-beat 0.45s cubic-bezier(0.3, 1.8, 0.5, 1); }
@keyframes card-like-beat {
from { transform: scale(0.4); }
}
/* Six dots fly out from the heart when it fills. */
.card-like__spark { position: absolute; left: 50%; top: 50%; width: 4px; height: 4px; margin: -2px; border-radius: 50%; background: light-dark(#50535b, #c6c8cd); opacity: 0; }
.card-like__btn[aria-pressed='true'] .card-like__spark { animation: card-like-spark 0.5s ease-out; }
@keyframes card-like-spark {
0% { opacity: 1; transform: rotate(var(--a)) translateY(-6px) scale(1); }
100% { opacity: 0; transform: rotate(var(--a)) translateY(-18px) scale(0.4); }
}
.card-like__count { position: relative; display: inline-grid; overflow: hidden; height: 16px; font-size: 12px; font-weight: 600; line-height: 16px; color: light-dark(#373a40, #f1f2f3); font-variant-numeric: tabular-nums; }
.card-like__num { animation: card-like-roll 0.35s cubic-bezier(0.3, 1.3, 0.5, 1); }
.card-like__num--down { animation-name: card-like-roll-down; }
@keyframes card-like-roll { from { transform: translateY(100%); } }
@keyframes card-like-roll-down { from { transform: translateY(-100%); } }
.card-like__cap { margin: 2px 0 0; font-size: 11px; color: light-dark(#5f626a, #a7abb2); }
@media (prefers-reduced-motion: reduce) {
.card-like__big { animation: card-like-fade 0.9s forwards; }
@keyframes card-like-fade { 70% { opacity: 1; } 100% { opacity: 0; } }
.card-like__btn[aria-pressed='true'] svg, .card-like__btn[aria-pressed='true'] .card-like__spark, .card-like__num { animation: none; }
}function initCardLike(root) {
var BASE = 1204;
var HEART = 'M12 20.5s-7.5-4.6-9.3-9.2C1.5 8 3.6 4.5 7.2 4.5c2 0 3.4 1.1 4.8 2.9 1.4-1.8 2.8-2.9 4.8-2.9 3.6 0 5.7 3.5 4.5 6.8-1.8 4.6-9.3 9.2-9.3 9.2z';
var photo = root.querySelector('.card-like__photo');
var btn = root.querySelector('.card-like__btn');
var num = root.querySelector('.card-like__num');
var liked = false;
function set(next) {
if (next === liked) return;
liked = next;
btn.setAttribute('aria-pressed', String(liked));
// Fresh copies replay the heart's beat and the count's roll; the sparks run off aria-pressed in the CSS.
var heart = btn.querySelector('svg');
heart.replaceWith(heart.cloneNode(true));
var fresh = document.createElement('span');
fresh.className = 'card-like__num' + (liked ? '' : ' card-like__num--down');
fresh.textContent = (BASE + (liked ? 1 : 0)).toLocaleString('en-US') + ' likes';
num.replaceWith(fresh);
num = fresh;
}
// Double-click likes (never unlikes) and pops the big heart every time.
function onDouble() {
set(true);
var old = photo.querySelector('.card-like__big');
if (old) old.remove();
photo.insertAdjacentHTML('beforeend', '<svg class="card-like__big" viewBox="0 0 24 24"><path d="' + HEART + '" fill="currentColor"></path></svg>');
}
function onClick() {
set(!liked);
}
photo.addEventListener('dblclick', onDouble);
btn.addEventListener('click', onClick);
return function () {
photo.removeEventListener('dblclick', onDouble);
btn.removeEventListener('click', onClick);
};
}
document.querySelectorAll('.card-like').forEach(initCardLike);Goal: Create a "Like" card.
Visual behavior: A photo card you like by double-clicking the picture: a big heart pops over it and floats away, the small heart below fills with a burst of sparks, and the count rolls up.
Technique:
- Each double-click adds a fresh big heart over the photo that runs one keyframe: it scales up past full size, settles, then rises and fades.
- The small heart fills and beats when pressed, and six dots placed by an --a angle custom property fly out from it on the same press.
- The count is replaced on each change and rolls in from below when it rises or from above when it falls.
Accessibility: The small heart is a toggle button with aria-pressed and the label Like; double-clicking the photo is a shortcut to it. Under prefers-reduced-motion the big heart only fades and the rest changes without moving.
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 "Like" card.
Visual behavior: A photo card you like by double-clicking the picture: a big heart pops over it and floats away, the small heart below fills with a burst of sparks, and the count rolls up.
Technique:
- Each double-click adds a fresh big heart over the photo that runs one keyframe: it scales up past full size, settles, then rises and fades.
- The small heart fills and beats when pressed, and six dots placed by an --a angle custom property fly out from it on the same press.
- The count is replaced on each change and rolls in from below when it rises or from above when it falls.
Accessibility: The small heart is a toggle button with aria-pressed and the label Like; double-clicking the photo is a shortcut to it. Under prefers-reduced-motion the big heart only fades and the rest changes without moving.
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
A photo card you like by double-clicking the picture: a big heart pops over it and floats away, the small heart below fills with a burst of sparks, and the count rolls up.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, icon, state change, card
- Techniques
- keyframes, svg, css variables
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The small heart is a toggle button with aria-pressed and the label Like; double-clicking the photo is a shortcut to it. Under prefers-reduced-motion the big heart only fades and the rest changes without moving.
More cards
See all- Calendarnow
Standup moved to 10:30
1. Collect
Save anything you like into a collection as you browse.
Files
Press and hold to peek