Design Lounge
Install

Nº 139 of 520

Portfolio index with hover preview

An art director's project index: a 12-column table of work where hovering a row floats an art-directed plate that trails the cursor with lag and tilt.

Preview

Open full size
1280 × 800 · 15.8 KB · 186 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Portfolio index with hover preview" (piece portfolio-index-hover-preview) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

196 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

The home page of a fictional Lisbon art director, Ines Varga. The whole site is an index: a giant serif “Index” headline, a one-sentence pitch, discipline filters, and a hairline table of twelve projects (number, project, client, discipline, year). Hovering a row dims every other row to 32% and floats a 300×380 “plate” (a CSS-gradient composition standing in for the project’s key image) that chases the cursor with a lerp and tilts with its velocity. The detail worth copying is the lag: the plate eases toward the pointer at 11% of the remaining distance per frame and rotates up to ±6° in the direction of travel, so it feels like a print being slid across a table.

Reference behaviour

  1. Initial state: row 03 “Lumen No. 4” is active (hero state), its plate visible at ~62% of the row width, vertically centred on the row (clamped to at least 266px from the top). Other rows are at 32% opacity.
  2. Move the pointer onto any row: that row becomes active (full opacity, project name slides 14px right over 420ms, number turns vermilion). All other rows drop to opacity .32 over 160ms.
  3. The plate targets pointerX + 165px, pointerY, clamped so it never leaves the viewport (16px margin; top clamp 76px below the sticky header). Each animation frame moves x += (tx − x) × 0.11, same for y. Rotation is clamp((tx − x) × 0.04, −6°, 6°). The rAF loop stops itself when the remaining distance is under 0.4px.
  4. When the active row changes, the new plate reveals over the old one with a bottom-up clip-path wipe (inset(100% 0 0 0) → inset(0), 420ms expo-out). Plates are stacked; the active one has z-index:2.
  5. Leaving the list: plate fades to 0 over 240ms and scales to .92; all rows return to full opacity.
  6. Keyboard focus on a row (Tab) activates it the same way and parks the plate at 68% of the row width, centred on the row.
  7. Filter pills (All 12 / Identity 3 / Editorial 4 / Packaging 3 / Motion 2): click sets aria-pressed and hides non-matching rows (display:none).
  8. Header clock shows Lisbon time (UTC+1 in October) as LIS HH:MM, refreshed every 30s.
  9. The page scrolls: after row 12 comes a footer with an 88px serif sign-off “Have a slow thing to make? Write.” (the last word underlined 3px vermilion) and contact meta. Scrolling while not hovering hides the plate.

Structure

1280 × 800  (12-col grid, 40px side padding, 20px gutters)
┌────────────────────────────────────────────────────────────────────────┐
│ Ines Varga │ • Art direction & identity · Lisbon │ LIS 21:12 │ Index Info Journal Contact │ 60 sticky
├────────────────────────────────────────────────────────────────────────┤
│                                        │ Identities, books and packaging  │
│  Index⁽¹²⁾   (172px Gloock, cols 1–7)  │ for people… (22px serif, 30ch)   │
│                                        │ [All¹²][Identity³][Editorial⁴]…  │
├─NO.──PROJECT──────────────────────CLIENT──────────DISCIPLINE─────YEAR──┤ 34, 1px ink rule
│ 01   Salt & Ledger                Ferro Ceramics   Identity      2026  │ 50
│ 03     Lumen No. 4  ┌──────────┐  Oficina Lumen    Packaging     2025  │ ← active
│ 04   Rio Abaixo     │  plate   │  …                                    │
│ …                   │ 300×380  │                                       │
│                     └──────────┘                                       │
├────────────────────────────────────────────────────────────────────────┤
│ Have a slow thing to make? Write.  (88px)         │ email · address    │ footer
└────────────────────────────────────────────────────────────────────────┘
columns: No. 1/2 · Project 2/7 · Client 7/10 · Discipline 10/12 · Year 12/13 (right-aligned)
  • <header> sticky, 12-column grid: .name (link), .role with a 7px green availability dot, #clock, <nav aria-label="Primary"> with aria-current="page" on Index.
  • <main> → <section class="hero"> with <h1>Index<sup>(12)</sup></h1> and .intro (pitch <p> + .filters role="group").
  • .thead (aria-hidden column labels) then <ol id="list" aria-label="Projects">; each <li> holds one <a class="row" data-a="N"> with five spans.
  • <footer> sign-off and meta.
  • .pv fixed-position preview, aria-hidden="true", pointer-events:none, containing .pv-in (shadow + scale) and twelve stacked .art plates.

