№ 013
Indexed Drawer
A pixel search field with a drawer of index cards hidden inside it. ⌘K to open.
- Version
- 1.0.0
- Download
- 12 KB · 3 files
- Added
- Oct 26, 2026
- Built with
- HTML, CSS, JS
- Needs
- Google Fonts
- Tags
- search, command palette, pixel
Usage
Copy indexed-drawer.css and indexed-drawer.js (and assets/, if there is one) into your project, then:
new IndexedDrawer(element, {
items: [{ title: 'Music Library', type: 'FOLDER' }, ...],
placeholder: 'Search files...',
rows: 3, // cards visible at first; pull the drawer's front panel to show more
onSelect(item) {},
})
A pixel search field with a drawer hidden inside it. Focus it (or press ⌘/Ctrl + K) and the drawer
slides out beneath the field, its index cards rising into their slots. Typing filters immediately,
arrow keys move the highlight, Enter opens the highlighted card, Escape or a click outside closes.
Drag the front panel down (or Alt + ↓/↑) to pull the drawer further out and see more cards at once;
it snaps to whole rows. Longer lists scroll with the arrow keys or the wheel.
The animation is only feedback: results can be read and chosen from the first keystroke.Source
/* Indexed Drawer — a pixel search field with a drawer inside. Laid out on a 360-unit grid (--u),
the field's design width, so it scales as one piece with its container. The drawer hangs below the
field without taking up layout space, like any dropdown. */
.id {
--id-paper: #f1ede3;
--id-paper-lt: #f6f3eb;
--id-paper-dk: #ddd6c8;
--id-drawer: #ece7dc;
--id-lip: #c9c2b5;
--id-ink: #222a28;
--id-line: #a59f97;
--id-muted: #6f6a62;
--id-sage: #a5b7a5;
--id-sage-dk: #839684;
--id-coral: #e27c62;
--id-shadow: rgba(60, 54, 44, 0.24);
--id-bitmap: "DotGothic16", "Silkscreen", ui-monospace, monospace;
--id-mono: "IBM Plex Mono", ui-monospace, monospace;
container-type: inline-size;
position: relative;
width: 100%;
color: var(--id-ink);
}
.id *,
.id *::before,
.id *::after {
box-sizing: border-box;
}
.id-stage {
--u: calc(100cqw / 360);
position: relative;
width: 100%;
height: calc(var(--u) * 56);
}
/* one-pixel stepped corners: the corner square the size of the border is cut away */
.id-field,
.id-body,
.id-lip,
.id-card {
--st: calc(var(--u) * 2);
clip-path: polygon(
var(--st) 0, calc(100% - var(--st)) 0, calc(100% - var(--st)) var(--st), 100% var(--st),
100% calc(100% - var(--st)), calc(100% - var(--st)) calc(100% - var(--st)), calc(100% - var(--st)) 100%,
var(--st) 100%, var(--st) calc(100% - var(--st)), 0 calc(100% - var(--st)), 0 var(--st), var(--st) var(--st)
);
}
/* ── field ── */
.id-fieldwrap {
position: absolute;
inset: 0 0 auto;
height: calc(var(--u) * 52);
z-index: 2;
filter: drop-shadow(calc(var(--u) * 4) calc(var(--u) * 4) 0 var(--id-shadow));
}
.id-field {
position: absolute;
inset: 0;
border: calc(var(--u) * 2) solid var(--id-ink);
background: var(--id-paper);
box-shadow: inset 0 calc(var(--u) * -4) 0 var(--id-paper-dk), inset 0 calc(var(--u) * 2) 0 var(--id-paper-lt);
cursor: text;
}
.is-active .id-field {
box-shadow: inset 0 calc(var(--u) * -4) 0 var(--id-paper-dk), inset 0 0 0 calc(var(--u) * 2) var(--id-sage);
}
.id-icon {
position: absolute;
left: calc(var(--u) * 14);
top: calc(var(--u) * 14);
width: calc(var(--u) * 18);
fill: var(--id-ink);
}
.id-icon svg { display: block; width: 100%; }
.id-rule {
position: absolute;
left: calc(var(--u) * 42);
top: calc(var(--u) * 11);
width: calc(var(--u) * 1.5);
height: calc(var(--u) * 26);
background: #8a827a;
}
.id-rule-r {
left: auto;
right: calc(var(--u) * 64);
}
.id-entry {
position: absolute;
left: calc(var(--u) * 52);
right: calc(var(--u) * 72);
top: 0;
bottom: calc(var(--u) * 2);
display: flex;
align-items: center;
}
.id-input,
.id-mirror {
font: 400 calc(var(--u) * 15) / 1 var(--id-bitmap);
letter-spacing: 0.04em;
}
.id-input {
position: relative;
z-index: 1;
width: 100%;
padding: 0;
border: 0;
outline: none;
background: transparent;
color: var(--id-ink);
caret-color: transparent; /* the caret is drawn as a pixel block */
}
.id-input::placeholder {
color: #8d877e;
opacity: 1;
}
/* invisible copy of the text: measures where the caret goes, and shows select-all */
.id-mirror {
position: absolute;
left: 0;
white-space: pre;
color: transparent;
visibility: hidden;
}
.is-all .id-mirror {
visibility: visible;
background: var(--id-sage);
box-shadow: 0 0 0 calc(var(--u) * 2) var(--id-sage);
}
.id-caret {
position: absolute;
top: 50%;
width: calc(var(--u) * 2.5);
height: calc(var(--u) * 18);
margin-top: calc(var(--u) * -9);
margin-left: calc(var(--u) * 1);
background: var(--id-coral);
}
.id-kbd {
position: absolute;
right: calc(var(--u) * 12);
top: calc(var(--u) * 11);
width: calc(var(--u) * 42);
height: calc(var(--u) * 26);
display: grid;
place-items: center;
border: calc(var(--u) * 1.5) solid #c4beb3;
font: 500 calc(var(--u) * 11) / 1 var(--id-mono);
letter-spacing: 0.12em;
color: var(--id-ink);
}
.is-active .id-kbd { visibility: hidden; }
@media (hover: none) {
.id-kbd,
.id-rule-r { display: none; }
.id-entry { right: calc(var(--u) * 16); }
}
/* ── drawer ── */
.id-well {
--recess: 0;
position: absolute;
left: 0;
right: 0;
top: calc(var(--u) * 50); /* tucked under the field's bottom border */
height: calc(var(--u) * 470); /* room for the drawer pulled out to eight rows */
clip-path: inset(0 -10% -10% -10%);
pointer-events: none;
z-index: 1;
}
/* the dark recess the drawer slides out of */
.id-well::before {
content: "";
position: absolute;
left: calc(var(--u) * 22);
right: calc(var(--u) * 22);
top: 0;
height: calc(var(--u) * 4);
background: var(--id-ink);
opacity: var(--recess);
}
.id-drawer {
--body: 180;
position: absolute;
inset: 0 0 auto;
}
/* hard shadow behind the body and the front panel, as plain layers: a drop-shadow filter over these
clipped shapes sometimes failed to paint the body at all (the drawer blinked in recordings) */
.id-drawer::before,
.id-drawer::after {
content: "";
position: absolute;
background: var(--id-shadow);
}
.id-drawer::before {
left: calc(var(--u) * 28);
right: calc(var(--u) * 20);
top: calc(var(--u) * 4);
height: calc(var(--u) * var(--body));
}
.id-drawer::after {
left: calc(var(--u) * 24);
right: calc(var(--u) * 16);
top: calc(var(--u) * (var(--body) + 4));
height: calc(var(--u) * 18);
}
.is-open .id-drawer { pointer-events: auto; }
/* the drawer is a little narrower than the field, as if sliding inside a housing */
.id-body {
position: absolute;
left: calc(var(--u) * 24);
right: calc(var(--u) * 24);
top: 0;
height: calc(var(--u) * var(--body));
border: calc(var(--u) * 2) solid var(--id-ink);
border-bottom: 0;
background: var(--id-drawer);
box-shadow: inset 0 calc(var(--u) * 3) 0 var(--id-paper-dk);
}
.id-head {
position: absolute;
left: calc(var(--u) * 12);
right: calc(var(--u) * 12);
top: calc(var(--u) * 12);
display: flex;
align-items: center;
justify-content: space-between;
}
.id-range {
margin-left: auto;
margin-right: calc(var(--u) * 10);
font: 500 calc(var(--u) * 9) / 1 var(--id-mono);
letter-spacing: 0.12em;
color: var(--id-muted);
}
.id-count {
font: 500 calc(var(--u) * 9) / 1 var(--id-mono);
letter-spacing: 0.16em;
color: var(--id-ink);
white-space: nowrap;
}
.id-dots {
display: flex;
gap: calc(var(--u) * 3);
}
.id-dots i {
width: calc(var(--u) * 5);
height: calc(var(--u) * 8);
background: #b9b2a6;
}
.id-dots i.is-on { background: var(--id-coral); }
.id-list {
position: absolute;
left: calc(var(--u) * 10);
right: calc(var(--u) * 10);
top: calc(var(--u) * 32);
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: calc(var(--u) * 8);
}
.id-card {
position: relative;
height: calc(var(--u) * 40);
display: flex;
align-items: center;
border: calc(var(--u) * 1.5) solid var(--id-line);
background: var(--id-paper);
cursor: pointer;
}
.id-card[hidden] { display: none; }
.id-card.is-selected {
background: var(--id-sage);
border-color: var(--id-sage-dk);
}
.id-card.is-pressed {
border-color: var(--id-ink);
background: var(--id-sage-dk);
}
.id-num {
width: calc(var(--u) * 33);
padding-left: calc(var(--u) * 10);
font: 400 calc(var(--u) * 11.5) / 1 var(--id-bitmap);
letter-spacing: 0.06em;
}
.id-num::after {
content: "";
position: absolute;
left: calc(var(--u) * 34);
top: calc(var(--u) * 10);
width: calc(var(--u) * 1.5);
height: calc(var(--u) * 17);
background: currentColor;
opacity: 0.6;
}
.id-title {
flex: 1;
min-width: 0;
padding-left: calc(var(--u) * 12);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: 400 calc(var(--u) * 13) / 1 var(--id-bitmap);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.id-type {
width: calc(var(--u) * 54);
font: 500 calc(var(--u) * 8.5) / 1 var(--id-mono);
letter-spacing: 0.18em;
}
.id-card svg {
width: calc(var(--u) * 5);
margin-right: calc(var(--u) * 10);
fill: var(--id-coral);
visibility: hidden;
}
.id-card.is-selected svg { visibility: inherit; } /* hidden with its card while it rises */
.id-empty {
position: absolute;
left: 0;
right: 0;
top: calc(var(--u) * 32);
height: calc(var(--u) * 40);
margin: 0;
display: grid;
place-items: center;
font: 500 calc(var(--u) * 9.5) / 1 var(--id-mono);
letter-spacing: 0.14em;
color: var(--id-muted);
}
.id-empty[hidden] { display: none; }
/* front panel, a touch wider than the body */
.id-lip {
position: absolute;
left: calc(var(--u) * 20);
right: calc(var(--u) * 20);
top: calc(var(--u) * var(--body));
height: calc(var(--u) * 18);
border: calc(var(--u) * 2) solid var(--id-ink);
background: var(--id-lip);
box-shadow: inset 0 calc(var(--u) * 2) 0 #dbd4c7;
}
/* three short ridges in the middle of the panel: something to pull */
.id-grip {
position: absolute;
left: 50%;
top: calc(var(--u) * 3.5);
width: calc(var(--u) * 28);
height: calc(var(--u) * 7);
margin-left: calc(var(--u) * -14);
background: repeating-linear-gradient(to bottom, #8f887c 0 calc(var(--u) * 1.5), transparent 0 calc(var(--u) * 3));
}
.id-lip:has(~ .id-pull:hover) .id-grip,
.is-pulling .id-grip {
background: repeating-linear-gradient(to bottom, var(--id-ink) 0 calc(var(--u) * 1.5), transparent 0 calc(var(--u) * 3));
}
/* the grab area: the panel plus a margin above and below, so the thin panel is easy to catch */
.id-pull {
position: absolute;
left: calc(var(--u) * 20);
right: calc(var(--u) * 20);
top: calc(var(--u) * (var(--body) - 8));
height: calc(var(--u) * 34);
cursor: ns-resize;
touch-action: none;
}
.is-pulling,
.is-pulling * { cursor: ns-resize !important; }
.id-live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
} /* Indexed Drawer — usage:
* new IndexedDrawer(element, {
* items: [{ title: 'Music Library', type: 'FOLDER' }, ...],
* placeholder: 'Search files...',
* rows: 3, // cards visible at first; pull the drawer's front panel to show more
* onSelect(item) {},
* })
*
* A pixel search field with a drawer hidden inside it. Focus it (or press ⌘/Ctrl + K) and the drawer
* slides out beneath the field, its index cards rising into their slots. Typing filters immediately,
* arrow keys move the highlight, Enter opens the highlighted card, Escape or a click outside closes.
* Drag the front panel down (or Alt + ↓/↑) to pull the drawer further out and see more cards at once;
* it snaps to whole rows. Longer lists scroll with the arrow keys or the wheel.
* The animation is only feedback: results can be read and chosen from the first keystroke.
*/
(function () {
const MIN_ROWS = 2, MAX_ROWS = 8; // how far the drawer can be pushed in or pulled out
const CARD = 40, GAP = 8; // card height and spacing (units of a 360-unit-wide field)
const PITCH = CARD + GAP;
const HEAD = 34, FOOT = 10; // drawer interior above and below the cards
const LIP = 18; // the drawer's front panel, which is also its pull
const RECESS = 2; // dark gap between field and drawer when open
// opening choreography (s)
const OPEN = { release: 0.08, extend: 0.16, extended: 0.36, cards: 0.26, stagger: 0.06, rise: 0.14, settle: 0.52, done: 0.6 };
// closing (s): cards drop first, then the drawer retracts
const CLOSE = { cards: 0.1, retract: 0.08, done: 0.3 };
const PRESS = 0.09; // a chosen card compresses for this long before the drawer closes
const RESIZE = 0.14; // the drawer adjusts to a new number of rows over this long
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const lerp = (a, b, t) => a + (b - a) * t;
const unit = (t, from, dur) => clamp((t - from) / dur, 0, 1);
const easeOut = (t) => 1 - (1 - t) ** 3;
const easeIn = (t) => t ** 3;
const pad = (n, w) => String(n).padStart(w, '0');
const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const isMac = /Mac|iPhone|iPad/.test(navigator.platform);
const bodyH = (rows) => HEAD + rows * PITCH - GAP + FOOT;
const sprite = (art) => {
const rows = art.trim().split('\n').map((r) => r.trim());
let d = '';
rows.forEach((row, y) => {
for (let x = 0; x < row.length; ) {
if (row[x] !== 'X') { x++; continue; }
const s = x;
while (row[x] === 'X') x++;
d += `M${s} ${y}h${x - s}v1h${s - x}z`;
}
});
return `<svg viewBox="0 0 ${rows[0].length} ${rows.length}" shape-rendering="crispEdges" aria-hidden="true"><path d="${d}"/></svg>`;
};
const ICON = {
search: sprite(`
..XXXXX.......
.XXXXXXX......
XXX...XXX.....
XX.....XX.....
XX.....XX.....
XX.....XX.....
XXX...XXX.....
.XXXXXXX......
..XXXXXXX.....
.......XXX....
........XXX...
.........XXX..
..........XXX.
...........XX.`),
arrow: sprite(`
X...
XX..
XXX.
XXXX
XXX.
XX..
X...`),
};
// Ranks titles that start with the query first, then words that do, then any match.
const search = (items, q) => {
q = q.trim().toLowerCase();
if (!q) return items.slice();
const scored = [];
items.forEach((item, i) => {
const t = item.title.toLowerCase();
const at = t.indexOf(q);
if (at < 0) return;
const rank = at === 0 ? 0 : /\s|[._-]/.test(t[at - 1]) ? 1 : 2;
scored.push({ item, rank, i });
});
return scored.sort((a, b) => a.rank - b.rank || a.i - b.i).map((s) => s.item);
};
let uid = 0;
class IndexedDrawer {
constructor(root, { items = [], placeholder = 'Search files...', rows = 3, onSelect } = {}) {
this.root = root;
this.items = items;
this.onSelect = onSelect;
this.initialSize = clamp(rows, MIN_ROWS, MAX_ROWS);
this.id = `id${++uid}`;
this.build(placeholder);
this.reset();
this.render();
}
// Everything visual comes from this state and a timestamp, so the reel can rebuild any moment
// by replaying the keystrokes.
reset() {
this.isOpen = false;
this.active = false; // field focused
this.query = '';
this.allSelected = false;
this.results = this.items.slice();
this.sel = 0; // highlighted result (index into results)
this.top = 0; // first result in view
this.size = this.initialSize; // rows the drawer is pulled out to
this.move = null; // { open, at, from } — the last open or close
this.rows = this.rowsFor(this.results);
this.resize = { from: this.rows, to: this.rows, at: -1 };
this.pull = null; // live drag of the front panel: { rows } as a fraction
this.pressAt = -1;
this.pending = null; // close scheduled after a card press
this.typedAt = -1; // restarts the caret blink
this.input.value = '';
this.fill();
}
clock() {
return performance.now() / 1000;
}
// rows on show: as many as the drawer is pulled out to, or fewer if there aren't that many results
rowsFor(results) {
return clamp(results.length, 1, this.size);
}
build(placeholder) {
this.root.classList.add('id');
const cards = Array.from({ length: MAX_ROWS }, (_, i) => `
<li class="id-card" role="option" id="${this.id}-opt${i}" aria-selected="false">
<span class="id-num"></span><span class="id-title"></span><span class="id-type"></span>${ICON.arrow}
</li>`).join('');
this.root.innerHTML = `
<div class="id-stage">
<div class="id-well">
<div class="id-drawer">
<div class="id-body">
<div class="id-head"><span class="id-count"></span><span class="id-range"></span><span class="id-dots"><i></i><i></i><i></i></span></div>
<ul class="id-list" role="listbox" id="${this.id}-list" aria-label="Results">${cards}</ul>
<p class="id-empty" hidden></p>
</div>
<div class="id-lip"><span class="id-grip"></span></div>
<div class="id-pull" title="Drag to show more" aria-hidden="true"></div>
</div>
</div>
<div class="id-fieldwrap"><div class="id-field">
<span class="id-icon">${ICON.search}</span>
<span class="id-rule"></span>
<span class="id-entry">
<span class="id-mirror" aria-hidden="true"></span>
<input class="id-input" type="text" spellcheck="false" autocomplete="off" placeholder="${esc(placeholder)}"
role="combobox" aria-label="Search" aria-autocomplete="list" aria-expanded="false" aria-controls="${this.id}-list"
aria-description="Alt and arrow keys pull the drawer out further">
<span class="id-caret" aria-hidden="true"></span>
</span>
<span class="id-rule id-rule-r"></span>
<kbd class="id-kbd" aria-hidden="true">${isMac ? '⌘' : 'Ctrl'} K</kbd>
</div></div>
<p class="id-live" aria-live="polite"></p>
</div>`;
const q = (s) => this.root.querySelector(s);
this.drawer = q('.id-drawer');
this.well = q('.id-well');
this.pullEl = q('.id-pull');
this.list = q('.id-list');
this.input = q('.id-input');
this.mirror = q('.id-mirror');
this.caret = q('.id-caret');
this.countEl = q('.id-count');
this.rangeEl = q('.id-range');
this.dots = [...this.root.querySelectorAll('.id-dots i')];
this.cards = [...this.root.querySelectorAll('.id-card')];
this.emptyEl = q('.id-empty');
this.live = q('.id-live');
this.input.addEventListener('focus', () => this.focus());
this.input.addEventListener('blur', () => {
this.active = false;
this.loop();
});
this.input.addEventListener('input', () => {
this.allSelected = false;
this.setQuery(this.input.value);
});
this.input.addEventListener('select', () => {
this.allSelected = this.input.value && this.input.selectionStart === 0 && this.input.selectionEnd === this.input.value.length;
this.loop();
});
this.input.addEventListener('keydown', (e) => {
if (e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
this.setSize(this.size + (e.key === 'ArrowDown' ? 1 : -1));
} else if (e.key === 'ArrowDown') { e.preventDefault(); this.step(1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); this.step(-1); }
else if (e.key === 'Enter') { e.preventDefault(); this.choose(); }
else if (e.key === 'Escape') { e.preventDefault(); this.close(); }
else if (e.key === 'Tab') this.close();
else if (e.key.length === 1 || e.key === 'Backspace') this.allSelected = false;
});
this.cards.forEach((card, i) => {
card.addEventListener('pointerdown', (e) => e.preventDefault()); // keep focus in the input
card.addEventListener('pointerenter', () => !this.pull && this.highlight(this.top + i));
card.addEventListener('click', () => {
this.highlight(this.top + i);
this.choose();
});
});
this.list.addEventListener('wheel', (e) => {
if (!this.isOpen || this.results.length <= this.rows) return;
e.preventDefault();
this.scrollBy(Math.sign(e.deltaY));
}, { passive: false });
// the front panel is a pull: drag it down for more rows, up for fewer
let startY = 0, startRows = 0, unitPx = 1;
this.pullEl.addEventListener('pointerdown', (e) => {
if (!this.isOpen || e.button !== 0) return;
e.preventDefault(); // keep focus in the input
this.pullEl.setPointerCapture(e.pointerId);
unitPx = this.root.getBoundingClientRect().width / 360;
startY = e.clientY;
startRows = this.rowsAt(this.clock());
this.pull = { rows: startRows };
this.root.classList.add('is-pulling');
this.loop();
});
this.pullEl.addEventListener('pointermove', (e) => {
if (!this.pull || !this.pullEl.hasPointerCapture(e.pointerId)) return;
this.pull.rows = this.pullRows(startRows + (e.clientY - startY) / unitPx / PITCH);
});
const release = () => {
if (!this.pull) return;
const rows = this.pull.rows;
this.pull = null;
this.root.classList.remove('is-pulling');
this.setSize(Math.round(rows), rows);
};
this.pullEl.addEventListener('pointerup', release);
this.pullEl.addEventListener('pointercancel', release);
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
this.shortcut();
}
});
document.addEventListener('pointerdown', (e) => {
if (this.isOpen && !this.root.contains(e.target)) this.close();
});
}
// Rows a pull of the front panel asks for: free between the stops, stiff past them.
pullRows(r) {
const lo = MIN_ROWS, hi = Math.max(lo, Math.min(MAX_ROWS, this.results.length));
if (r < lo) return lo - (1 - Math.exp(-(lo - r))) * 0.35;
if (r > hi) return hi + (1 - Math.exp(-(r - hi))) * 0.35;
return r;
}
/* ── actions ── */
focus(now = this.clock()) {
this.active = true;
this.typedAt = now;
this.open(now);
}
// ⌘/Ctrl + K: focus the field and select what's in it, ready to search again
shortcut(now = this.clock()) {
if (document.activeElement !== this.input) this.input.focus({ preventScroll: true });
this.focus(now);
this.input.select();
this.allSelected = !!this.input.value;
this.loop();
}
// a close that was scheduled after a card press, if its time has come
due(now) {
if (this.pending !== null && now >= this.pending) this.close(this.pending);
}
open(now = this.clock()) {
this.due(now);
if (this.isOpen) return;
this.isOpen = true;
this.move = { open: true, at: now, from: this.drawerY(now) };
this.input.setAttribute('aria-expanded', 'true');
this.fill();
this.loop();
}
close(now = this.clock()) {
if (!this.isOpen) return;
this.isOpen = false;
this.pending = null;
this.pull = null;
this.root.classList.remove('is-pulling');
this.move = { open: false, at: now, from: this.drawerY(now) };
this.input.setAttribute('aria-expanded', 'false');
this.input.removeAttribute('aria-activedescendant');
this.loop();
}
// Pulls the drawer out (or pushes it in) to a number of rows. `from` is where a drag let go.
setSize(rows, from = this.rowsAt(this.clock()), now = this.clock()) {
this.due(now);
if (!this.isOpen) return;
this.size = clamp(rows, MIN_ROWS, MAX_ROWS);
this.reflow(from, now);
this.fill();
this.loop();
}
// Types a character the way a keystroke would (used by the reel; real typing goes through the input).
type(ch, now = this.clock()) {
const q = this.allSelected ? ch : this.query + ch;
this.allSelected = false;
this.input.value = q;
this.setQuery(q, now);
}
backspace(now = this.clock()) {
const q = this.allSelected ? '' : this.query.slice(0, -1);
this.allSelected = false;
this.input.value = q;
this.setQuery(q, now);
}
setQuery(q, now = this.clock()) {
this.query = q;
this.typedAt = now;
this.results = search(this.items, q);
this.sel = 0;
this.top = 0;
this.reflow(this.rowsAt(now), now);
this.fill();
this.open(now);
this.loop();
}
// adjusts the drawer to the rows the current results and size call for
reflow(from, now) {
const rows = this.rowsFor(this.results);
if (rows !== this.rows || from !== rows) {
this.resize = { from, to: rows, at: now };
this.rows = rows;
}
this.top = clamp(this.top, 0, Math.max(0, this.results.length - rows));
this.sel = clamp(this.sel, 0, Math.max(0, this.results.length - 1));
this.keepInView();
}
step(d, now = this.clock()) {
this.due(now);
if (!this.isOpen) {
this.open(now);
return;
}
const n = this.results.length;
if (n) this.highlight((this.sel + d + n) % n);
}
scrollBy(d) {
this.top = clamp(this.top + d, 0, Math.max(0, this.results.length - this.rows));
this.sel = clamp(this.sel, this.top, this.top + this.rows - 1);
this.fill();
this.loop();
}
keepInView() {
if (this.sel < this.top) this.top = this.sel;
if (this.sel >= this.top + this.rows) this.top = this.sel - this.rows + 1;
}
highlight(i) {
if (i < 0 || i >= this.results.length) return;
this.sel = i;
this.keepInView();
this.fill();
this.loop();
}
// The highlighted card compresses, the result is chosen, and the drawer closes.
choose(now = this.clock()) {
this.due(now);
const item = this.results[this.sel];
if (!this.isOpen || !item) return;
this.pressAt = now;
this.pending = now + PRESS;
this.input.value = item.title;
this.query = item.title;
this.onSelect?.(item);
this.loop();
}
/* ── content: cards, header, empty state (only when results, scroll or highlight change) ── */
fill() {
const total = this.results.length;
const shown = this.results.slice(this.top, this.top + MAX_ROWS);
this.countEl.textContent = this.query.trim()
? `INDEX / ${pad(total, 3)} RESULT${total === 1 ? '' : 'S'}`
: `INDEX / ${pad(total, 3)} FILES`;
const last = Math.min(total, this.top + this.rows);
this.rangeEl.textContent = total > this.rows ? `${pad(this.top + 1, 2)}—${pad(last, 2)}` : '';
this.cards.forEach((card, i) => {
const item = shown[i];
const at = this.top + i;
card.hidden = !item;
if (!item) return;
card.querySelector('.id-num').textContent = pad(at + 1, 2);
card.querySelector('.id-title').textContent = item.title;
card.querySelector('.id-type').textContent = item.type || '';
card.classList.toggle('is-selected', at === this.sel);
card.setAttribute('aria-selected', String(at === this.sel));
});
// the dots show where the view sits in a longer list: top, middle, end
const span = total - this.rows;
const dot = span > 0 ? Math.round((this.top / span) * 2) : 0;
this.dots.forEach((d, i) => d.classList.toggle('is-on', total > 0 && i === dot));
this.emptyEl.hidden = total > 0;
this.emptyEl.textContent = total ? '' : `NO MATCH FOR “${this.query.trim().toUpperCase()}”`;
const selCard = this.cards[this.sel - this.top];
if (total && this.isOpen && selCard) this.input.setAttribute('aria-activedescendant', selCard.id);
else this.input.removeAttribute('aria-activedescendant');
this.live.textContent = total ? `${total} result${total === 1 ? '' : 's'}` : 'No results';
}
/* ── what things look like at a given moment ── */
rowsAt(now) {
if (this.pull) return this.pull.rows;
const r = this.resize;
return lerp(r.from, r.to, easeOut(unit(now, r.at, RESIZE)));
}
// drawer offset in units: closed shows only its bottom edge, as a thin seam under the field
closedY(now) {
return -(bodyH(this.rowsAt(now)) + LIP - 4);
}
drawerY(now) {
const m = this.move;
const closed = this.closedY(now), open = RECESS;
if (!m) return closed;
const t = now - m.at;
if (reducedMotion.matches) return m.open ? open : closed;
if (m.open) {
// release a few units, extend to just short of the stop, then lock in
const full = open - closed;
const p = t < OPEN.extend
? (4 / full) * easeOut(unit(t, OPEN.release, OPEN.extend - OPEN.release))
: t < OPEN.settle
? lerp(4 / full, 1 - 2 / full, easeOut(unit(t, OPEN.extend, OPEN.extended - OPEN.extend)))
: lerp(1 - 2 / full, 1, unit(t, OPEN.settle, OPEN.done - OPEN.settle));
// a drawer that was part-way closed starts from where it is
const fromP = clamp((m.from - closed) / full, 0, 1);
return closed + full * lerp(fromP, 1, fromP > 0.02 ? easeOut(unit(t, 0, OPEN.extended)) : p);
}
return lerp(m.from, closed, easeIn(unit(t, CLOSE.retract, CLOSE.done - CLOSE.retract)));
}
// per-card rise (units below its slot) and visibility
cardAt(i, now) {
const m = this.move;
if (!m) return { y: 0, on: false };
const t = now - m.at;
if (reducedMotion.matches) return { y: 0, on: m.open };
if (m.open) {
// the stagger tightens for a drawer pulled out further, so the whole reveal keeps its length
const start = OPEN.cards + i * Math.min(OPEN.stagger, 0.18 / Math.max(1, this.rows - 1));
if (t < start) return { y: 10, on: m.from > this.closedY(now) + 30 };
return { y: 10 * (1 - easeOut(unit(t, start, OPEN.rise))), on: true };
}
return { y: 6 * easeIn(unit(t, 0, CLOSE.cards)), on: t < CLOSE.cards };
}
render(now = this.clock()) {
this.due(now);
const y = this.drawerY(now);
this.drawer.style.transform = `translateY(calc(var(--u) * ${y.toFixed(2)}))`;
this.drawer.style.setProperty('--body', bodyH(this.rowsAt(now)).toFixed(2));
this.well.style.setProperty('--recess', clamp((y - this.closedY(now)) / 6, 0, 1).toFixed(2));
const inView = Math.ceil(this.rowsAt(now) - 0.01); // cards the drawer is pulled out far enough to show
this.cards.forEach((card, i) => {
const c = this.cardAt(i, now);
const pressed = this.top + i === this.sel && now - this.pressAt < PRESS;
card.style.visibility = c.on && i < inView ? '' : 'hidden';
card.style.transform = `translateY(calc(var(--u) * ${(c.y + (pressed ? 1 : 0)).toFixed(2)}))`;
card.classList.toggle('is-pressed', pressed);
});
// field: focus state, hint, caret
this.root.classList.toggle('is-active', this.active);
this.root.classList.toggle('is-open', this.isOpen);
this.root.classList.toggle('has-text', !!this.query);
this.root.classList.toggle('is-all', this.allSelected);
this.mirror.textContent = this.input.value;
const blinkOn = Math.floor((now - this.typedAt) / 0.53) % 2 === 0;
this.caret.style.left = `${this.mirror.offsetWidth}px`;
this.caret.style.visibility = this.active && blinkOn && !this.allSelected ? '' : 'hidden';
}
/* ── animation: runs while something moves or the caret blinks ── */
loop() {
if (this.running) return;
this.running = true;
const tick = () => {
const now = this.clock();
this.render(now);
const m = this.move;
const moving = m && now - m.at < (m.open ? OPEN.done : CLOSE.done) + 0.05;
const busy = this.active || moving || this.pull || this.pending !== null
|| now - this.resize.at < RESIZE + 0.05 || now - this.pressAt < PRESS + 0.05;
if (!busy) {
this.running = false;
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
}
IndexedDrawer.search = search;
IndexedDrawer.OPEN = OPEN;
IndexedDrawer.CLOSE = CLOSE;
window.IndexedDrawer = IndexedDrawer;
})(); <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Indexed Drawer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DotGothic16&family=IBM+Plex+Mono:wght@500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="indexed-drawer.css">
<style>
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
display: grid;
place-items: start center;
padding: 18vh 16px 16px;
/* muted warm stone grey */
background: #d3cfc1;
font-family: "IBM Plex Mono", ui-monospace, monospace;
color: #222a28;
}
.page {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 420px;
gap: 48px;
}
.title {
margin: 0;
font-size: 12px;
letter-spacing: 0.4em;
margin-right: -0.4em;
}
#search { width: 100%; }
</style>
</head>
<body>
<main class="page">
<p class="title">THE INDEXED DRAWER</p>
<div id="search"></div>
</main>
<script src="indexed-drawer.js"></script>
<script>
new IndexedDrawer(document.getElementById('search'), {
items: [
{ title: 'Music Library', type: 'FOLDER' },
{ title: 'Recent Tracks', type: 'FILE' },
{ title: 'Saved Albums', type: 'FOLDER' },
{ title: 'Mixtapes', type: 'FOLDER' },
{ title: 'Session Mix.wav', type: 'AUDIO' },
{ title: 'Remix Stems', type: 'FOLDER' },
{ title: 'Moodboard.png', type: 'IMAGE' },
{ title: 'Studio Notes.txt', type: 'FILE' },
{ title: 'Sample Pack', type: 'FOLDER' },
{ title: 'Record Sleeve.png', type: 'IMAGE' },
{ title: 'Lyrics Draft.txt', type: 'FILE' },
{ title: 'Live Sets', type: 'FOLDER' },
{ title: 'Demo Takes', type: 'FOLDER' },
{ title: 'Master Final.wav', type: 'AUDIO' },
{ title: 'Drum Loops', type: 'FOLDER' },
{ title: 'Vocal Chops.wav', type: 'AUDIO' },
{ title: 'Tour Photos', type: 'FOLDER' },
{ title: 'Press Kit.pdf', type: 'FILE' },
{ title: 'Album Artwork.png', type: 'IMAGE' },
{ title: 'Setlist 2026.txt', type: 'FILE' },
{ title: 'Synth Presets', type: 'FOLDER' },
{ title: 'Field Recordings', type: 'FOLDER' },
{ title: 'Mix Notes.txt', type: 'FILE' },
{ title: 'Release Plan.pdf', type: 'FILE' },
],
onSelect: (item) => console.log('Opened', item.title),
});
</script>
</body>
</html>