Everyday•• 4242
Travel•• 0937
Studio•• 8810
Wallet 3 cards
import { useEffect, useRef, useState } from 'react';
const OPEN = 182.4;
const CLOSE = 196.85;
// The day's price, sampled every half hour: a fixed series, so the line is the same every time.
const DAY = [182.4, 181.6, 183.9, 183.1, 185.7, 184.9, 188.2, 187.5, 186.8, 190.4, 189.6, 192.8, 191.9, 194.6, 193.8, 196.85];
const W = 220;
const H = 64;
const lo = Math.min(...DAY) - 1;
const hi = Math.max(...DAY) + 1;
const PTS = DAY.map((p, i) => `${((i / (DAY.length - 1)) * W).toFixed(1)} ${(H - ((p - lo) / (hi - lo)) * H).toFixed(1)}`);
const LINE = `M${PTS.join('L')}`;
const AREA = `${LINE}L${W} ${H}L0 ${H}Z`;
const css = `
.card-sparkline {
display: grid;
gap: 10px;
width: 252px;
padding: 16px;
border-radius: 18px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), 0 12px 24px -14px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7));
outline: none;
}
.card-sparkline:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.card-sparkline__head { display: flex; justify-content: space-between; align-items: flex-start; }
.card-sparkline__sym { color: light-dark(#373a40, #f1f2f3); font-size: 14px; font-weight: 600; }
.card-sparkline__co { color: light-dark(#5f626a, #a7abb2); font-size: 11.5px; }
.card-sparkline__price { color: light-dark(#373a40, #f1f2f3); font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -0.01em; text-align: right; }
.card-sparkline__chg { display: inline-block; margin-top: 2px; padding: 1px 6px; border-radius: 6px; background: light-dark(#ebedf0, #232529); color: light-dark(#42454c, #e5e6e9); font-size: 11px; font-weight: 500; font-variant-numeric: tabular-nums; transform: translateY(4px); opacity: 0; transition: opacity 0.3s, transform 0.4s cubic-bezier(0.3, 1.4, 0.5, 1); }
.card-sparkline svg { width: 100%; height: ${H}px; overflow: visible; }
.card-sparkline__area { fill: color-mix(in oklab, light-dark(#50535b, #c6c8cd) 16%, transparent); clip-path: inset(0 100% 0 0); transition: clip-path 1.2s cubic-bezier(0.5, 0, 0.3, 1); }
.card-sparkline__line { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.6; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 1.2s cubic-bezier(0.5, 0, 0.3, 1); }
.card-sparkline__base { stroke: light-dark(#cbced3, #393c41); stroke-dasharray: 2 3; }
.card-sparkline__ghost { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 1.2; stroke-linejoin: round; }
.card-sparkline__dot { fill: light-dark(#373a40, #f1f2f3); opacity: 0; transition: opacity 0.3s 1.1s; }
.card-sparkline__axis { display: flex; justify-content: space-between; color: light-dark(#5f626a, #a7abb2); font-size: 10px; }
.card-sparkline[data-on] .card-sparkline__area { clip-path: inset(0 0 0 0); }
.card-sparkline[data-on] .card-sparkline__line { stroke-dashoffset: 0; }
.card-sparkline[data-on] .card-sparkline__dot { opacity: 1; }
.card-sparkline[data-on] .card-sparkline__chg { opacity: 1; transform: none; transition-delay: 1.1s; }
@media (prefers-reduced-motion: reduce) {
.card-sparkline * { transition-duration: 0.01s !important; transition-delay: 0s !important; }
}
`;
const fmt = (n: number) => n.toFixed(2);
export function SparklineCard() {
const [on, setOn] = useState(false);
const [price, setPrice] = useState(OPEN);
const raf = useRef(0);
useEffect(() => {
cancelAnimationFrame(raf.current);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const from = price;
const to = on ? CLOSE : OPEN;
if (reduce) return setPrice(to);
const t0 = performance.now();
// Count with the line: same 1.2s, eased the same way, so the number arrives when the line does.
const tick = (now: number) => {
const t = Math.min(1, (now - t0) / 1200);
const e = 1 - Math.pow(1 - t, 3);
setPrice(from + (to - from) * e);
if (t < 1) raf.current = requestAnimationFrame(tick);
};
raf.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [on]);
const last = PTS[PTS.length - 1]!.split(' ');
return (
<>
<style>
{css}
</style>
<div
className="card-sparkline"
data-on={on || undefined}
tabIndex={0}
role="group"
aria-label={`MVN, Movin Inc. Opened at ${fmt(OPEN)}, closed at ${fmt(CLOSE)}, up 7.9 percent`}
onPointerEnter={() => setOn(true)}
onPointerLeave={() => setOn(false)}
onFocus={() => setOn(true)}
onBlur={() => setOn(false)}
>
<div className="card-sparkline__head" aria-hidden="true">
<span>
<span className="card-sparkline__sym">MVN</span>
<br />
<span className="card-sparkline__co">Movin Inc.</span>
</span>
<span>
<span className="card-sparkline__price">${fmt(price)}</span>
<br />
<span style={{ display: 'block', textAlign: 'right' }}>
<span className="card-sparkline__chg">+7.9% today</span>
</span>
</span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
<line className="card-sparkline__base" x1="0" x2={W} y1={PTS[0]!.split(' ')[1]} y2={PTS[0]!.split(' ')[1]} />
<path className="card-sparkline__ghost" d={LINE} />
<path className="card-sparkline__area" d={AREA} />
<path className="card-sparkline__line" d={LINE} pathLength={1} />
<circle className="card-sparkline__dot" cx={last[0]} cy={last[1]} r="3" />
</svg>
<div className="card-sparkline__axis" aria-hidden="true">
<span>9:30</span>
<span>12:00</span>
<span>16:00</span>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardSparkline(el) on the mounted element instead.
<div class="card-sparkline" tabindex="0" role="group" aria-label="MVN, Movin Inc. Opened at 182.40, closed at 196.85, up 7.9 percent">
<div class="card-sparkline__head" aria-hidden="true">
<span>
<span class="card-sparkline__sym">MVN</span>
<br/>
<span class="card-sparkline__co">Movin Inc.</span>
</span>
<span>
<span class="card-sparkline__price">$182.40</span>
<br/>
<span style="display:block;text-align:right">
<span class="card-sparkline__chg">+7.9% today</span>
</span>
</span>
</div>
<svg viewBox="0 0 220 64" preserveAspectRatio="none" aria-hidden="true">
<line class="card-sparkline__base" x1="0" x2="220" y1="57.3" y2="57.3"></line>
<path class="card-sparkline__ghost" d="M0.0 57.3L14.7 60.3L29.3 51.8L44.0 54.7L58.7 45.1L73.3 48.0L88.0 35.8L102.7 38.4L117.3 41.0L132.0 27.6L146.7 30.6L161.3 18.7L176.0 22.1L190.7 12.1L205.3 15.0L220.0 3.7"></path>
<path class="card-sparkline__area" d="M0.0 57.3L14.7 60.3L29.3 51.8L44.0 54.7L58.7 45.1L73.3 48.0L88.0 35.8L102.7 38.4L117.3 41.0L132.0 27.6L146.7 30.6L161.3 18.7L176.0 22.1L190.7 12.1L205.3 15.0L220.0 3.7L220 64L0 64Z"></path>
<path class="card-sparkline__line" d="M0.0 57.3L14.7 60.3L29.3 51.8L44.0 54.7L58.7 45.1L73.3 48.0L88.0 35.8L102.7 38.4L117.3 41.0L132.0 27.6L146.7 30.6L161.3 18.7L176.0 22.1L190.7 12.1L205.3 15.0L220.0 3.7" pathLength="1"></path>
<circle class="card-sparkline__dot" cx="220.0" cy="3.7" r="3"></circle>
</svg>
<div class="card-sparkline__axis" aria-hidden="true">
<span>9:30</span>
<span>12:00</span>
<span>16:00</span>
</div>
</div>.card-sparkline {
display: grid;
gap: 10px;
width: 252px;
padding: 16px;
border-radius: 18px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), 0 12px 24px -14px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7));
outline: none;
}
.card-sparkline:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.card-sparkline__head { display: flex; justify-content: space-between; align-items: flex-start; }
.card-sparkline__sym { color: light-dark(#373a40, #f1f2f3); font-size: 14px; font-weight: 600; }
.card-sparkline__co { color: light-dark(#5f626a, #a7abb2); font-size: 11.5px; }
.card-sparkline__price { color: light-dark(#373a40, #f1f2f3); font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -0.01em; text-align: right; }
.card-sparkline__chg { display: inline-block; margin-top: 2px; padding: 1px 6px; border-radius: 6px; background: light-dark(#ebedf0, #232529); color: light-dark(#42454c, #e5e6e9); font-size: 11px; font-weight: 500; font-variant-numeric: tabular-nums; transform: translateY(4px); opacity: 0; transition: opacity 0.3s, transform 0.4s cubic-bezier(0.3, 1.4, 0.5, 1); }
.card-sparkline svg { width: 100%; height: 64px; overflow: visible; }
.card-sparkline__area { fill: color-mix(in oklab, light-dark(#50535b, #c6c8cd) 16%, transparent); clip-path: inset(0 100% 0 0); transition: clip-path 1.2s cubic-bezier(0.5, 0, 0.3, 1); }
.card-sparkline__line { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.6; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 1.2s cubic-bezier(0.5, 0, 0.3, 1); }
.card-sparkline__base { stroke: light-dark(#cbced3, #393c41); stroke-dasharray: 2 3; }
.card-sparkline__ghost { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 1.2; stroke-linejoin: round; }
.card-sparkline__dot { fill: light-dark(#373a40, #f1f2f3); opacity: 0; transition: opacity 0.3s 1.1s; }
.card-sparkline__axis { display: flex; justify-content: space-between; color: light-dark(#5f626a, #a7abb2); font-size: 10px; }
.card-sparkline[data-on] .card-sparkline__area { clip-path: inset(0 0 0 0); }
.card-sparkline[data-on] .card-sparkline__line { stroke-dashoffset: 0; }
.card-sparkline[data-on] .card-sparkline__dot { opacity: 1; }
.card-sparkline[data-on] .card-sparkline__chg { opacity: 1; transform: none; transition-delay: 1.1s; }
@media (prefers-reduced-motion: reduce) {
.card-sparkline * { transition-duration: 0.01s !important; transition-delay: 0s !important; }
}function initCardSparkline(root) {
var OPEN = 182.4;
var CLOSE = 196.85;
var out = root.querySelector('.card-sparkline__price');
var price = OPEN;
var raf = 0;
function show(p) {
price = p;
out.textContent = '$' + p.toFixed(2);
}
function set(on) {
root.toggleAttribute('data-on', on);
cancelAnimationFrame(raf);
var from = price;
var to = on ? CLOSE : OPEN;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return show(to);
var t0 = performance.now();
// Count with the line: same 1.2s, eased the same way, so the number arrives when the line does.
function tick(now) {
var t = Math.min(1, (now - t0) / 1200);
show(from + (to - from) * (1 - Math.pow(1 - t, 3)));
if (t < 1) raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
}
function on() { set(true); }
function off() { set(false); }
root.addEventListener('pointerenter', on);
root.addEventListener('pointerleave', off);
root.addEventListener('focus', on);
root.addEventListener('blur', off);
return function () {
cancelAnimationFrame(raf);
root.removeEventListener('pointerenter', on);
root.removeEventListener('pointerleave', off);
root.removeEventListener('focus', on);
root.removeEventListener('blur', off);
};
}
document.querySelectorAll('.card-sparkline').forEach(initCardSparkline);Goal: Create a "Sparkline" card.
Visual behavior: A stock card: on hover the day's price line draws itself across the chart with the area filling under it, and the price counts up from the open to the close as the line arrives.
Technique:
- The line is an SVG path with pathLength="1" drawn by a dash-offset transition, and the area under it is revealed by a clip-path inset transition on the same duration and curve.
- The price is counted with requestAnimationFrame over the same 1.2s, eased the same way (an ease-out cubic), so the number lands when the line does; leaving counts it back down.
Accessibility: The card is focusable (tabIndex 0, role="group") with an aria-label that carries its content, so keyboard focus reveals the same state as hover and screen readers get the information in one go; the drawing is aria-hidden. Under prefers-reduced-motion the line appears at once and the price jumps to the close.
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 "Sparkline" card.
Visual behavior: A stock card: on hover the day's price line draws itself across the chart with the area filling under it, and the price counts up from the open to the close as the line arrives.
Technique:
- The line is an SVG path with pathLength="1" drawn by a dash-offset transition, and the area under it is revealed by a clip-path inset transition on the same duration and curve.
- The price is counted with requestAnimationFrame over the same 1.2s, eased the same way (an ease-out cubic), so the number lands when the line does; leaving counts it back down.
Accessibility: The card is focusable (tabIndex 0, role="group") with an aria-label that carries its content, so keyboard focus reveals the same state as hover and screen readers get the information in one go; the drawing is aria-hidden. Under prefers-reduced-motion the line appears at once and the price jumps to the close.
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 stock card: on hover the day's price line draws itself across the chart with the area filling under it, and the price counts up from the open to the close as the line arrives.
The card is focusable (tabIndex 0, role="group") with an aria-label that carries its content, so keyboard focus reveals the same state as hover and screen readers get the information in one go; the drawing is aria-hidden. Under prefers-reduced-motion the line appears at once and the price jumps to the close.