Tokens

:root {
  /* colour: bone paper, near-black ink, one vermilion accent */
  --bg: #ece9e2;        /* page */
  --ink: #141414;       /* text, active row, header rule */
  --ink-2: #5b574f;     /* client, discipline, meta (6.2:1 on bg) */
  --ink-3: #8a857b;     /* column labels only */
  --line: #cdc8bd;      /* row hairlines, pill borders */
  --accent: #d9411e;    /* active number, count sup, footer underline, focus */
  --ok: #3d9a5b;        /* availability dot */

  /* type */
  --serif: "Gloock", Georgia, serif;
  --mono: "DM Mono", ui-monospace, monospace;

  /* layout */
  --pad: 40px;
  --row-h: 50px;
  --pv-w: 300px;
  --pv-h: 380px;
  --pv-shadow: 0 30px 60px -20px rgba(20, 20, 20, .35);

  /* motion */
  --t-micro: 160ms;
  --t-swap: 420ms;
  --t-fade: 240ms;
  --lerp: .11;
  --tilt-max: 6deg;
  --ease: cubic-bezier(.2, .7, .2, 1);
  --ease-out: cubic-bezier(.16, 1, .3, 1);
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
Display “Index”Gloock172px400.82−0.045emTitle
Count supDM Mono15px40010(12)
PitchGloock22px4001.28−0.005emsentence
Project nameGloock26px4001−0.015emTitle
Body / cellsDM Mono13px4001.50sentence
Column labelsDM Mono11px4001+0.1emUPPERCASE
BrandGloock20px4001−0.01emTitle
Plate captionDM Mono10px4001.5+0.08emUPPERCASE
Plate glyphGloock140px (92px for long words)400.8−0.05emas set
Footer sign-offGloock88px400.9−0.035emsentence

Motion

ElementTriggerPropertyFrom → ToDurationEasing
.pv positionpointermovetranslate3dlerp 0.11 / frameuntil < .4px—
.pv tiltpointermoverotate(tx−x)·0.04, clamp ±6°per frame—
.pv visibilityenter / leave listopacity0 ↔ 1240ms--ease
.pv-inenter / leave listscale.92 ↔ 1420ms--ease-out
.art swapactive row changeclip-pathinset(100% 0 0 0) → inset(0)420ms--ease-out
other rowshover any rowopacity1 → .32160ms--ease
active namehover rowtranslateX0 → 14px420ms--ease-out
filter pillhover / pressbackground, colour—160ms--ease

Reduced motion: lerp factor becomes 1 (plate snaps to the target, no tilt), every transition is 1ms. The plate still appears and swaps.

States

  • Row hover / active: .on class; opacity 1, number --accent, name +14px. List gets .hovering so siblings dim.
  • Row focus-visible: 2px --accent outline, offset −2px (inside the row).
  • Filter pill: rest 1px --line border, --ink-2 text; hover border and text --ink; pressed --ink fill with --bg text.
  • Nav link: current and hover show a 1px underline 2px above the baseline.
  • Filtered-out rows: display:none; the table closes up, no animation.
  • Plate hidden: opacity 0, scale .92; it keeps its last position so the next reveal starts from there.

Accessibility

  • Rows are real links inside an <ol>; the column header row is aria-hidden because each row reads naturally (“03 Lumen No. 4 Oficina Lumen Packaging 2025”).
  • The preview is decorative: aria-hidden="true", pointer-events:none. Never put essential info only in the plate.
  • Filters are <button aria-pressed> inside role="group" aria-label="Filter by discipline".
  • Tab order: brand, nav, filters, rows, footer link. Focus activates the preview so keyboard users get the same moment.
  • --ink-2 on --bg is 6.2:1; --ink-3 (3.4:1) is used only for 11px column labels that are duplicated by row content.
  • Dimmed rows at .32 opacity are a hover-only state; at rest everything meets 4.5:1.

Responsive rules

  • ≥ 1280: as specified.
  • 1024–1279: display drops to 140px; pitch 20px; preview 260×330.
  • 768–1023: hide the Discipline column (Client spans 7/12); hero stacks (headline full width, intro below); preview 220×280.
  • < 640 or (hover: none): no floating preview. Each row expands on tap to show its plate inline at 100% width × 4:5 above the row text; the name drops to 22px and client/year sit on a second line.

Acceptance checklist

  • Display “Index” is 172px Gloock with line-height .82 and a vermilion mono “(12)” superscript.
  • Rows are exactly 50px tall with 1px #cdc8bd hairlines; the header row has a 1px ink rule.
  • Hovering a row dims all others to opacity .32 within 160ms.
  • The plate trails the pointer with visible lag (lerp 0.11) and tilts no more than 6°.
  • The rAF loop stops when the plate settles (no loop running while the pointer is still).
  • The plate never leaves the viewport and never sits under the 60px sticky header.
  • Switching rows wipes the new plate up from the bottom over 420ms.
  • On first paint, row 03 is active and its plate is visible.
  • Tabbing to a row shows its plate; leaving the list via Tab hides it.
  • Filter pills toggle aria-pressed and hide non-matching rows.
  • Reduced motion: no lag, no tilt, still fully functional.
  • No raster images: every plate is CSS gradients plus type.

Implementation notes

Lerp loop that sleeps. Only run rAF while there is distance to cover; restart it on pointermove.

let tx = 0, ty = 0, x = 0, y = 0, raf = 0;
function tick() {
  x += (tx - x) * 0.11; y += (ty - y) * 0.11;
  const tilt = Math.max(-6, Math.min(6, (tx - x) * 0.04));
  pv.style.transform = `translate3d(${x - 150}px, ${y - 190}px, 0) rotate(${tilt}deg)`;
  raf = Math.abs(tx - x) + Math.abs(ty - y) > 0.4 ? requestAnimationFrame(tick) : 0;
}
list.addEventListener('pointermove', (e) => {
  tx = Math.min(innerWidth - 166, e.clientX + 165);
  ty = Math.min(innerHeight - 206, Math.max(266, e.clientY));
  if (!raf) raf = requestAnimationFrame(tick);
});

Stacked plates with a clip wipe. Keep all plates mounted so the swap is a pure CSS transition; the previous plate stays visible underneath until the new one covers it.

.art { position: absolute; inset: 0; clip-path: inset(100% 0 0 0);
       transition: clip-path 420ms cubic-bezier(.16, 1, .3, 1); }
.art.on { clip-path: inset(0); z-index: 2; }

Art-directed plates from gradients. Each plate is one composition, not a random gradient. Example (sun over horizon line):

.a1 { background:
  radial-gradient(circle at 50% 66%, #c2522d 0 31%, transparent 31.5%),
  linear-gradient(#e7dfcf 0 66%, #1a1a1a 66% 66.6%, #e7dfcf 66.6%); }

Common mistakes: positioning the plate with left/top (layout thrash; use transform), listening to mousemove on window so the plate follows into the footer, and forgetting pointer-events:none on the plate so it steals the hover from the row beneath it.

Details

Palette
Type
Gloock · DM Mono
Motion
Rich motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

Susan Acharya

Draw one scene and let it move a little. The words stay calm on top.

All sources