Line Clear
import { useEffect, useState } from 'react';
const css = `
.loader-line-clear {
display: grid;
grid-template-columns: repeat(6, 10px);
grid-auto-rows: 10px;
gap: 2px;
padding: 5px;
border-radius: 6px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.loader-line-clear__cell { border-radius: 2px; background: light-dark(#f3f4f7, #1a1c1f); }
.loader-line-clear__cell[data-s='set'] { background: light-dark(#5f626a, #a7abb2); }
.loader-line-clear__cell[data-s='live'] { background: light-dark(#373a40, #f1f2f3); }
.loader-line-clear__cell[data-s='flash'] { background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 8px light-dark(#373a40, #f1f2f3); }
`;
const W = 6;
const H = 9;
type Cell = '' | 'set' | 'live' | 'flash';
// Pieces as [col, row] cells relative to their top-left, each dropped at column `x`.
// Together they fill the bottom two rows exactly, so the board clears back to empty.
const PIECES: { cells: [number, number][]; x: number }[] = [
{ cells: [[0, 0], [0, 1], [1, 1], [2, 1]], x: 0 }, // J
{ cells: [[0, 0], [1, 0], [0, 1], [1, 1]], x: 4 }, // O
{ cells: [[0, 0], [1, 0], [2, 0], [2, 1]], x: 1 }, // L, dropping its foot into the last gap
];
/** Every frame of one round: each piece falling cell by cell, then the full rows flashing and clearing. */
function frames(): Cell[][] {
const board: Cell[] = Array(W * H).fill('');
const out: Cell[][] = [];
const fits = (cells: [number, number][], x: number, y: number) =>
cells.every(([cx, cy]) => y + cy < H && !board[(y + cy) * W + x + cx]);
for (const p of PIECES) {
let y = 0;
while (fits(p.cells, p.x, y + 1)) {
const f = board.slice();
p.cells.forEach(([cx, cy]) => (f[(y + cy) * W + p.x + cx] = 'live'));
out.push(f);
y += 1;
}
p.cells.forEach(([cx, cy]) => (board[(y + cy) * W + p.x + cx] = 'set'));
out.push(board.slice());
}
const full = Array.from({ length: H }, (_, r) => r).filter((r) => board.slice(r * W, r * W + W).every(Boolean));
for (let k = 0; k < 4; k++) {
const f = board.slice();
if (k % 2 === 0) full.forEach((r) => f.fill('flash', r * W, r * W + W));
out.push(f);
}
// Clear: drop whatever sits above the cleared rows, then a beat of rest.
const kept = Array.from({ length: H }, (_, r) => r).filter((r) => !full.includes(r));
const after: Cell[] = Array(W * H).fill('');
kept.reverse().forEach((r, i) => after.splice((H - 1 - i) * W, W, ...board.slice(r * W, r * W + W)));
out.push(after, after, after);
return out;
}
const FRAMES = frames();
export function LineClearLoader() {
const [i, setI] = useState(0);
useEffect(() => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const id = window.setInterval(() => setI((n) => (n + 1) % FRAMES.length), 85);
return () => clearInterval(id);
}, []);
return (
<>
<style>
{css}
</style>
<div className="loader-line-clear" role="status" aria-label="Loading">
{FRAMES[i].map((s, k) => (
<span key={k} className="loader-line-clear__cell" data-s={s || undefined} aria-hidden="true" />
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initLoaderLineClear(el) on the mounted element instead.
<div class="loader-line-clear" role="status" aria-label="Loading">
<span class="loader-line-clear__cell" data-s="live" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" data-s="live" aria-hidden="true"></span>
<span class="loader-line-clear__cell" data-s="live" aria-hidden="true"></span>
<span class="loader-line-clear__cell" data-s="live" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
<span class="loader-line-clear__cell" aria-hidden="true"></span>
</div>.loader-line-clear {
display: grid;
grid-template-columns: repeat(6, 10px);
grid-auto-rows: 10px;
gap: 2px;
padding: 5px;
border-radius: 6px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.loader-line-clear__cell { border-radius: 2px; background: light-dark(#f3f4f7, #1a1c1f); }
.loader-line-clear__cell[data-s='set'] { background: light-dark(#5f626a, #a7abb2); }
.loader-line-clear__cell[data-s='live'] { background: light-dark(#373a40, #f1f2f3); }
.loader-line-clear__cell[data-s='flash'] { background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 8px light-dark(#373a40, #f1f2f3); }function initLoaderLineClear(root) {
var cells = root.querySelectorAll('.loader-line-clear__cell');
var W = 6;
var H = 9;
if (cells.length !== W * H) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
// Pieces as [col, row] cells relative to their top-left, each dropped at column x.
// Together they fill the bottom two rows exactly, so the board clears back to empty.
var PIECES = [
{ cells: [[0, 0], [0, 1], [1, 1], [2, 1]], x: 0 },
{ cells: [[0, 0], [1, 0], [0, 1], [1, 1]], x: 4 },
{ cells: [[0, 0], [1, 0], [2, 0], [2, 1]], x: 1 },
];
// Every frame of one round: each piece falling, then the full rows flashing and clearing.
function frames() {
var board = [];
for (var i = 0; i < W * H; i++) board.push('');
var out = [];
function fits(p, y) {
return p.cells.every(function (c) { return y + c[1] < H && !board[(y + c[1]) * W + p.x + c[0]]; });
}
PIECES.forEach(function (p) {
var y = 0;
while (fits(p, y + 1)) {
var f = board.slice();
p.cells.forEach(function (c) { f[(y + c[1]) * W + p.x + c[0]] = 'live'; });
out.push(f);
y += 1;
}
p.cells.forEach(function (c) { board[(y + c[1]) * W + p.x + c[0]] = 'set'; });
out.push(board.slice());
});
var full = [];
for (var r = 0; r < H; r++) if (board.slice(r * W, r * W + W).every(Boolean)) full.push(r);
for (var k = 0; k < 4; k++) {
var g = board.slice();
if (k % 2 === 0) full.forEach(function (row) { g.fill('flash', row * W, row * W + W); });
out.push(g);
}
// Clear: drop whatever sits above the cleared rows, then a beat of rest.
var kept = [];
for (var q = 0; q < H; q++) if (full.indexOf(q) < 0) kept.push(q);
var after = [];
for (var z = 0; z < W * H; z++) after.push('');
kept.reverse().forEach(function (row, n) {
var line = board.slice(row * W, row * W + W);
for (var c = 0; c < W; c++) after[(H - 1 - n) * W + c] = line[c];
});
out.push(after, after, after);
return out;
}
var FRAMES = frames();
var n = 0;
var timer = setInterval(function () {
n = (n + 1) % FRAMES.length;
FRAMES[n].forEach(function (s, k) {
if (s) cells[k].setAttribute('data-s', s);
else cells[k].removeAttribute('data-s');
});
}, 85);
return function () { clearInterval(timer); };
}
document.querySelectorAll('.loader-line-clear').forEach(initLoaderLineClear);Goal: Create a "Line Clear" loader.
Visual behavior: A tiny falling-block game: pieces drop and settle until the bottom rows are complete, the rows flash and clear, and the board starts again.
Technique:
- The board is a 6 x 9 CSS grid of cells whose state (empty, live, set, flash) is a data attribute.
- All frames of one round are precomputed: each piece (J, O, L) falls one row per frame until it would collide, then locks; the three are chosen to fill the bottom two rows exactly.
- Full rows flash twice, then everything above drops into their place; an interval steps through the frames every 85ms and loops.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the board stays on its first frame.
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 "Line Clear" loader.
Visual behavior: A tiny falling-block game: pieces drop and settle until the bottom rows are complete, the rows flash and clear, and the board starts again.
Technique:
- The board is a 6 x 9 CSS grid of cells whose state (empty, live, set, flash) is a data attribute.
- All frames of one round are precomputed: each piece (J, O, L) falls one row per frame until it would collide, then locks; the three are chosen to fill the bottom two rows exactly.
- Full rows flash twice, then everything above drops into their place; an interval steps through the frames every 85ms and loops.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the board stays on its first frame.
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 tiny falling-block game: pieces drop and settle until the bottom rows are complete, the rows flash and clear, and the board starts again.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- continuous, loop, state change
- Techniques
- interval, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the board stays on its first frame.