web design
COMPONENT F / PROJECT CARD · HOVER REVEAL + STACKED INTRO
GUIDE / PROJECT CARD

Overview

The signature right-rail card: a numbered index, a single-line title, a short description, mono tag chips, and a meta row that stays hidden until hover. The rule under the title is the only accent and appears one card at a time.

When to use

The 1/3-width stacked column beside a 2/3 info pane on the work page. Stack three to six; beyond that, move the rest to an archive index. In production, swap the load timeline for an IntersectionObserver trigger so cards reveal as the section enters view.

Spec

Tokens
--line dividers, --accent title rule, --grey body
Motion
staggered intro 0.15s; hover 300ms ease-out
A11y
title contrast 7.7:1, meta reveals on hover/focus
Layout
grid auto 1fr, collapses to 1col under 768px

Anti-patterns

No card images inside this variant, keep it typographic. Do not show the meta row at rest, it is the reward for attention. One accent rule per hovered card, never all at once.

<!-- Requires GSAP: https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/gsap.min.js -->

<style>
:root{
  --black:#050505;--surface:#0C0C0C;--white:#FFFFFF;--grey:#A3A3A3;--grey-dim:#6B6B6B;--accent:#22D3EE;
  --line:rgba(255,255,255,.16);--line-faint:rgba(255,255,255,.07);
  --font-display:'Space Grotesk',system-ui,sans-serif;--font-mono:'JetBrains Mono',ui-monospace,monospace;
  --dur-fast:150ms;--dur-base:300ms;--ease-out:cubic-bezier(.16,1,.3,1);
}
*{margin:0;padding:0;box-sizing:border-box;border-radius:0!important}
body{background:var(--black);color:var(--white);font-family:var(--font-display);min-height:100vh;padding:64px 48px}
.pc-stack{list-style:none;max-width:680px;margin:0 auto;visibility:hidden}
.pc{
  display:grid;grid-template-columns:auto 1fr;gap:32px;align-items:start;
  padding:32px 0;border-top:1px solid var(--line);position:relative;cursor:pointer;
}
.pc-stack .pc:last-child{border-bottom:1px solid var(--line)}
.pc-idx{font-family:var(--font-mono);font-size:clamp(1.5rem,3vw,2.25rem);font-weight:500;color:var(--grey-dim);line-height:1;min-width:2ch;transition:color var(--dur-base) var(--ease-out)}
.pc-body{min-width:0}
.pc-title{position:relative;display:inline-block;font-size:clamp(1.25rem,2.2vw,1.75rem);font-weight:500;letter-spacing:-.01em;line-height:1.2;transition:color var(--dur-base) var(--ease-out)}
.pc-title::after{content:"";position:absolute;left:0;right:0;bottom:-6px;height:2px;background:var(--accent);transform:scaleX(0);transform-origin:left center;transition:transform var(--dur-base) var(--ease-out)}
.pc-desc{color:var(--grey);font-size:.9375rem;line-height:1.6;margin-top:14px;max-width:48ch}
.pc-tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:18px}
.pc-tags span{font-family:var(--font-mono);font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--grey);border:1px solid var(--line);padding:4px 8px}
.pc-meta{display:flex;gap:24px;margin-top:18px;opacity:0;transform:translateY(6px);transition:opacity var(--dur-base) var(--ease-out),transform var(--dur-base) var(--ease-out)}
.pc-meta span{font-family:var(--font-mono);font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--grey-dim)}
.pc-meta span b{color:var(--grey);font-weight:400}
.pc:hover .pc-idx{color:var(--white)}
.pc:hover .pc-title::after{transform:scaleX(1)}
.pc:hover .pc-meta{opacity:1;transform:translateY(0)}
</style>

<ul class="pc-stack" id="stack">
  <li class="pc">
    <span class="pc-idx">01</span>
    <div class="pc-body">
      <h3 class="pc-title">Order mirroring engine for NinjaTrader 8</h3>
      <p class="pc-desc">A low-latency trade copier that routes fills across broker accounts with sub-tick skew protection. Shipped as an installable add-on.</p>
      <div class="pc-tags"><span>C#</span><span>.NET</span><span>NT8 API</span></div>
      <div class="pc-meta"><span><b>2025</b></span><span><b>Lead engineer</b></span></div>
    </div>
  </li>
  <li class="pc">
    <span class="pc-idx">02</span>
    <div class="pc-body">
      <h3 class="pc-title">Design system rebuild for a fintech dashboard</h3>
      <p class="pc-desc">Token-driven component library replacing a fragmented UI. Cut bundle size and unified theming across twelve surfaces.</p>
      <div class="pc-tags"><span>React</span><span>TypeScript</span><span>Tokens</span></div>
      <div class="pc-meta"><span><b>2024</b></span><span><b>Frontend lead</b></span></div>
    </div>
  </li>
  <li class="pc">
    <span class="pc-idx">03</span>
    <div class="pc-body">
      <h3 class="pc-title">Scroll-driven portfolio framework</h3>
      <p class="pc-desc">A small set of reusable motion primitives wired to IntersectionObserver and GSAP, with a hard reduced-motion contract.</p>
      <div class="pc-tags"><span>GSAP</span><span>CSS</span><span>a11y</span></div>
      <div class="pc-meta"><span><b>2024</b></span><span><b>Solo</b></span></div>
    </div>
  </li>
</ul>

<script>
const stack = document.getElementById("stack");
const cards = gsap.utils.toArray(stack.querySelectorAll(".pc"));

function intro(){
  const tl = gsap.timeline({delay:0.3, defaults:{ease:"power3.out"}});
  tl.set(stack, {autoAlpha:1});
  cards.forEach((c, i) => {
    tl.from(c.querySelector(".pc-idx"),   {autoAlpha:0, y:12, duration:0.5}, i * 0.15)
      .from(c.querySelector(".pc-title"), {autoAlpha:0, y:12, duration:0.5}, "<0.08")
      .from(c.querySelectorAll(".pc-tags span, .pc-desc"), {autoAlpha:0, duration:0.4, stagger:0.04}, "<0.1");
  });
  return tl;
}

let tl = null;
const mm = gsap.matchMedia();
mm.add({
  full:   "(prefers-reduced-motion: no-preference)",
  reduce: "(prefers-reduced-motion: reduce)"
}, (ctx) => {
  if (ctx.conditions.full) tl = intro();
  else gsap.set(stack, {autoAlpha:1});
});
</script>