Nº 081 of 520
Hover image trail
An editorial index of 6 project rows; dragging the cursor across a row spawns 180×124px gradient thumbnails every 48px that pop in, drift up and fade, never more than 6 alive.
Preview
Use it
Free to use · credit appreciated1 Once, in your project
npx skills add SusanAcharya/Design-Lounge 2 Then ask
Using the Design Lounge skill, build "Hover image trail" (piece hover-image-trail) 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.
<!-- Design Lounge Nº 081 · "Hover image trail" · www.designlounge.live -->
# Hover image trail
> **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
A studio portfolio's "Selected work" page on off-white paper: six full-width project rows set in 44px Instrument Serif with a number, a category, a year and an arrow. Moving the pointer across a row leaves a trail of small "images" (180×124px cards, each a CSS gradient standing in for a photo, with a tiny caption) that pop in under the cursor, drift 24px upward and fade out over 720ms. A new card is spawned every 48px of pointer travel and at most six exist at once, so the effect stays light. Keyboard focus and reduced-motion users get one static thumbnail pinned to the row's right edge instead. The detail worth copying is the spawn rule: distance-based, not time-based, so fast sweeps and slow drags both look right.
## Reference behaviour
1. Initial state: header (brand "Tessel *Studio*", 4 nav links), `<h1>` "Selected work *2021–2026*", six rows separated by 1px hairlines filling the remaining height, a 48px footer line. No trail cards.
2. Pointer enters a row: the row's text and arrow turn `--accent` (#2f4bd8) over 160ms; the arrow slides 4px right.
3. Pointer moves within a row: each time the pointer has travelled ≥ 48px (Euclidean) since the last spawn, a `.img` card is appended to a fixed, `pointer-events: none` layer at the pointer position (centred with `translate(-50%,-50%)`). It uses the row's gradient (`--g`) and caption (`data-cap`, e.g. "Fjord Bank · 01"), and a small alternating rotation of −3°, 0°, +3°.
4. The card animates in: opacity 0 → 1, scale .6 → 1 over 260ms expo-out; then out: opacity 1 → 0, moves 24px up, scale 1 → .94 over 720ms, starting at 260ms. On `animationend` of the "gone" animation it removes itself.
5. If a seventh card would exist, the oldest is removed immediately.
6. Pointer leaves the list: the distance anchor resets so the next entry spawns at once; existing cards finish their fade.
7. Tab to a row: 2px `--accent` inset outline, text turns `--accent`, and a static 140×96px `.thumb` with the same gradient fades in at `right: 56px`, vertically centred.
8. With `prefers-reduced-motion: reduce`, `<body>` gets `.static`: no pointer listeners, `.img` is `display: none`, and hovering a row shows the same static `.thumb` as focus.
9. Clicking a row is prevented in the demo (it is a link to `#`); in production it navigates to the project.
## Structure
```
1280 × 800
┌─────────────────────────────────────────────────────────────────────────┐
│ header 76 Tessel Studio Work Studio Journal Contact │
│ │
│ h1 40px Selected work 2021–2026 │
│ ──────────────────────────────────────────────────────────────────────── │
│ 01 Fjord Bank rebrand Identity, signage, motion 2026 ↗ │ ← each row ≈ 96px
│ ──────────────────────────────────────────────────────────────────────── │
│ 02 Halden ferry wayfinding Environmental, pictograms 2025 ↗ │
│ ──────────────────────────────────────────────────────────────────────── │
│ 03 Marrow editions [img][img] Book series, 14 volumes 2025 ↗ │ ← trail cards float over
│ ──────────────────────────────────────────────────────────────────────── │
│ 04 Orbital annual report Print, 96 pages, data 2024 ↗ │
│ 05 Loam packaging Range of 18 SKUs 2023 ↗ │
│ 06 Nord Post fleet livery Vehicles, uniforms 2021 ↗ │
… 172 more lines. Copy or download for the full brief. Loading the source…Brief
212 linesBuild 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
A studio portfolio’s “Selected work” page on off-white paper: six full-width project rows set in 44px Instrument Serif with a number, a category, a year and an arrow. Moving the pointer across a row leaves a trail of small “images” (180×124px cards, each a CSS gradient standing in for a photo, with a tiny caption) that pop in under the cursor, drift 24px upward and fade out over 720ms. A new card is spawned every 48px of pointer travel and at most six exist at once, so the effect stays light. Keyboard focus and reduced-motion users get one static thumbnail pinned to the row’s right edge instead. The detail worth copying is the spawn rule: distance-based, not time-based, so fast sweeps and slow drags both look right.
Reference behaviour
- Initial state: header (brand “Tessel Studio”, 4 nav links),
<h1>“Selected work 2021–2026”, six rows separated by 1px hairlines filling the remaining height, a 48px footer line. No trail cards. - Pointer enters a row: the row’s text and arrow turn
--accent(#2f4bd8) over 160ms; the arrow slides 4px right. - Pointer moves within a row: each time the pointer has travelled ≥ 48px (Euclidean) since the last spawn, a
.imgcard is appended to a fixed,pointer-events: nonelayer at the pointer position (centred withtranslate(-50%,-50%)). It uses the row’s gradient (--g) and caption (data-cap, e.g. “Fjord Bank · 01”), and a small alternating rotation of −3°, 0°, +3°. - The card animates in: opacity 0 → 1, scale .6 → 1 over 260ms expo-out; then out: opacity 1 → 0, moves 24px up, scale 1 → .94 over 720ms, starting at 260ms. On
animationendof the “gone” animation it removes itself. - If a seventh card would exist, the oldest is removed immediately.
- Pointer leaves the list: the distance anchor resets so the next entry spawns at once; existing cards finish their fade.
- Tab to a row: 2px
--accentinset outline, text turns--accent, and a static 140×96px.thumbwith the same gradient fades in atright: 56px, vertically centred. - With
prefers-reduced-motion: reduce,<body>gets.static: no pointer listeners,.imgisdisplay: none, and hovering a row shows the same static.thumbas focus. - Clicking a row is prevented in the demo (it is a link to
#); in production it navigates to the project.
Structure
1280 × 800
┌─────────────────────────────────────────────────────────────────────────┐
│ header 76 Tessel Studio Work Studio Journal Contact │
│ │
│ h1 40px Selected work 2021–2026 │
│ ──────────────────────────────────────────────────────────────────────── │
│ 01 Fjord Bank rebrand Identity, signage, motion 2026 ↗ │ ← each row ≈ 96px
│ ──────────────────────────────────────────────────────────────────────── │
│ 02 Halden ferry wayfinding Environmental, pictograms 2025 ↗ │
│ ──────────────────────────────────────────────────────────────────────── │
│ 03 Marrow editions [img][img] Book series, 14 volumes 2025 ↗ │ ← trail cards float over
│ ──────────────────────────────────────────────────────────────────────── │
│ 04 Orbital annual report Print, 96 pages, data 2024 ↗ │
│ 05 Loam packaging Range of 18 SKUs 2023 ↗ │
│ 06 Nord Post fleet livery Vehicles, uniforms 2021 ↗ │
│ foot 48 Move across a row to preview it · 6 of 41 Tessel Studio, Gothenburg │
└─────────────────────────────────────────────────────────────────────────┘
row grid: 48px | 1fr | 220px | 120px | 40px, gap 24px, 64px page gutters
.wrap— flex column,padding: 0 64px, full height,overflow: hiddenon body.<header>—.brand(Instrument Serif 22px, italic word in--accent) +<nav aria-label="Primary">.<h1>— 40px serif; the date range in<em>(italic,--ink-2).<ul class="list">—border-tophairline,flex: 1; each<li>holds<a class="row" style="--g: var(--gN)" data-cap="…">with spans.n(number),.t(title, italic tail in<i>),.m(meta),.y(year), an inline 22px SVG arrow.a, and an empty.thumbspan (aria-hidden)..foot— 12px helper line.<div class="trail" aria-hidden="true">—position: fixed; inset: 0; pointer-events: none; z-index: 5; JS appends.imgdivs here.
Tokens
:root {
/* colour — off-white paper, near-black ink, one cobalt accent */
--bg: #f6f4ef; /* page */
--ink: #17161a; /* titles */
--ink-2: #6f6c66; /* meta, nav */
--ink-3: #a5a199; /* numbers, years, footer, arrow */
--line: #dedad1; /* hairlines */
--accent: #2f4bd8; /* hover/focus colour, brand italic */
/* type */
--serif: "Instrument Serif", Georgia, serif;
--sans: "Hanken Grotesk", system-ui, sans-serif;
/* trail cards */
--card-w: 180px;
--card-h: 124px;
--r: 4px;
--shadow: 0 12px 32px rgba(23, 22, 26, .18);
--step: 48px; /* pointer travel between spawns (JS constant) */
--max-alive: 6; /* JS constant */
/* stand-in "photos": one gradient per project */
--g1: linear-gradient(135deg, #d9c7b2, #8f6b4d 60%, #3d2a1e);
--g2: linear-gradient(160deg, #c9d6e2, #5b7a94 55%, #1f2f3f);
--g3: linear-gradient(120deg, #e6dcc3, #b7a26a 50%, #5a4a22);
--g4: linear-gradient(150deg, #e2c9cf, #a86b7c 55%, #3f1f2b);
--g5: linear-gradient(140deg, #cfd9cd, #6e8a6a 55%, #24331f);
--g6: linear-gradient(130deg, #d6d6d8, #7d7f8a 55%, #26272e);
/* layout */
--gutter: 64px;
--header-h: 76px;
--foot-h: 48px;
/* motion */
--t-micro: 160ms; /* colour, arrow nudge, thumb */
--t-in: 260ms; /* card pop */
--t-out: 720ms; /* card fade, starts after --t-in */
--ease: cubic-bezier(.2, .7, .2, 1);
--ease-out: cubic-bezier(.16, 1, .3, 1);
}
Typography
| Role | Family | Size | Weight | Line-height | Tracking | Case / style |
|---|---|---|---|---|---|---|
| Row title | Instrument Serif | 44px | 400 | 1 | −0.02em | sentence, tail word italic |
| Page title | Instrument Serif | 40px | 400 | 1.05 | −0.015em | sentence, range italic --ink-2 |
| Brand | Instrument Serif | 22px | 400 | 1 | −0.01em | “Studio” italic in --accent |
| Row meta | Hanken Grotesk | 14px | 400 | 1.5 | 0 | sentence, --ink-2 |
| Row number / year | Hanken Grotesk | 13px / 14px | 400 | 1 | 0 | tabular numerals, --ink-3 |
| Nav | Hanken Grotesk | 14px | 500 | 1.5 | 0 | sentence |
| Card caption | Hanken Grotesk | 10px | 500 | 1 | +0.08em | UPPERCASE, rgba(255,255,255,.85) |
| Footer | Hanken Grotesk | 12px | 400 | 1.5 | 0 | sentence, --ink-3 |
Motion
| Element | Trigger | Property | From → To | Duration | Easing | Delay |
|---|---|---|---|---|---|---|
.img (pop) | spawn | opacity, transform | 0, scale(.6) → 1, scale(1) | 260ms | --ease-out | 0 |
.img (gone) | after pop | opacity, transform | 1, y 0 → 0, y −24px, scale(.94) | 720ms | --ease | 260ms |
.row | hover / focus | color | --ink → --accent | 160ms | --ease | 0 |
.a arrow | hover / focus | transform, color | 0 → translateX(4px); --ink-3 → --accent | 160ms | --ease | 0 |
.thumb | focus-visible (or hover in .static) | opacity, transform | 0, scale(.9) → 1, scale(1) | 160ms | --ease | 0 |
Card rotation: --rot cycles −3°, 0°, +3° per spawn and is baked into both keyframes so it does not reset mid-animation. Total card life: 980ms. Spawn is distance-based (48px), so no timers exist.
Reduced motion: .img { animation: none; display: none }; JS returns early after adding .static; the arrow has no transition; the static .thumb still fades over 160ms (opacity only).
States
- Rest row: title
--ink, meta--ink-2, number/year/arrow--ink-3. - Hover row: title, meta and arrow
--accent; arrow +4px; trail spawns on movement. - Focus-visible row:
outline: 2px solid var(--accent); outline-offset: −2px(inset so it does not collide with hairlines); colour as hover;.thumbvisible. - Reduced motion: hover behaves like focus (static thumbnail), no trail.
- Nav hover:
--ink-2→--ink. - Trail card: always non-interactive (
pointer-events: none), sits above rows (z-index: 5) and below nothing else.
Accessibility
- Rows are
<a>inside<ul>/<li>, so screen readers get “list, 6 items” and each link’s name is the full row text (“01 Fjord Bank rebrand Identity, signage, motion 2026”). - The trail layer and every
.thumbarearia-hidden="true"; captions on cards are decorative (::afterfromdata-cap). - Keyboard: Tab moves nav (4) → rows (6). Enter follows the link. No key spawns cards; the static thumbnail is the keyboard equivalent.
- Focus ring is inset 2px cobalt; visible against both paper and the hairlines.
- Contrast:
--inkon--bg15.3:1;--ink-25.2:1;--accent6.6:1;--ink-3(a5a199) is 2.5:1 and is used only for 12–14px numerals and the footer helper — if the product needs AA on those, use--ink-2. - Hit targets: rows are ≈ 96px tall × full width.
overflow: hiddenon body is for the 1280×800 frame; remove it in a real page so the list can scroll.
Responsive rules
- ≥ 1280: as specified.
- 1024–1279 (demo
max-width: 1100px): titles 36px; grid40px 1fr 160px 90px 32px. - 768–1023: hide the meta column; grid
36px 1fr 72px 28px; card 150×104px; step 40px. - < 640: rows become two lines (title over meta), 28px titles; disable the trail entirely (touch has no hover) and show the static
.thumbon:activeinstead; gutters 20px.
Acceptance checklist
- Six rows in a
<ul>, each an<a>with grid columns48px 1fr 220px 120px 40pxand 24px gap, separated by 1px#dedad1hairlines. - Row titles are Instrument Serif 44px with the last word italic.
- A trail card spawns only after ≥ 48px of pointer travel, at the pointer position, centred.
- Cards are 180×124px, 4px radius,
0 12px 32px rgba(23,22,26,.18)shadow, gradient from the row’s--g, caption fromdata-cap. - Pop: 260ms
cubic-bezier(.16,1,.3,1)from scale .6; fade: 720mscubic-bezier(.2,.7,.2,1)moving 24px up, starting at 260ms. - Never more than 6 cards in the DOM; the oldest is removed synchronously when a 7th spawns.
- Cards remove themselves on
animationendof the fade — nosetTimeout. - Rotation alternates −3° / 0° / +3° and persists through both keyframes.
- Hovering or focusing a row turns its text
#2f4bd8and nudges the arrow 4px right in 160ms. - Focus shows an inset 2px cobalt outline and a static 140×96px thumbnail at
right: 56px. - With
prefers-reduced-motion: reduceno cards ever appear and hover shows the static thumbnail. - The trail layer is
position: fixed; inset: 0; pointer-events: none; aria-hidden.
Implementation notes
Distance-based spawning avoids timers and makes fast/slow pointer speeds look the same:
const STEP = 48, MAX = 6, alive = []; let lastX = 0, lastY = 0, k = 0;
list.addEventListener('pointermove', (e) => {
const row = e.target.closest('.row'); if (!row) return;
const dx = e.clientX - lastX, dy = e.clientY - lastY;
if (dx * dx + dy * dy < STEP * STEP) return;
lastX = e.clientX; lastY = e.clientY; spawn(row, e.clientX, e.clientY);
});
list.addEventListener('pointerleave', () => { lastX = -9999; }); // next entry spawns immediately
Two chained keyframes on one element, with the rotation carried through both:
.img { transform: translate(-50%,-50%) scale(.6) rotate(var(--rot, 0deg)); opacity: 0;
animation: pop 260ms cubic-bezier(.16,1,.3,1) forwards,
gone 720ms cubic-bezier(.2,.7,.2,1) forwards 260ms; }
@keyframes pop { to { opacity: 1; transform: translate(-50%,-50%) scale(1) rotate(var(--rot, 0deg)); } }
@keyframes gone { to { opacity: 0; transform: translate(-50%, calc(-50% - 24px)) scale(.94) rotate(var(--rot, 0deg)); } }
Cap the population and clean up on the right animation only:
function spawn(row, x, y) {
const el = document.createElement('div'); el.className = 'img';
el.style.left = x + 'px'; el.style.top = y + 'px';
el.style.setProperty('--g', getComputedStyle(row).getPropertyValue('--g'));
el.style.setProperty('--rot', ((k++ % 3) - 1) * 3 + 'deg');
el.dataset.cap = row.dataset.cap; trail.appendChild(el); alive.push(el);
if (alive.length > MAX) alive.shift().remove();
el.addEventListener('animationend', (e) => { if (e.animationName !== 'gone') return;
alive.splice(alive.indexOf(el), 1); el.remove(); });
}
Common mistakes: spawning on every pointermove (hundreds of nodes per second); listening for animationend without checking animationName (the pop ends first and removes the card early); putting the trail layer inside a scrolling container instead of position: fixed; forgetting pointer-events: none so cards steal hover from the row beneath and flicker.
Details
- Palette
- Type
- Instrument Serif · Hanken Grotesk
- Motion
- Rich motion
- Build
- One session
- Tags
On the shelves