Enhance
import { useEffect, useRef, useState } from 'react';
const css = `
.card-enhance {
width: 210px;
border-radius: 16px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
padding: 8px 8px 12px;
cursor: zoom-in;
}
.card-enhance:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.card-enhance canvas { display: block; width: 194px; height: 120px; border-radius: 10px; image-rendering: pixelated; }
.card-enhance__meta { display: flex; justify-content: space-between; align-items: center; padding: 10px 4px 0; }
.card-enhance__meta b { color: light-dark(#373a40, #f1f2f3); font-size: 13px; font-weight: 600; }
.card-enhance__res { color: light-dark(#5f626a, #a7abb2); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; font-variant-numeric: tabular-nums; }
.card-enhance__probe { position: absolute; width: 0; height: 0; overflow: hidden; }
.card-enhance__probe i:nth-child(1) { color: light-dark(#fbfcfd, #121417); }
.card-enhance__probe i:nth-child(2) { color: light-dark(#dbdde1, #2e3035); }
.card-enhance__probe i:nth-child(3) { color: light-dark(#6d7179, #7d8087); }
.card-enhance__probe i:nth-child(4) { color: light-dark(#50535b, #c6c8cd); }
.card-enhance__probe i:nth-child(5) { color: light-dark(#373a40, #f1f2f3); }
`;
const W = 194;
const H = 120;
// Columns across the picture at each step: blocky to full.
const STEPS = [6, 12, 24, 48, W];
export function EnhanceCard() {
const canvas = useRef<HTMLCanvasElement>(null);
const probe = useRef<HTMLSpanElement>(null);
const [step, setStep] = useState(0);
const target = useRef(0);
useEffect(() => {
const el = canvas.current!;
const dpr = Math.min(2, devicePixelRatio || 1);
el.width = W * dpr;
el.height = H * dpr;
// The scene, painted once at full size from the palette (resolved through probe elements,
// since a canvas can't read CSS variables).
const ink = [...probe.current!.children].map((c) => getComputedStyle(c).color);
const full = document.createElement('canvas');
full.width = W;
full.height = H;
const f = full.getContext('2d')!;
const sky = f.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, ink[1]);
sky.addColorStop(1, ink[0]);
f.fillStyle = sky;
f.fillRect(0, 0, W, H);
f.fillStyle = ink[4];
f.beginPath();
f.arc(132, 42, 18, 0, Math.PI * 2);
f.fill();
f.fillStyle = ink[2];
f.beginPath();
f.moveTo(0, 96);
[[30, 58], [58, 80], [92, 40], [126, 84], [160, 56], [194, 78], [194, 120], [0, 120]].forEach(([x, y]) => f.lineTo(x, y));
f.fill();
f.fillStyle = ink[3];
f.beginPath();
f.moveTo(0, 108);
[[44, 84], [80, 102], [120, 90], [158, 108], [194, 96], [194, 120], [0, 120]].forEach(([x, y]) => f.lineTo(x, y));
f.fill();
f.fillStyle = ink[1];
for (let i = 0; i < 6; i++) f.fillRect(0, 112 + i * 2, W, 1);
const ctx = el.getContext('2d')!;
const small = document.createElement('canvas');
const paint = (cols: number) => {
const rows = Math.max(1, Math.round((cols * H) / W));
small.width = cols;
small.height = rows;
small.getContext('2d')!.drawImage(full, 0, 0, cols, rows);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(small, 0, 0, el.width, el.height);
};
paint(STEPS[0]);
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
let s = 0;
const id = window.setInterval(() => {
const t = target.current;
if (s === t) return;
s = still ? t : s + Math.sign(t - s);
paint(STEPS[s]);
setStep(s);
}, 170);
return () => clearInterval(id);
}, []);
const go = (t: number) => (target.current = t);
return (
<>
<style>
{css}
</style>
<div
className="card-enhance"
tabIndex={0}
role="group"
aria-label="Low tide, enhance on hover"
onPointerEnter={() => go(STEPS.length - 1)}
onPointerLeave={() => go(0)}
onFocus={() => go(STEPS.length - 1)}
onBlur={() => go(0)}
>
<span ref={probe} className="card-enhance__probe" aria-hidden="true">
<i />
<i />
<i />
<i />
<i />
</span>
<canvas ref={canvas} aria-hidden="true" />
<div className="card-enhance__meta">
<b>Low tide</b>
<span className="card-enhance__res" aria-hidden="true">
{step === STEPS.length - 1 ? 'full res' : `${STEPS[step]} px`}
</span>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardEnhance(el) on the mounted element instead.
<div class="card-enhance" tabindex="0" role="group" aria-label="Low tide, enhance on hover">
<span class="card-enhance__probe" aria-hidden="true">
<i></i>
<i></i>
<i></i>
<i></i>
<i></i>
</span>
<canvas aria-hidden="true"></canvas>
<div class="card-enhance__meta">
<b>Low tide</b>
<span class="card-enhance__res" aria-hidden="true">6 px</span>
</div>
</div>.card-enhance {
width: 210px;
border-radius: 16px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
padding: 8px 8px 12px;
cursor: zoom-in;
}
.card-enhance:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.card-enhance canvas { display: block; width: 194px; height: 120px; border-radius: 10px; image-rendering: pixelated; }
.card-enhance__meta { display: flex; justify-content: space-between; align-items: center; padding: 10px 4px 0; }
.card-enhance__meta b { color: light-dark(#373a40, #f1f2f3); font-size: 13px; font-weight: 600; }
.card-enhance__res { color: light-dark(#5f626a, #a7abb2); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; font-variant-numeric: tabular-nums; }
.card-enhance__probe { position: absolute; width: 0; height: 0; overflow: hidden; }
.card-enhance__probe i:nth-child(1) { color: light-dark(#fbfcfd, #121417); }
.card-enhance__probe i:nth-child(2) { color: light-dark(#dbdde1, #2e3035); }
.card-enhance__probe i:nth-child(3) { color: light-dark(#6d7179, #7d8087); }
.card-enhance__probe i:nth-child(4) { color: light-dark(#50535b, #c6c8cd); }
.card-enhance__probe i:nth-child(5) { color: light-dark(#373a40, #f1f2f3); }function initCardEnhance(root) {
var canvas = root.querySelector('canvas');
var probe = root.querySelector('.card-enhance__probe');
var res = root.querySelector('.card-enhance__res');
if (!canvas || !probe || !res) return;
var W = 194;
var H = 120;
var STEPS = [6, 12, 24, 48, W]; // columns across the picture: blocky to full
var dpr = Math.min(2, devicePixelRatio || 1);
canvas.width = W * dpr;
canvas.height = H * dpr;
// The scene, painted once at full size from the palette, read through probe elements.
var ink = Array.prototype.map.call(probe.children, function (c) { return getComputedStyle(c).color; });
var full = document.createElement('canvas');
full.width = W;
full.height = H;
var f = full.getContext('2d');
var sky = f.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, ink[1]);
sky.addColorStop(1, ink[0]);
f.fillStyle = sky;
f.fillRect(0, 0, W, H);
f.fillStyle = ink[4];
f.beginPath();
f.arc(132, 42, 18, 0, Math.PI * 2);
f.fill();
function hills(color, start, pts) {
f.fillStyle = color;
f.beginPath();
f.moveTo(0, start);
pts.forEach(function (p) { f.lineTo(p[0], p[1]); });
f.fill();
}
hills(ink[2], 96, [[30, 58], [58, 80], [92, 40], [126, 84], [160, 56], [194, 78], [194, 120], [0, 120]]);
hills(ink[3], 108, [[44, 84], [80, 102], [120, 90], [158, 108], [194, 96], [194, 120], [0, 120]]);
f.fillStyle = ink[1];
for (var i = 0; i < 6; i++) f.fillRect(0, 112 + i * 2, W, 1);
var ctx = canvas.getContext('2d');
var small = document.createElement('canvas');
function paint(cols) {
small.width = cols;
small.height = Math.max(1, Math.round((cols * H) / W));
small.getContext('2d').drawImage(full, 0, 0, small.width, small.height);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(small, 0, 0, canvas.width, canvas.height);
}
paint(STEPS[0]);
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var s = 0;
var target = 0;
var timer = setInterval(function () {
if (s === target) return;
s = still ? target : s + Math.sign(target - s);
paint(STEPS[s]);
res.textContent = s === STEPS.length - 1 ? 'full res' : STEPS[s] + ' px';
}, 170);
function sharpen() { target = STEPS.length - 1; }
function blur() { target = 0; }
root.addEventListener('pointerenter', sharpen);
root.addEventListener('pointerleave', blur);
root.addEventListener('focus', sharpen);
root.addEventListener('blur', blur);
return function () {
clearInterval(timer);
root.removeEventListener('pointerenter', sharpen);
root.removeEventListener('pointerleave', blur);
root.removeEventListener('focus', sharpen);
root.removeEventListener('blur', blur);
};
}
document.querySelectorAll('.card-enhance').forEach(initCardEnhance);Goal: Create a "Enhance" card.
Visual behavior: A photo card that enhances on hover: the picture starts as a few big pixels and resolves step by step, 6, 12, 24, 48 columns and then full resolution, with the resolution shown beside the title.
Technique:
- The scene is painted once at full size on an offscreen canvas from palette colours read through hidden probe elements (a canvas cannot read CSS variables).
- Each step draws it into a tiny canvas a few columns wide, then scales that back up with imageSmoothingEnabled = false and image-rendering: pixelated, which gives clean big pixels.
- An interval moves one step every 170ms toward the target (full on hover or focus, coarsest on leave), so it sharpens and degrades progressively.
Accessibility: The card is focusable (tabIndex 0) with role="group" and an aria-label that sums it up; keyboard focus plays the same reveal as hover. Under prefers-reduced-motion it jumps straight to the target resolution.
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 "Enhance" card.
Visual behavior: A photo card that enhances on hover: the picture starts as a few big pixels and resolves step by step, 6, 12, 24, 48 columns and then full resolution, with the resolution shown beside the title.
Technique:
- The scene is painted once at full size on an offscreen canvas from palette colours read through hidden probe elements (a canvas cannot read CSS variables).
- Each step draws it into a tiny canvas a few columns wide, then scales that back up with imageSmoothingEnabled = false and image-rendering: pixelated, which gives clean big pixels.
- An interval moves one step every 170ms toward the target (full on hover or focus, coarsest on leave), so it sharpens and degrades progressively.
Accessibility: The card is focusable (tabIndex 0) with role="group" and an aria-label that sums it up; keyboard focus plays the same reveal as hover. Under prefers-reduced-motion it jumps straight to the target resolution.
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 that enhances on hover: the picture starts as a few big pixels and resolves step by step, 6, 12, 24, 48 columns and then full resolution, with the resolution shown beside the title.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- hover, card, state change
- Techniques
- interval, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The card is focusable (tabIndex 0) with role="group" and an aria-label that sums it up; keyboard focus plays the same reveal as hover. Under prefers-reduced-motion it jumps straight to the target resolution.