Filter, sort & drag grid that glides
Filter chips hide cells, sort buttons and drag/Alt+arrow reorder only change CSS order; every swatch is anchored to its grid cell with data-⚓️-fill + data-⚓️-transition="glide" + data-⚓️-scope-name, so it glides to it…
<header class="intro">
<h1>Filter, sort & drag grid</h1>
<p>
A plain CSS grid of invisible cells reflows instantly; every swatch is anchored to its cell with
<code>data-⚓️-fill</code> and glides after it (<code>data-⚓️-transition="glide"</code>,
<code>data-⚓️-scope-name</code> so all cells share one anchor name). Filters toggle <code>hidden</code>,
sorting and dragging only change each cell's CSS <code>order</code>. Drag a swatch (grip on touch) or
focus one and press <kbd>Alt</kbd>+<kbd>←</kbd>/<kbd>→</kbd>.
</p>
</header>
<div class="toolbar">
<fieldset class="chips" aria-label="Filter">
<label><input type="radio" name="filter" value="all" checked><span>All <i>24</i></span></label>
<label><input type="radio" name="filter" value="warm"><span>Warm <i>8</i></span></label>
<label><input type="radio" name="filter" value="cool"><span>Cool <i>8</i></span></label>
<label><input type="radio" name="filter" value="neutral"><span>Neutral <i>8</i></span></label>
</fieldset>
<fieldset class="chips" aria-label="Sort">
<label><input type="radio" name="sort" value="hue"><span>Hue</span></label>
<label><input type="radio" name="sort" value="light"><span>Lightness</span></label>
<label><input type="radio" name="sort" value="name"><span>A–Z</span></label>
<button id="shuffle" type="button">Shuffle</button>
</fieldset>
<label class="xray-toggle"><input type="checkbox" id="xray"> X-ray</label>
</div>
<div class="grid" id="grid" data-⚓️-root>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="warm" data-l="58" data-c="0.13" data-h="45">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(58% .13 45)" aria-label="Terracotta">
<span class="face"><b>Terracotta</b><small>58% .13 45</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="neutral" data-l="80" data-c="0.01" data-h="250">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(80% .01 250)" aria-label="Fog">
<span class="face"><b>Fog</b><small>80% .01 250</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="neutral" data-l="96" data-c="0.01" data-h="80">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(96% .01 80)" aria-label="Linen">
<span class="face"><b>Linen</b><small>96% .01 80</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="cool" data-l="40" data-c="0.1" data-h="290">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(40% .1 290)" aria-label="Dusk">
<span class="face"><b>Dusk</b><small>40% .1 290</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="warm" data-l="86" data-c="0.06" data-h="10">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(86% .06 10)" aria-label="Blush">
<span class="face"><b>Blush</b><small>86% .06 10</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="cool" data-l="86" data-c="0.09" data-h="165">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(86% .09 165)" aria-label="Mint">
<span class="face"><b>Mint</b><small>86% .09 165</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="neutral" data-l="70" data-c="0.015" data-h="70">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(70% .015 70)" aria-label="Stone">
<span class="face"><b>Stone</b><small>70% .015 70</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="neutral" data-l="22" data-c="0.015" data-h="270">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(22% .015 270)" aria-label="Ink">
<span class="face"><b>Ink</b><small>22% .015 270</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="warm" data-l="75" data-c="0.14" data-h="75">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(75% .14 75)" aria-label="Honey">
<span class="face"><b>Honey</b><small>75% .14 75</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="neutral" data-l="65" data-c="0.025" data-h="50">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(65% .025 50)" aria-label="Clay">
<span class="face"><b>Clay</b><small>65% .025 50</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="cool" data-l="50" data-c="0.18" data-h="260">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(50% .18 260)" aria-label="Cobalt">
<span class="face"><b>Cobalt</b><small>50% .18 260</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="neutral" data-l="93" data-c="0.015" data-h="85">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(93% .015 85)" aria-label="Bone">
<span class="face"><b>Bone</b><small>93% .015 85</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="cool" data-l="65" data-c="0.12" data-h="200">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(65% .12 200)" aria-label="Lagoon">
<span class="face"><b>Lagoon</b><small>65% .12 200</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="warm" data-l="62" data-c="0.2" data-h="35">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(62% .2 35)" aria-label="Ember">
<span class="face"><b>Ember</b><small>62% .2 35</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="cool" data-l="72" data-c="0.07" data-h="150">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(72% .07 150)" aria-label="Sage">
<span class="face"><b>Sage</b><small>72% .07 150</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="cool" data-l="88" data-c="0.05" data-h="220">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(88% .05 220)" aria-label="Glacier">
<span class="face"><b>Glacier</b><small>88% .05 220</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="warm" data-l="70" data-c="0.16" data-h="25">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(70% .16 25)" aria-label="Coral">
<span class="face"><b>Coral</b><small>70% .16 25</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="neutral" data-l="35" data-c="0.01" data-h="260">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(35% .01 260)" aria-label="Graphite">
<span class="face"><b>Graphite</b><small>35% .01 260</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="warm" data-l="82" data-c="0.16" data-h="85">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(82% .16 85)" aria-label="Saffron">
<span class="face"><b>Saffron</b><small>82% .16 85</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="warm" data-l="80" data-c="0.12" data-h="65">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(80% .12 65)" aria-label="Apricot">
<span class="face"><b>Apricot</b><small>80% .12 65</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="neutral" data-l="55" data-c="0.02" data-h="250">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(55% .02 250)" aria-label="Slate">
<span class="face"><b>Slate</b><small>55% .02 250</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="cool" data-l="45" data-c="0.09" data-h="160">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(45% .09 160)" aria-label="Pine">
<span class="face"><b>Pine</b><small>45% .09 160</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="warm" data-l="48" data-c="0.13" data-h="15">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(48% .13 15)" aria-label="Rosewood">
<span class="face"><b>Rosewood</b><small>48% .13 15</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
<div class="cell" data-⚓️-name="--cell" data-⚓️-scope-name="--cell" data-family="cool" data-l="58" data-c="0.17" data-h="285">
<button class="tile" data-⚓️-to="--cell" data-⚓️-fill data-⚓️-transition="glide" style="--c: oklch(58% .17 285)" aria-label="Iris">
<span class="face"><b>Iris</b><small>58% .17 285</small><span class="grip" aria-hidden="true">⠿</span></span>
</button>
</div>
</div>/* Demo styling only. Every tile position comes from data-⚓️-* attributes. */
:root {
--paper: oklch(97% 0.008 85);
--ink: oklch(25% 0.015 60);
--muted: oklch(50% 0.015 60);
--line: oklch(88% 0.012 80);
--accent: oklch(55% 0.16 35);
color-scheme: light;
font: 400 1rem/1.5 system-ui, sans-serif;
color: var(--ink);
background: var(--paper);
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: clamp(1rem, 4vw, 2.5rem);
display: grid;
gap: 1.25rem;
max-width: 64rem;
margin-inline: auto;
}
.intro h1 { margin: 0 0 0.4rem; font-size: 1.4rem; letter-spacing: -0.01em; }
.intro p { margin: 0; color: var(--muted); max-width: 68ch; }
code, kbd { font: 0.85em ui-monospace, monospace; color: var(--accent); white-space: nowrap; }
kbd { padding: 0 0.3em; border: 1px solid var(--line); border-radius: 0.25em; background: white; color: var(--ink); }
/* ---------- toolbar ---------- */
.toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; }
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin: 0;
padding: 0.25rem;
border: 1px solid var(--line);
border-radius: 99px;
background: white;
}
.chips label { display: grid; cursor: pointer; }
.chips input { position: absolute; opacity: 0; pointer-events: none; }
.chips span, .chips button {
display: inline-flex;
gap: 0.35rem;
align-items: baseline;
padding: 0.35rem 0.85rem;
border-radius: 99px;
border: 0;
background: none;
font: 500 0.88rem system-ui;
color: var(--muted);
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.chips i { font: 600 0.72rem ui-monospace, monospace; font-style: normal; opacity: 0.7; }
.chips input:checked + span { background: var(--ink); color: var(--paper); }
.chips button:hover, .chips span:hover { color: var(--ink); }
.chips input:focus-visible + span, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.xray-toggle {
display: inline-flex;
gap: 0.4rem;
align-items: center;
margin-inline-start: auto;
font: 500 0.85rem ui-monospace, monospace;
padding: 0.4rem 0.8rem;
border: 1px solid var(--line);
border-radius: 99px;
background: white;
cursor: pointer;
}
/* ---------- the grid: invisible cells, reflowing instantly ---------- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(8.5rem, 40vw), 1fr));
gap: 0.75rem;
}
.cell {
aspect-ratio: 1;
/* A hidden cell keeps its slot for the fade-out, then drops out of the
grid; that's the moment every other tile glides. */
transition: display 0.22s allow-discrete;
}
/* ---------- the tiles: anchored to their cell, positioned by the library ---------- */
.tile {
padding: 0;
border: 0;
background: none;
font: inherit;
cursor: grab;
border-radius: 1rem;
}
.tile:focus-visible { outline: 3px solid var(--ink); outline-offset: 3px; }
.tile.lifted { z-index: 2; cursor: grabbing; }
.face {
position: relative;
display: grid;
align-content: end;
block-size: 100%;
padding: 0.75rem;
border-radius: inherit;
background: var(--c);
/* black or white text, picked from the swatch's own lightness */
color: oklch(from var(--c) round(1.18 - l) 0 0);
text-align: start;
box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.06);
transition: opacity 0.22s ease, scale 0.22s ease, box-shadow 0.2s ease;
}
@starting-style { .face { opacity: 0; scale: 0.85; } }
.cell[hidden] .face { opacity: 0; scale: 0.85; }
.lifted .face { scale: 1.06; box-shadow: 0 16px 30px -12px oklch(20% 0.02 60 / 0.45); }
.face b { font-size: 0.95rem; }
.face small { font: 0.7rem ui-monospace, monospace; opacity: 0.75; }
.grip {
position: absolute;
inset-block-start: 0.4rem;
inset-inline-end: 0.45rem;
padding: 0.2rem 0.35rem;
border-radius: 0.4rem;
font-size: 1rem;
line-height: 1;
opacity: 0.55;
touch-action: none; /* touch drags start here; the rest of the tile still scrolls */
}
@media (pointer: fine) { .grip { opacity: 0; } .tile:hover .grip { opacity: 0.55; } }
/* ---------- X-ray: see the cells snap while the tiles glide ---------- */
.xray .cell {
outline: 2px dashed oklch(58% 0.2 25);
outline-offset: -1px;
border-radius: 1rem;
}
.xray .tile::after {
content: "order " attr(data-rank);
position: absolute;
inset-block-start: 0.35rem;
inset-inline-start: 0.45rem;
z-index: 3;
padding: 0.05rem 0.35rem;
border-radius: 0.3rem;
font: 600 0.66rem ui-monospace, monospace;
background: oklch(58% 0.2 25);
color: white;
}
.xray .face { opacity: 0.6; }// State only: which cells are hidden and each cell's CSS `order`. The grid
// reflows instantly; the anchored tiles glide after their cells on their own.
const grid = document.getElementById("grid");
const cells = [...grid.querySelectorAll(".cell")];
const byOrder = () => [...cells].sort((a, b) => a.style.order - b.style.order);
function applyOrder(list) {
list.forEach((c, i) => {
c.style.order = i;
c.firstElementChild.dataset.rank = i + 1; // shown in X-ray mode
});
}
const d = (c) => c.dataset;
const hue = (c) => (+d(c).c < 0.03 ? 1000 + +d(c).l : +d(c).h); // neutrals last
const SORTS = {
hue: (a, b) => hue(a) - hue(b),
light: (a, b) => d(b).l - d(a).l,
name: (a, b) => a.textContent.trim().localeCompare(b.textContent.trim()),
};
function sort(kind) {
applyOrder(kind === "shuffle"
? byOrder().map((c) => [Math.random(), c]).sort((x, y) => x[0] - y[0]).map((x) => x[1])
: [...cells].sort(SORTS[kind]));
}
function filter(family) {
for (const c of cells) c.hidden = !(family === "all" || d(c).family === family);
}
function move(cell, toIndex) {
const list = byOrder().filter((c) => c !== cell);
list.splice(toIndex, 0, cell);
applyOrder(list);
document.querySelectorAll('input[name="sort"]').forEach((r) => (r.checked = false)); // now "custom"
}
// Drag reorder: the dragged tile never follows the pointer itself. We only
// find which cell is under the pointer and move the dragged cell there; the
// tile glides to its (new) cell like every other tile.
let dragging = null;
grid.addEventListener("pointerdown", (e) => {
const tile = e.target.closest(".tile");
if (!tile || (e.pointerType !== "mouse" && !e.target.closest(".grip"))) return;
e.preventDefault();
dragging = tile.parentElement;
tile.setPointerCapture(e.pointerId);
tile.classList.add("lifted");
tile.focus({ preventScroll: true });
});
grid.addEventListener("pointermove", (e) => {
if (!dragging) return;
const over = document.elementsFromPoint(e.clientX, e.clientY)
.find((el) => el.classList.contains("cell") && el !== dragging && !el.hidden);
if (over) move(dragging, byOrder().indexOf(over));
});
const drop = () => {
dragging?.querySelector(".tile").classList.remove("lifted");
dragging = null;
};
grid.addEventListener("pointerup", drop);
grid.addEventListener("pointercancel", drop);
// Keyboard reorder: Alt + arrow moves the focused swatch one visible slot.
grid.addEventListener("keydown", (e) => {
const step = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[e.key];
if (!e.altKey || !step || !e.target.matches(".tile")) return;
e.preventDefault();
const visible = byOrder().filter((c) => !c.hidden);
const cell = e.target.parentElement;
const neighbour = visible[visible.indexOf(cell) + step];
if (neighbour) move(cell, byOrder().indexOf(neighbour));
});
document.querySelectorAll('input[name="filter"]').forEach((r) => (r.onchange = () => filter(r.value)));
document.querySelectorAll('input[name="sort"]').forEach((r) => (r.onchange = () => sort(r.value)));
document.getElementById("shuffle").onclick = () => {
document.querySelectorAll('input[name="sort"]').forEach((r) => (r.checked = false));
sort("shuffle");
};
document.getElementById("xray").onchange = (e) => document.body.classList.toggle("xray", e.target.checked);
applyOrder(cells);data-⚓️-filldata-⚓️-namedata-⚓️-rootdata-⚓️-scope-namedata-⚓️-todata-⚓️-transition