Paper plane, take 3
Scrub
import { useState } from 'react';
const FRAMES = 12;
const TW = 212;
const TH = 112;
const time = (f: number) => `0:${String(f * 2).padStart(2, '0')}`;
const css = `
.card-scrub { 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-scrub__thumb { position: relative; width: ${TW}px; height: ${TH}px; border-radius: 10px; overflow: hidden; cursor: ew-resize; touch-action: pan-y; }
.card-scrub__thumb:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.card-scrub__thumb svg { display: block; width: 100%; height: 100%; }
.card-scrub__sky { fill: light-dark(#ebedf0, #232529); }
.card-scrub__far { fill: light-dark(#dbdde1, #2e3035); }
.card-scrub__near { fill: light-dark(#cbced3, #393c41); }
.card-scrub__plane { fill: light-dark(#373a40, #f1f2f3); }
.card-scrub__trail { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1.2; stroke-dasharray: 2 3; }
.card-scrub__bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: var(--card-scrub-shadow); opacity: 0; transition: opacity 0.2s; }
.card-scrub { --card-scrub-shadow: light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.4)); }
.card-scrub__bar span { display: block; height: 100%; background: light-dark(#373a40, #f1f2f3); }
.card-scrub__chip { position: absolute; right: 6px; bottom: 8px; padding: 1px 6px; border-radius: 4px; background: light-dark(#fbfcfd, #121417); color: light-dark(#373a40, #f1f2f3); font-size: 10px; font-weight: 600; font-variant-numeric: tabular-nums; }
.card-scrub--live .card-scrub__bar { opacity: 1; }
.card-scrub__title { margin: 10px 0 2px; font-size: 13px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.card-scrub__meta { margin: 0; font-size: 11px; color: light-dark(#5f626a, #a7abb2); }
@media (prefers-reduced-motion: reduce) {
.card-scrub__bar { transition: none; }
}
`;
// One frame of the clip: a paper plane loops across, the hills sliding behind it at two speeds.
function Frame({ f }: { f: number }) {
const t = f / (FRAMES - 1);
const x = 22 + t * 168;
const y = 58 - 30 * Math.sin(t * Math.PI) + 10 * Math.sin(t * Math.PI * 3);
const dy = -30 * Math.PI * Math.cos(t * Math.PI) + 30 * Math.PI * Math.cos(t * Math.PI * 3);
const tilt = (Math.atan2(dy / 168, 1) * 180) / Math.PI;
return (
<svg viewBox={`0 0 ${TW} ${TH}`} aria-hidden="true">
<rect className="card-scrub__sky" width={TW} height={TH} />
<path className="card-scrub__far" d={`M${-t * 30} 86 Q${40 - t * 30} 64 ${90 - t * 30} 80 T${200 - t * 30} 74 T${300 - t * 30} 78 V${TH} H${-t * 30} Z`} />
<path className="card-scrub__near" d={`M${-t * 70} 98 Q${50 - t * 70} 84 ${110 - t * 70} 96 T${230 - t * 70} 92 T${340 - t * 70} 96 V${TH} H${-t * 70} Z`} />
<path className="card-scrub__trail" d={`M 10 ${58} Q ${(10 + x) / 2} ${y - 18} ${x - 8} ${y}`} />
<g transform={`translate(${x} ${y}) rotate(${tilt})`}>
<path className="card-scrub__plane" d="M10 0 L-8 -6 L-4 0 L-8 6 Z" />
</g>
</svg>
);
}
export function ScrubCard() {
const [f, setF] = useState(0);
const [live, setLive] = useState(false);
const pick = (e: React.PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
setLive(true);
setF(Math.max(0, Math.min(FRAMES - 1, Math.floor(((e.clientX - r.left) / r.width) * FRAMES))));
};
return (
<>
<style>
{css}
</style>
<article className={`card-scrub${live ? ' card-scrub--live' : ''}`}>
{/* The pointer's position across the thumbnail picks the frame; the keys step through them. */}
<div
className="card-scrub__thumb"
role="slider"
tabIndex={0}
aria-label="Preview position"
aria-valuemin={0}
aria-valuemax={FRAMES - 1}
aria-valuenow={f}
aria-valuetext={time(f)}
onPointerMove={pick}
onPointerDown={pick}
onPointerLeave={() => {
setLive(false);
setF(0);
}}
onKeyDown={(e) => {
const next = { ArrowRight: f + 1, ArrowLeft: f - 1, Home: 0, End: FRAMES - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
setLive(true);
setF(Math.max(0, Math.min(FRAMES - 1, next)));
}}
onBlur={() => setLive(false)}
>
<Frame f={f} />
<span className="card-scrub__bar" aria-hidden="true">
<span style={{ width: `${((f + 1) / FRAMES) * 100}%` }} />
</span>
<span className="card-scrub__chip" aria-hidden="true">
{live ? time(f) : time(FRAMES - 1)}
</span>
</div>
<p className="card-scrub__title">Paper plane, take 3</p>
<p className="card-scrub__meta">Studio reel · 1.2k views</p>
</article>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardScrub(el) on the mounted element instead.
<article class="card-scrub">
<div class="card-scrub__thumb" role="slider" tabindex="0" aria-label="Preview position" aria-valuemin="0" aria-valuemax="11" aria-valuenow="0" aria-valuetext="0:00">
<svg viewBox="0 0 212 112" aria-hidden="true">
<rect class="card-scrub__sky" width="212" height="112"></rect>
<path class="card-scrub__far" d="M0 86 Q40 64 90 80 T200 74 T300 78 V112 H0 Z"></path>
<path class="card-scrub__near" d="M0 98 Q50 84 110 96 T230 92 T340 96 V112 H0 Z"></path>
<path class="card-scrub__trail" d="M 10 58 Q 16 40 14 58"></path>
<g transform="translate(22 58) rotate(0)">
<path class="card-scrub__plane" d="M10 0 L-8 -6 L-4 0 L-8 6 Z"></path>
</g>
</svg>
<span class="card-scrub__bar" aria-hidden="true">
<span style="width:8.333%"></span>
</span>
<span class="card-scrub__chip" aria-hidden="true">0:22</span>
</div>
<p class="card-scrub__title">Paper plane, take 3</p>
<p class="card-scrub__meta">Studio reel · 1.2k views</p>
</article>.card-scrub { 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-scrub__thumb { position: relative; width: 212px; height: 112px; border-radius: 10px; overflow: hidden; cursor: ew-resize; touch-action: pan-y; }
.card-scrub__thumb:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.card-scrub__thumb svg { display: block; width: 100%; height: 100%; }
.card-scrub__sky { fill: light-dark(#ebedf0, #232529); }
.card-scrub__far { fill: light-dark(#dbdde1, #2e3035); }
.card-scrub__near { fill: light-dark(#cbced3, #393c41); }
.card-scrub__plane { fill: light-dark(#373a40, #f1f2f3); }
.card-scrub__trail { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1.2; stroke-dasharray: 2 3; }
.card-scrub__bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: var(--card-scrub-shadow); opacity: 0; transition: opacity 0.2s; }
.card-scrub { --card-scrub-shadow: light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.4)); }
.card-scrub__bar span { display: block; height: 100%; background: light-dark(#373a40, #f1f2f3); }
.card-scrub__chip { position: absolute; right: 6px; bottom: 8px; padding: 1px 6px; border-radius: 4px; background: light-dark(#fbfcfd, #121417); color: light-dark(#373a40, #f1f2f3); font-size: 10px; font-weight: 600; font-variant-numeric: tabular-nums; }
.card-scrub--live .card-scrub__bar { opacity: 1; }
.card-scrub__title { margin: 10px 0 2px; font-size: 13px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.card-scrub__meta { margin: 0; font-size: 11px; color: light-dark(#5f626a, #a7abb2); }
@media (prefers-reduced-motion: reduce) {
.card-scrub__bar { transition: none; }
}function initCardScrub(root) {
var FRAMES = 12;
var thumb = root.querySelector('.card-scrub__thumb');
var far = root.querySelector('.card-scrub__far');
var near = root.querySelector('.card-scrub__near');
var trail = root.querySelector('.card-scrub__trail');
var plane = root.querySelector('.card-scrub__plane').parentNode;
var fill = root.querySelector('.card-scrub__bar span');
var chip = root.querySelector('.card-scrub__chip');
var f = 0;
var live = false;
function time(n) {
return '0:' + String(n * 2).padStart(2, '0');
}
// One frame of the clip: a paper plane loops across, the hills sliding behind it at two speeds.
function drawFrame() {
var t = f / (FRAMES - 1);
var x = 22 + t * 168;
var y = 58 - 30 * Math.sin(t * Math.PI) + 10 * Math.sin(t * Math.PI * 3);
var dy = -30 * Math.PI * Math.cos(t * Math.PI) + 30 * Math.PI * Math.cos(t * Math.PI * 3);
var tilt = (Math.atan2(dy / 168, 1) * 180) / Math.PI;
var a = -t * 30;
var b = -t * 70;
far.setAttribute('d', 'M' + a + ' 86 Q' + (40 + a) + ' 64 ' + (90 + a) + ' 80 T' + (200 + a) + ' 74 T' + (300 + a) + ' 78 V112 H' + a + ' Z');
near.setAttribute('d', 'M' + b + ' 98 Q' + (50 + b) + ' 84 ' + (110 + b) + ' 96 T' + (230 + b) + ' 92 T' + (340 + b) + ' 96 V112 H' + b + ' Z');
trail.setAttribute('d', 'M 10 58 Q ' + (10 + x) / 2 + ' ' + (y - 18) + ' ' + (x - 8) + ' ' + y);
plane.setAttribute('transform', 'translate(' + x + ' ' + y + ') rotate(' + tilt + ')');
}
function render() {
root.classList.toggle('card-scrub--live', live);
drawFrame();
fill.style.width = ((f + 1) / FRAMES) * 100 + '%';
chip.textContent = live ? time(f) : time(FRAMES - 1);
thumb.setAttribute('aria-valuenow', f);
thumb.setAttribute('aria-valuetext', time(f));
}
// The pointer's position across the thumbnail picks the frame; the keys step through them.
// Under prefers-reduced-motion nothing plays by itself: every frame is one the reader chose.
function pick(e) {
var r = thumb.getBoundingClientRect();
live = true;
f = Math.max(0, Math.min(FRAMES - 1, Math.floor(((e.clientX - r.left) / r.width) * FRAMES)));
render();
}
function onLeave() {
live = false;
f = 0;
render();
}
function onKey(e) {
var next = { ArrowRight: f + 1, ArrowLeft: f - 1, Home: 0, End: FRAMES - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
live = true;
f = Math.max(0, Math.min(FRAMES - 1, next));
render();
}
function onBlur() {
live = false;
render();
}
thumb.addEventListener('pointermove', pick);
thumb.addEventListener('pointerdown', pick);
thumb.addEventListener('pointerleave', onLeave);
thumb.addEventListener('keydown', onKey);
thumb.addEventListener('blur', onBlur);
return function () {
thumb.removeEventListener('pointermove', pick);
thumb.removeEventListener('pointerdown', pick);
thumb.removeEventListener('pointerleave', onLeave);
thumb.removeEventListener('keydown', onKey);
thumb.removeEventListener('blur', onBlur);
};
}
document.querySelectorAll('.card-scrub').forEach(initCardScrub);Goal: Create a "Scrub" card.
Visual behavior: A video card you preview by moving across the thumbnail: the pointer position picks the frame of a paper plane's flight, a bar under it tracks the position and a chip shows the time.
Technique:
- The pointer's x across the thumbnail maps to one of 12 frames. Each frame is computed, not stored: the plane's position and tilt follow a curve, and two hill layers slide behind it at different speeds for depth.
- A thin progress bar and a time chip follow the frame while the pointer is over the thumbnail; leaving resets it to the first frame and the chip shows the full length again.
Accessibility: The thumbnail is a focusable role="slider" with aria-valuenow and an aria-valuetext of the time, stepped with the arrow keys, Home and End. The frames are aria-hidden. Nothing plays by itself, so prefers-reduced-motion only drops the bar's fade.
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 "Scrub" card.
Visual behavior: A video card you preview by moving across the thumbnail: the pointer position picks the frame of a paper plane's flight, a bar under it tracks the position and a chip shows the time.
Technique:
- The pointer's x across the thumbnail maps to one of 12 frames. Each frame is computed, not stored: the plane's position and tilt follow a curve, and two hill layers slide behind it at different speeds for depth.
- A thin progress bar and a time chip follow the frame while the pointer is over the thumbnail; leaving resets it to the first frame and the chip shows the full length again.
Accessibility: The thumbnail is a focusable role="slider" with aria-valuenow and an aria-valuetext of the time, stepped with the arrow keys, Home and End. The frames are aria-hidden. Nothing plays by itself, so prefers-reduced-motion only drops the bar's fade.
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 video card you preview by moving across the thumbnail: the pointer position picks the frame of a paper plane's flight, a bar under it tracks the position and a chip shows the time.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- mouse tracking, state change, card
- Techniques
- event position, svg, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The thumbnail is a focusable role="slider" with aria-valuenow and an aria-valuetext of the time, stepped with the arrow keys, Home and End. The frames are aria-hidden. Nothing plays by itself, so prefers-reduced-motion only drops the bar's fade.