Which easing do you reach for first?
100 votes
Which easing do you reach for first?
100 votes
import { useEffect, useId, useRef, useState } from 'react';
const OPTIONS = [
{ t: 'Ease out', v: 48 },
{ t: 'Spring', v: 37 },
{ t: 'Linear', v: 15 },
];
const css = `
.card-poll { 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-poll__q { margin: 0 0 10px; font-size: 13px; font-weight: 600; line-height: 1.35; color: light-dark(#373a40, #f1f2f3); }
.card-poll__list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
.card-poll__opt { position: relative; display: flex; align-items: center; gap: 6px; box-sizing: border-box; width: 100%; height: 32px; padding: 0 10px; border: 1.5px solid light-dark(#dbdde1, #2e3035); border-radius: 9px; background: none; color: light-dark(#373a40, #f1f2f3); font: inherit; font-size: 12px; text-align: left; overflow: hidden; cursor: pointer; transition: border-color 0.3s; }
.card-poll__opt:hover:not(:disabled) { border-color: light-dark(#6d7179, #7d8087); }
.card-poll__opt:disabled { cursor: default; }
.card-poll__opt:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.card-poll--voted .card-poll__opt { border-color: transparent; }
/* After the vote each option becomes a bar grown to its share, a beat after the one above. */
.card-poll__bar { position: absolute; inset: 0; background: light-dark(#ebedf0, #232529); transform: scaleX(0); transform-origin: left; transition: transform 0.6s cubic-bezier(0.3, 1.1, 0.5, 1); }
.card-poll__opt--mine .card-poll__bar { background: light-dark(#cbced3, #393c41); }
.card-poll__t, .card-poll__pct, .card-poll__tick { position: relative; }
.card-poll__pct { margin-left: auto; font-weight: 600; font-variant-numeric: tabular-nums; opacity: 0; transition: opacity 0.3s; }
.card-poll--voted .card-poll__pct { opacity: 1; }
.card-poll__tick { width: 12px; height: 12px; }
.card-poll__tick path { stroke-dasharray: 1; animation: card-poll-tick 0.3s ease-out 0.3s backwards; }
@keyframes card-poll-tick { from { stroke-dashoffset: 1; } }
.card-poll__foot { display: flex; justify-content: space-between; margin-top: 10px; font-size: 11px; color: light-dark(#5f626a, #a7abb2); }
.card-poll__again { padding: 0; border: 0; background: none; color: light-dark(#42454c, #e5e6e9); font: inherit; font-size: 11px; text-decoration: underline; text-underline-offset: 2px; cursor: pointer; }
.card-poll__again:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.card-poll__bar { transition: none; }
.card-poll__tick path { animation: none; }
}
`;
export function PollCard() {
const [mine, setMine] = useState<number | null>(null);
const [t, setT] = useState(0);
const raf = useRef(0);
const id = useId();
const total = OPTIONS.reduce((n, o) => n + o.v, 0) + (mine === null ? 0 : 1);
const pct = (i: number) => Math.round(((OPTIONS[i].v + (mine === i ? 1 : 0)) / total) * 100);
// The percentages count up from zero over the same time the bars grow.
useEffect(() => {
cancelAnimationFrame(raf.current);
if (mine === null) return setT(0);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return setT(1);
const t0 = performance.now();
const step = (now: number) => {
const k = Math.min(1, (now - t0) / 700);
setT(1 - (1 - k) ** 3);
if (k < 1) raf.current = requestAnimationFrame(step);
};
raf.current = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf.current);
}, [mine]);
return (
<>
<style>
{css}
</style>
<article className={`card-poll${mine !== null ? ' card-poll--voted' : ''}`}>
<p className="card-poll__q" id={id}>
Which easing do you reach for first?
</p>
<ul className="card-poll__list" aria-labelledby={id}>
{OPTIONS.map((o, i) => (
<li key={o.t}>
<button
type="button"
className={`card-poll__opt${mine === i ? ' card-poll__opt--mine' : ''}`}
disabled={mine !== null}
aria-pressed={mine === i}
aria-label={mine === null ? o.t : `${o.t}, ${pct(i)}%${mine === i ? ', your vote' : ''}`}
onClick={() => setMine(i)}
>
<span className="card-poll__bar" style={{ transform: mine === null ? undefined : `scaleX(${pct(i) / 100})`, transitionDelay: `${i * 80}ms` }} aria-hidden="true" />
<span className="card-poll__t">{o.t}</span>
{mine === i && (
<svg className="card-poll__tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5 12.5l4.5 4.5L19 7.5" pathLength={1} />
</svg>
)}
<span className="card-poll__pct" aria-hidden="true">
{Math.round(pct(i) * t)}%
</span>
</button>
</li>
))}
</ul>
<p className="card-poll__foot">
{total} votes
{mine !== null && (
<button type="button" className="card-poll__again" onClick={() => setMine(null)}>
Change vote
</button>
)}
</p>
</article>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardPoll(el) on the mounted element instead.
<article class="card-poll">
<p class="card-poll__q" id="card-poll-q">Which easing do you reach for first?</p>
<ul class="card-poll__list" aria-labelledby="card-poll-q">
<li>
<button type="button" class="card-poll__opt" aria-pressed="false" aria-label="Ease out">
<span class="card-poll__bar" style="transition-delay:0ms" aria-hidden="true"></span>
<span class="card-poll__t">Ease out</span>
<span class="card-poll__pct" aria-hidden="true">0%</span>
</button>
</li>
<li>
<button type="button" class="card-poll__opt" aria-pressed="false" aria-label="Spring">
<span class="card-poll__bar" style="transition-delay:80ms" aria-hidden="true"></span>
<span class="card-poll__t">Spring</span>
<span class="card-poll__pct" aria-hidden="true">0%</span>
</button>
</li>
<li>
<button type="button" class="card-poll__opt" aria-pressed="false" aria-label="Linear">
<span class="card-poll__bar" style="transition-delay:160ms" aria-hidden="true"></span>
<span class="card-poll__t">Linear</span>
<span class="card-poll__pct" aria-hidden="true">0%</span>
</button>
</li>
</ul>
<p class="card-poll__foot">100 votes</p>
</article>.card-poll { 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-poll__q { margin: 0 0 10px; font-size: 13px; font-weight: 600; line-height: 1.35; color: light-dark(#373a40, #f1f2f3); }
.card-poll__list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
.card-poll__opt { position: relative; display: flex; align-items: center; gap: 6px; box-sizing: border-box; width: 100%; height: 32px; padding: 0 10px; border: 1.5px solid light-dark(#dbdde1, #2e3035); border-radius: 9px; background: none; color: light-dark(#373a40, #f1f2f3); font: inherit; font-size: 12px; text-align: left; overflow: hidden; cursor: pointer; transition: border-color 0.3s; }
.card-poll__opt:hover:not(:disabled) { border-color: light-dark(#6d7179, #7d8087); }
.card-poll__opt:disabled { cursor: default; }
.card-poll__opt:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.card-poll--voted .card-poll__opt { border-color: transparent; }
/* After the vote each option becomes a bar grown to its share, a beat after the one above. */
.card-poll__bar { position: absolute; inset: 0; background: light-dark(#ebedf0, #232529); transform: scaleX(0); transform-origin: left; transition: transform 0.6s cubic-bezier(0.3, 1.1, 0.5, 1); }
.card-poll__opt--mine .card-poll__bar { background: light-dark(#cbced3, #393c41); }
.card-poll__t, .card-poll__pct, .card-poll__tick { position: relative; }
.card-poll__pct { margin-left: auto; font-weight: 600; font-variant-numeric: tabular-nums; opacity: 0; transition: opacity 0.3s; }
.card-poll--voted .card-poll__pct { opacity: 1; }
.card-poll__tick { width: 12px; height: 12px; }
.card-poll__tick path { stroke-dasharray: 1; animation: card-poll-tick 0.3s ease-out 0.3s backwards; }
@keyframes card-poll-tick { from { stroke-dashoffset: 1; } }
.card-poll__foot { display: flex; justify-content: space-between; margin-top: 10px; font-size: 11px; color: light-dark(#5f626a, #a7abb2); }
.card-poll__again { padding: 0; border: 0; background: none; color: light-dark(#42454c, #e5e6e9); font: inherit; font-size: 11px; text-decoration: underline; text-underline-offset: 2px; cursor: pointer; }
.card-poll__again:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.card-poll__bar { transition: none; }
.card-poll__tick path { animation: none; }
}function initCardPoll(root) {
var VOTES = [48, 37, 15];
var TICK =
'<svg class="card-poll__tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M5 12.5l4.5 4.5L19 7.5" pathLength="1"></path></svg>';
var opts = root.querySelectorAll('.card-poll__opt');
var foot = root.querySelector('.card-poll__foot');
var again = document.createElement('button');
again.type = 'button';
again.className = 'card-poll__again';
again.textContent = 'Change vote';
var names = [];
opts.forEach(function (o) {
names.push(o.querySelector('.card-poll__t').textContent);
});
var mine = null;
var raf = 0;
function total() {
return VOTES.reduce(function (n, v) {
return n + v;
}, 0) + (mine === null ? 0 : 1);
}
function pct(i) {
return Math.round(((VOTES[i] + (mine === i ? 1 : 0)) / total()) * 100);
}
function count(t) {
opts.forEach(function (o, i) {
o.querySelector('.card-poll__pct').textContent = Math.round(pct(i) * t) + '%';
});
}
// After the vote each option becomes a bar grown to its share, a beat after the one above.
function render() {
root.classList.toggle('card-poll--voted', mine !== null);
opts.forEach(function (o, i) {
o.classList.toggle('card-poll__opt--mine', mine === i);
o.disabled = mine !== null;
o.setAttribute('aria-pressed', String(mine === i));
o.setAttribute('aria-label', mine === null ? names[i] : names[i] + ', ' + pct(i) + '%' + (mine === i ? ', your vote' : ''));
o.querySelector('.card-poll__bar').style.transform = mine === null ? '' : 'scaleX(' + pct(i) / 100 + ')';
var tick = o.querySelector('.card-poll__tick');
if (mine === i && !tick) o.querySelector('.card-poll__t').insertAdjacentHTML('afterend', TICK);
if (mine !== i && tick) tick.remove();
});
foot.firstChild.textContent = total() + ' votes';
if (mine !== null && !again.parentNode) foot.appendChild(again);
if (mine === null && again.parentNode) again.remove();
}
// The percentages count up from zero over the same time the bars grow.
function run() {
cancelAnimationFrame(raf);
if (mine === null) return count(0);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return count(1);
var t0 = performance.now();
function step(now) {
var k = Math.min(1, (now - t0) / 700);
count(1 - Math.pow(1 - k, 3));
if (k < 1) raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
function onVote(e) {
mine = Array.prototype.indexOf.call(opts, e.currentTarget);
render();
run();
}
function onAgain() {
mine = null;
render();
run();
}
opts.forEach(function (o) {
o.addEventListener('click', onVote);
});
again.addEventListener('click', onAgain);
return function () {
cancelAnimationFrame(raf);
opts.forEach(function (o) {
o.removeEventListener('click', onVote);
});
again.removeEventListener('click', onAgain);
};
}
document.querySelectorAll('.card-poll').forEach(initCardPoll);Goal: Create a "Poll" card.
Visual behavior: A poll card: vote and the options turn into result bars that grow to their share one after another while the percentages count up, with your choice ticked.
Technique:
- Each option has a bar behind its text, scaled on X from 0 to its share after the vote, each 80ms after the one above.
- The percentages count up from zero in requestAnimationFrame on an ease out curve over the same 0.7s.
- Change vote clears it back to the options.
Accessibility: The options are buttons with aria-pressed; after the vote their labels include the percentage and which was yours, and they are disabled until Change vote. Under prefers-reduced-motion the bars and figures appear at their final values.
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 "Poll" card.
Visual behavior: A poll card: vote and the options turn into result bars that grow to their share one after another while the percentages count up, with your choice ticked.
Technique:
- Each option has a bar behind its text, scaled on X from 0 to its share after the vote, each 80ms after the one above.
- The percentages count up from zero in requestAnimationFrame on an ease out curve over the same 0.7s.
- Change vote clears it back to the options.
Accessibility: The options are buttons with aria-pressed; after the vote their labels include the percentage and which was yours, and they are disabled until Change vote. Under prefers-reduced-motion the bars and figures appear at their final values.
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
A poll card: vote and the options turn into result bars that grow to their share one after another while the percentages count up, with your choice ticked.
The options are buttons with aria-pressed; after the vote their labels include the percentage and which was yours, and they are disabled until Change vote. Under prefers-reduced-motion the bars and figures appear at their final values.