Threadbare is a circular-fashion resale marketplace concept, built as a Next.js static export. There are no photos anywhere — every “product shot” is a flat SVG garment drawn in code — and the centerpiece is a dense, filterable rack whose re-flow is animated with nothing but CSS transitions and arithmetic.
Resale sites usually whisper. Threadbare shouts, because its pitch is pride, not compromise: loved once, wanted twice. The identity is a stack of color-blocked slabs — cobalt, paper, lemon, ink, bubblegum — with thick 2.5–3px ink borders and hard offset shadows (no blur, ever). The type is Schibsted Grotesk at weight 900 for the shouting, Inter for the reading, and Space Mono for anything that behaves like a tag: grades, sizes, prices-adjacent chips, story stamps.
The interaction patterns came from shipped resale products on Mobbin: Depop’s dense two-column grid with hearts-with-counts and minimal brand/size/price metadata, Grailed’s fixed four-grade condition scale and live result counts, Vinted’s seller-trust chip and offer culture, and Vestiaire Collective’s plain-language grade definitions. Threadbare’s own invention is the story tag — flip any card and the garment shows its passport: owners, cities, kilograms of CO₂e saved.
output: ‘export’. The rack is genuinely stateful — one filter model drives twelve cards, a live count, an empty state and a sort order — so React earns its keep. The export is plain HTML/JS that GitHub Pages can serve.basePath: ‘/threadbare-resale’ + assetPrefix + trailingSlash: true, copy out/ to docs/, add .nojekyll, point Pages at main /docs.next/font — self-hosted at build time, zero runtime font requests.Animating a filtered grid usually means a layout-animation library. Threadbare uses the cheaper trick: every card is always in the DOM in a stable order, absolutely positioned, and the only thing a filter changes is each card’s transform. Slot positions are arithmetic, so a plain CSS transition animates the entire re-flow:
// slot i in a grid of `cols` columns is just arithmetic
const x = (i % cols) * (cardW + gap);
const y = Math.floor(i / cols) * (cardH + gap);
// visible cards animate to their slot; filtered-out cards
// keep their LAST slot and scale away in place
style = {
transform: `translate(${x}px, ${y}px) scale(${shown ? 1 : 0.82})`,
opacity: shown ? 1 : 0,
visibility: shown ? 'visible' : 'hidden',
// delays match transition-property order: transform, opacity, visibility
transitionDelay: shown
? `${i * 24}ms, ${i * 24}ms, 0ms` // entrance stagger
: '0ms, 0ms, 380ms', // exit keeps visibility until done
};Two details carry the craft. First, transitions are only enabled one frame after the first absolute-position paint, so cards don’t fly in from the container origin on load. Second, visibility rides the transition with a per-property delay: instant on entry, delayed 380ms on exit — so departing cards stay visible while they scale away, then drop out of the tab order and the accessibility tree. Before JavaScript runs (and for no-JS visitors) the same markup renders as a static CSS grid, so the rack is never blank. The overshoot in every movement is one named curve, --ease-pop: cubic-bezier(.3, 1.45, .4, 1).
Flipping is a button, never a hover. The toggle lives outside the rotating plane, so it never disappears mid-flip and stays keyboard-reachable in both states:
<div className={flipped ? 'card-inner flipped' : 'card-inner'}>
<div className="face face-front" aria-hidden={flipped}>…art, badge, meta…</div>
<div className="face face-back" aria-hidden={!flipped}>…owners · cities · CO₂e…</div>
<button aria-pressed={flipped} onClick={() => setFlipped(v => !v)}>
{flipped ? '× close' : '★ story'}
</button> {/* sibling of the faces — not a child of the rotating plane */}
</div>.card-inner { transform-style: preserve-3d; transition: transform .65s var(--ease-pop); }
.card-inner.flipped { transform: rotateY(180deg); }
.face { backface-visibility: hidden; }
.face-back { position: absolute; inset: 0; transform: rotateY(180deg); }
/* visibility swaps at mid-flip → hidden face leaves the tab order */
.face-front { transition: visibility 0s .2s; }
.flipped .face-front { visibility: hidden; }Under prefers-reduced-motion the rotation transition is removed, so the same button swaps the faces instantly — the story is still fully reachable, nothing spins.
aria-pressed, the result count is a role="status" live region, and a zero-result state offers to loosen the filters.:focus-visible gets a thick currentColor outline, so the ring is ink on paper sections and paper on ink — always visible, always on-palette.# next.config.mjs
output: 'export'
basePath: '/threadbare-resale'
assetPrefix: '/threadbare-resale/'
trailingSlash: true
npm run build # emits ./out + touches out/.nojekyll
rm -rf docs && cp -r out docs
git add -A && git commit && git push
# GitHub → Settings → Pages → Deploy from branch → main /docstrailingSlash makes every route a real directory (/guide/index.html), which is exactly what a static file host wants; .nojekyll stops Pages from swallowing the _next/ asset directory.