← ThreadbareThe guide · how it was built
Design showcase · build notes

A grid that re-hangs itself.

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.

The idea

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.

The stack

Signature technique #1 — the FLIP-ish rack re-flow

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).

Signature technique #2 — story-tag card flips

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.

Details that matter

Ship it on GitHub Pages

# 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 /docs

trailingSlash 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.

Designed & built by Parablethreadbare · MIT