Subchapter 9.1
PICKER.mdMarkdown7 KBView on GitHub
The picker’s appearance is not a design decision — it is this spec. Copy the markup, CSS, and wiring below verbatim; the only values that change per run are the variant names and count. It stays identical across every project so it always reads as harness chrome, never as part of the design being judged. Do not restyle it with the project’s tokens, fonts, or colors.
It is a floating dark pill, bottom-center. Dark glass works on top of any page — light or dark — which is why it is not theme-aware.
The sliding highlight span first, one button per variant, a hairline divider, then the replay button (only when at least one variant has motion to re-trigger):
<nav class="proto-picker" aria-label="Prototype variants">
<span class="proto-picker-highlight" aria-hidden="true"></span>
<button class="proto-picker-item" data-active aria-current="true">Quiet</button>
<button class="proto-picker-item">Editorial</button>
<button class="proto-picker-item">Playful</button>
<span class="proto-picker-divider" aria-hidden="true"></span>
<button class="proto-picker-item proto-picker-replay" aria-label="Replay animation (R)">↻</button>
</nav>In a framework, keep the class names and structure; only the rendering syntax changes.
.proto-picker {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 2147483647;
display: flex;
align-items: center;
gap: 2px;
padding: 4px;
border-radius: 999px;
background: rgba(10, 10, 10, 0.82);
-webkit-backdrop-filter: blur(12px) saturate(1.4);
backdrop-filter: blur(12px) saturate(1.4);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
0 8px 24px rgba(0, 0, 0, 0.24),
0 2px 6px rgba(0, 0, 0, 0.12);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 13px;
line-height: 1;
-webkit-font-smoothing: antialiased;
user-select: none;
-webkit-user-select: none;
}
.proto-picker-highlight {
position: absolute;
top: 4px;
left: 0;
height: 28px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
will-change: transform;
}
/* The slide is enabled only after first paint (data-ready), so load doesn't animate. */
.proto-picker[data-ready] .proto-picker-highlight {
transition:
transform 250ms cubic-bezier(0.23, 1, 0.32, 1),
width 250ms cubic-bezier(0.23, 1, 0.32, 1);
}
@media (prefers-reduced-motion: reduce) {
.proto-picker[data-ready] .proto-picker-highlight { transition: none; }
}
.proto-picker-item {
position: relative; /* sits above the highlight */
display: flex;
align-items: center;
height: 28px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.55);
font: inherit;
cursor: pointer;
transition: color 150ms ease-out;
}
.proto-picker-item:hover {
color: rgba(255, 255, 255, 0.85);
}
.proto-picker-item:active {
transform: scale(0.97);
}
.proto-picker-item:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.4);
outline-offset: 2px;
}
.proto-picker-item[data-active] {
color: #fff;
}
.proto-picker-divider {
width: 1px;
height: 16px;
margin: 0 4px;
background: rgba(255, 255, 255, 0.12);
}
.proto-picker-replay {
padding: 0 10px;
font-size: 14px;
}
.proto-picker[data-position="top"] {
bottom: auto;
top: 24px;
}width transition is a deliberate exception to the transform/opacity rule: the element is 28px tall, absolutely positioned, and has no layout dependents, so the paint cost is negligible.data-position="top" so the picker never covers the work. Nothing else about it may move or change.The contract is fixed regardless of how the harness renders:
1–N and ←/→ switch variants; R replays. Ignore key events when focus is in an input, textarea, select, or contenteditable, or when a modifier is held.data-active and aria-current="true" at all times, and the highlight slides to it.?v=2), falling back to variant 1. The highlight takes its initial position without animating (data-ready is added after first paint).Verbatim for the standalone-HTML branch; in a framework, keep the same behavior but express it idiomatically (state instead of innerHTML, a keyed re-mount instead of requestAnimationFrame, refs + a layout effect for the highlight measurement).
// `variants` is an array of render functions, one per variant, in picker order.
const stage = document.getElementById('stage');
const picker = document.querySelector('.proto-picker');
const highlight = picker.querySelector('.proto-picker-highlight');
const items = [...picker.querySelectorAll('.proto-picker-item:not(.proto-picker-replay)')];
const replay = picker.querySelector('.proto-picker-replay');
let current = 0;
function moveHighlight() {
const el = items[current];
highlight.style.width = el.offsetWidth + 'px';
highlight.style.transform = `translateX(${el.offsetLeft}px)`;
}
function mount(i) {
stage.innerHTML = '';
// Clear first, render next frame, so entrance animations re-run.
requestAnimationFrame(() => { stage.innerHTML = variants[i](); });
}
function setActive(i) {
if (i < 0 || i >= variants.length) return;
current = i;
items.forEach((el, j) => {
el.toggleAttribute('data-active', j === i);
if (j === i) el.setAttribute('aria-current', 'true');
else el.removeAttribute('aria-current');
});
moveHighlight();
const url = new URL(location);
url.searchParams.set('v', i + 1);
history.replaceState(null, '', url);
mount(i);
}
items.forEach((el, i) => el.addEventListener('click', () => setActive(i)));
replay?.addEventListener('click', () => mount(current));
window.addEventListener('resize', moveHighlight);
document.addEventListener('keydown', (e) => {
if (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const num = parseInt(e.key, 10);
if (num >= 1 && num <= variants.length) setActive(num - 1);
else if (e.key === 'ArrowRight') setActive((current + 1) % variants.length);
else if (e.key === 'ArrowLeft') setActive((current - 1 + variants.length) % variants.length);
else if (e.key === 'r' || e.key === 'R') mount(current);
});
setActive((parseInt(new URLSearchParams(location.search).get('v'), 10) || 1) - 1);
// Enable the slide only after first paint, so load doesn't animate.
requestAnimationFrame(() => requestAnimationFrame(() => picker.setAttribute('data-ready', '')));