Effects / Canvas 2D
Pixel art
A tiny low-resolution scene scaled up with crisp, square pixels and simple frame animation.
Prompt to build it
Build a pixel-art sunset scene with a bobbing character. Render on a 64×64 canvas with fillRect, scale up with image-rendering: pixelated, use a 12-color palette, and animate clouds and a character in whole-pixel steps. Make it responsive, cap devicePixelRatio at 2, pause when off-screen, and show a still frame when prefers-reduced-motion is set. No external libraries.
Tested code
One self-contained HTML file with no libraries: the same code that runs above. Your AI gets it from Flow's get_effect tool.
Show the code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pixel art | Flow effect</title>
<!--
Flow effect: Pixel art (https://flow.donta.io/effects/pixel-art)
Self-contained: no libraries. Copy the .fx-stage element, the CSS and the
script into your project and size .fx-stage however you like.
-->
<style>
:root { --display: "Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif; --body: ui-sans-serif, system-ui, sans-serif; }
html, body { margin: 0; min-height: 100%; background: #0e0e10; display: grid; place-items: center; }
.fx-stage { width: min(100vw, 960px); }
/* Stage that every effect fills. Container units (cqw/cqmin) size the art. */
.fx-stage {
position: relative;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 16px;
container-type: size;
background: #111;
isolation: isolate;
}
.fx-fill {
position: absolute;
inset: 0;
}
.fx-fallback {
position: absolute;
inset: 0;
}
.fx-center {
display: grid;
place-items: center;
}
@media (prefers-reduced-motion: reduce) {
.fx-stage *, .fx-stage *::before, .fx-stage *::after { animation-play-state: paused !important; }
}
</style>
</head>
<body>
<div class="fx-stage"><canvas class="fx-fill" aria-hidden="true" style="width:100%;height:100%;display:block"></canvas></div>
<script>
function flowLoop(el, frame, opts) {
opts = opts || {};
if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
for (let i = 0; i < (opts.stillFrames || 1); i++) frame(2 + i / 60);
return function () {};
}
let raf = 0, onScreen = true, last = 0;
const start = performance.now(), gap = opts.fps ? 1000 / opts.fps : 0;
const io = new IntersectionObserver(function (e) { onScreen = e[0].isIntersecting; });
io.observe(el);
function tick(now) {
raf = requestAnimationFrame(tick);
if (!onScreen || document.hidden || (gap && now - last < gap)) return;
last = now;
frame((now - start) / 1000);
}
raf = requestAnimationFrame(tick);
return function () { cancelAnimationFrame(raf); io.disconnect(); };
}
function flowCanvas(canvas, o) {
const ctx = canvas.getContext("2d");
const dpr = Math.min(devicePixelRatio || 1, 2);
function size() {
const w = o.fixed ? o.fixed : Math.max(1, Math.round(canvas.clientWidth * dpr));
const h = o.fixed ? o.fixed : Math.max(1, Math.round(canvas.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w; canvas.height = h;
if (o.init) o.init(ctx, w, h);
}
}
if (o.fixed) { canvas.style.imageRendering = "pixelated"; canvas.style.objectFit = "cover"; }
size();
new ResizeObserver(size).observe(canvas);
return flowLoop(canvas, function (t) { o.draw(ctx, t, canvas.width, canvas.height); }, o);
}
/** Canvas 2D effect: pixel. Self-contained; no imports. */
const SKY = ["#2b1b4f", "#43246a", "#6b2f80", "#a2407f", "#d9606d", "#f08c5a", "#f6b85c"];
function drawPixel(ctx , t , w , h ) {
const f = Math.floor(t * 6); // ~6 steps per second
const band = Math.ceil(36 / SKY.length);
SKY.forEach((c, i) => {
ctx.fillStyle = c;
ctx.fillRect(0, i * band, w, band);
});
// stars
ctx.fillStyle = "#fff6d5";
[[6, 3], [20, 6], [33, 2], [47, 5], [58, 9], [12, 11], [40, 10]].forEach(([x, y], i) => {
if ((f + i * 3) % 7 !== 0) ctx.fillRect(x, y, 1, 1);
});
// sun
ctx.fillStyle = "#ffd36b";
for (let y = 0; y < 10; y++) {
const half = Math.round(Math.sqrt(100 - (10 - y) * (10 - y)));
ctx.fillRect(32 - half, 26 + y, half * 2, 1);
}
// cloud
const cx = (Math.floor(t * 4) % 84) - 20;
ctx.fillStyle = "#f5e6ff";
ctx.fillRect(cx, 14, 12, 2);
ctx.fillRect(cx + 2, 12, 7, 2);
// hills
ctx.fillStyle = "#1d4b3a";
for (let x = 0; x < w; x++) {
const hh = 36 - Math.round(4 + 3 * Math.sin(x / 6) + 2 * Math.sin(x / 2.5));
ctx.fillRect(x, hh, 1, h - hh);
}
ctx.fillStyle = "#123326";
for (let x = 0; x < w; x++) {
const hh = 46 - Math.round(3 + 3 * Math.sin(x / 4 + 1));
ctx.fillRect(x, hh, 1, h - hh);
}
// trees
ctx.fillStyle = "#0b2219";
[4, 14, 50, 59].forEach((x) => {
ctx.fillRect(x, 40, 3, 6);
ctx.fillRect(x - 1, 42, 5, 4);
});
// character
const bob = f % 2;
ctx.fillStyle = "#5ee08a";
ctx.fillRect(28, 48 - bob, 8, 7);
ctx.fillRect(27, 50 - bob, 10, 4);
ctx.fillStyle = "#103020";
ctx.fillRect(30, 50 - bob, 1, 2);
ctx.fillRect(33, 50 - bob, 1, 2);
ctx.fillStyle = "#ff5d8f";
if (f % 12 < 6) ctx.fillRect(44, 46 - (f % 6), 2, 2);
// ground
ctx.fillStyle = "#0a1a14";
ctx.fillRect(0, 56, w, 8);
}
flowCanvas(document.querySelector(".fx-stage canvas"), { draw: drawPixel, fixed: 64, fps: 12 });
</script>
</body>
</html>
How it works
Draw on a small canvas (e.g. 64×64) and scale it up with image-rendering: pixelated; animate a few elements by whole pixels per frame.
Guardrails
- Snap to whole pixels; blurry scaling ruins the look.
- Respect prefers-reduced-motion: show a still frame instead of the animation.
- Pause when off-screen or when the tab is hidden (IntersectionObserver + visibilitychange).
- Keep text off busy animated areas, or give it a solid backing that passes WCAG contrast.
- One showpiece effect per page. If everything moves, nothing stands out.