Nº 595 of 605
Parallax cursor gallery
A salon-hung museum wall of drawn gouaches whose frames and paint layers drift at depths with the pointer, plus a focus mode that FLIPs one work up to full size.
Preview
Use it
Free to use · credit appreciated1 Once, in your project · Node 22.20+
npx skills add SusanAcharya/Design-Lounge 2 Then ask
Using the Design Lounge skill, build "Parallax cursor gallery" (piece parallax-cursor-gallery) 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º 595 · "Parallax cursor gallery" · www.designlounge.live -->
# Parallax cursor gallery
> **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 online room of a museum exhibition ("Weather Studies", Hall 4 of the fictional Varde Art Hall): eight small gouaches by an invented painter, Agnes Ruoho, hung salon style on a warm plaster wall. Each work is a framed, matted SVG made of three paint layers. Moving the pointer shifts the frames a few pixels at their own depth and shifts the paint layers inside each frame further, so every picture gains a little depth behind its glass, and the frame shadows lean away from the pointer as if the light moved. Scrolling adds a slow vertical drift at the same depths. Selecting a work opens a focus mode: the wall dims to 94% plaster, the work grows from its place on the wall to full height with a FLIP, and a wall-label panel gives the title, medium, size and a curator's line, with previous and next. The feeling is a quiet room; nothing moves faster than 900ms.
## Reference behaviour
1. Initial frame (1280×800): a 64px top bar ("VARDE ART HALL" tracked caps left; Collection, Hall 4 (current, red), Visit, Tickets right) over a 1px rule. Below, a 360px intro column (kicker "Hall 4 · Until 1 March 2027", the 84px title "Weather Studies", two short paragraphs, a ruled credit line, and the hint "Move to look closer. Select a work to stand in front of it.") and a salon of six framed works with labels to its right.
2. Pointer moves (mouse or pen, not touch): `--mx` and `--my` are set from −1 to 1 across the viewport. Each `.work` translates by `(mx, my) × d` px with its own depth `d` (5–14); each paint layer `<g>` inside the art translates by `(mx, my) × z` px with `z` 2, 6–8 and 12–14 for back, middle and front. Both transitions run 900ms expo-out, so motion trails the pointer softly.
3. Frame shadows: `box-shadow: (mx × −8px) (16px + my × −6px) 28px rgba(46,32,20,.22)`, so the shadow falls opposite the pointer.
4. Pointer leaves the document: everything eases back to centre.
5. Scroll: each work also drifts `scrollY × d × −0.004px` vertically (capped at scrollY 1600), so deeper works rise slightly faster; it is barely perceptible and only adds weight.
6. Hover a frame: lifts 4px over 320ms; cursor is `zoom-in`.
7. Click or press Enter on a frame: focus mode opens. The scrim fades in over 320ms; the large frame animates from the clicked frame's rect to its own (translate + scale from top-left, 560ms `cubic-bezier(.16,1,.3,1)`). Focus moves to Close. In focus mode the paint layers move at 2.2× depth.
8. In focus mode: Right/Left arrows or the 44px round buttons step through all eight works (wrapping); the counter reads "2 of 8". Esc, the Close pill, or a click on the scrim closes; the big frame FLIPs back to the current work's place on the wall and focus returns to that frame. Tab is trapped inside the dialog.
9. Further down, a second band on a slightly darker wall ("The harbour years, 1968 to 1971") hangs two more works in a `1fr 1.25fr` row and a closing note.
## Structure
```
1280 × 800
┌──────────────────────────────────────────────────────────────────────┐
│ VARDE ART HALL Collection Hall 4 Visit Tickets │ 64
├──────────────────────────────────────────────────────────────────────┤
│ HALL 4 · UNTIL … ┌────────────┐ ┌──────┐ ┌────┐ │
│ Weather │ Low cloud │ │Birch │ │Hern│ 144w │
│ Studies 84px │ 360w │ │224w │ └────┘ │
│ ¶ ¶ (max 300) └────────────┘ │ │ label │
│ ─ credit line ─ label └──────┘ ┌────┐ │
│ ⌖ hint ┌─────────┐ ┌──────┐ │Loft│ 152w │
│ │Rapeseed │ │Harbr │ └────┘ │
│ 360 col · gap 32 │ 296w │ │220w │ │
│ └─────────┘ └──────┘ salon 792 × 620 │
└──────────────────────────────────────────────────────────────────────┘
```
… 161 more lines. Copy or download for the full brief. Loading the source…Brief
201 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
The online room of a museum exhibition (“Weather Studies”, Hall 4 of the fictional Varde Art Hall): eight small gouaches by an invented painter, Agnes Ruoho, hung salon style on a warm plaster wall. Each work is a framed, matted SVG made of three paint layers. Moving the pointer shifts the frames a few pixels at their own depth and shifts the paint layers inside each frame further, so every picture gains a little depth behind its glass, and the frame shadows lean away from the pointer as if the light moved. Scrolling adds a slow vertical drift at the same depths. Selecting a work opens a focus mode: the wall dims to 94% plaster, the work grows from its place on the wall to full height with a FLIP, and a wall-label panel gives the title, medium, size and a curator’s line, with previous and next. The feeling is a quiet room; nothing moves faster than 900ms.
Reference behaviour
- Initial frame (1280×800): a 64px top bar (“VARDE ART HALL” tracked caps left; Collection, Hall 4 (current, red), Visit, Tickets right) over a 1px rule. Below, a 360px intro column (kicker “Hall 4 · Until 1 March 2027”, the 84px title “Weather Studies”, two short paragraphs, a ruled credit line, and the hint “Move to look closer. Select a work to stand in front of it.”) and a salon of six framed works with labels to its right.
- Pointer moves (mouse or pen, not touch):
--mxand--myare set from −1 to 1 across the viewport. Each.worktranslates by(mx, my) × dpx with its own depthd(5–14); each paint layer<g>inside the art translates by(mx, my) × zpx withz2, 6–8 and 12–14 for back, middle and front. Both transitions run 900ms expo-out, so motion trails the pointer softly. - Frame shadows:
box-shadow: (mx × −8px) (16px + my × −6px) 28px rgba(46,32,20,.22), so the shadow falls opposite the pointer. - Pointer leaves the document: everything eases back to centre.
- Scroll: each work also drifts
scrollY × d × −0.004pxvertically (capped at scrollY 1600), so deeper works rise slightly faster; it is barely perceptible and only adds weight. - Hover a frame: lifts 4px over 320ms; cursor is
zoom-in. - Click or press Enter on a frame: focus mode opens. The scrim fades in over 320ms; the large frame animates from the clicked frame’s rect to its own (translate + scale from top-left, 560ms
cubic-bezier(.16,1,.3,1)). Focus moves to Close. In focus mode the paint layers move at 2.2× depth. - In focus mode: Right/Left arrows or the 44px round buttons step through all eight works (wrapping); the counter reads “2 of 8”. Esc, the Close pill, or a click on the scrim closes; the big frame FLIPs back to the current work’s place on the wall and focus returns to that frame. Tab is trapped inside the dialog.
- Further down, a second band on a slightly darker wall (“The harbour years, 1968 to 1971”) hangs two more works in a
1fr 1.25frrow and a closing note.
Structure
1280 × 800
┌──────────────────────────────────────────────────────────────────────┐
│ VARDE ART HALL Collection Hall 4 Visit Tickets │ 64
├──────────────────────────────────────────────────────────────────────┤
│ HALL 4 · UNTIL … ┌────────────┐ ┌──────┐ ┌────┐ │
│ Weather │ Low cloud │ │Birch │ │Hern│ 144w │
│ Studies 84px │ 360w │ │224w │ └────┘ │
│ ¶ ¶ (max 300) └────────────┘ │ │ label │
│ ─ credit line ─ label └──────┘ ┌────┐ │
│ ⌖ hint ┌─────────┐ ┌──────┐ │Loft│ 152w │
│ │Rapeseed │ │Harbr │ └────┘ │
│ 360 col · gap 32 │ 296w │ │220w │ │
│ └─────────┘ └──────┘ salon 792 × 620 │
└──────────────────────────────────────────────────────────────────────┘
Salon positions (left, top, width of the outer frame): Low cloud 0, 28, 360 · Three birches 392, 0, 224 · Night heron 648, 44, 144 · Rapeseed 44, 372, 296 · Harbour 380, 392, 220 · Net loft window 640, 330, 152.
<header class="top">with a brand span and<nav aria-label="Museum">; current link hasaria-current="page".<section class="hall">: grid360px 1fr, gap 32, padding 56 48 64..introthen.salon(relative, 620px tall).- Each work:
.work(absolute, carries--d) →<button class="frame">(frame colour--fr, padding--b) →.mat(padding--m, inset shadow) →.art(aspect-ratio, overflow hidden) →<svg aria-hidden>with three<g style="--z:n">layers; then a.labelspan (title + year bold line, medium and size). <section class="room2">: kicker, h2,.rowgrid,.note.<div class="focus" role="dialog" aria-modal="true" aria-labelledby="ft">: Close pill,.stage>.big(the work rebuilt at size),.panel(kicker, h3, meta, paragraph, prev/next and counter).
Tokens
:root {
/* colour */
--wall: #ece6db; /* plaster */
--wall-2: #e3dccf; /* second room */
--rule: #d4ccbe; /* top bar and room rules */
--rule-2: #cfc6b7; /* credit rule */
--ink: #2e2924; /* text */
--dim: #62584e; /* labels, kickers */
--red: #9b2a23; /* current hall, focus ring */
--walnut: #2b2420; /* dark frames */
--oak: #b4966b; /* light frames */
--mat: #f7f3ec; /* mats */
--ctl-line: #7f7467; /* round button and pill outlines */
--scrim: rgba(236, 230, 219, .94);
--shadow: rgba(46, 32, 20, .22);
--font: "Tenor Sans", Optima, Candara, sans-serif;
/* frames */
--b: 9px; --m: 18px; /* frame and mat; small works 7px / 12px; focus 14px / 32px */
--lift: 4px;
/* pointer and scroll state, written by JS */
--mx: 0; --my: 0; --sy: 0;
/* motion */
--ease-out: cubic-bezier(.16, 1, .3, 1);
--ease: cubic-bezier(.2, .7, .2, 1);
--t-follow: 900ms; --t-flip: 560ms; --t-scrim: 320ms; --t-lift: 320ms;
}
Typography
| Role | Family | Size | Weight | Line-height | Tracking | Case |
|---|---|---|---|---|---|---|
| Hall title | Tenor Sans | 84px (56px < 900) | 400 | 0.98 | −0.02em | Title Case |
| Room title | Tenor Sans | 44px (34px < 900) | 400 | 1.05 | 0 | sentence |
| Focus title | Tenor Sans | 34px | 400 | 1.1 | 0 | sentence |
| Body | Tenor Sans | 15px | 400 | 1.55 | 0 | sentence |
| Brand | Tenor Sans | 15px | 400 | 1 | +0.24em | UPPERCASE |
| Nav | Tenor Sans | 13px | 400 | 1 | +0.08em | Title Case |
| Kicker | Tenor Sans | 11px | 400 | 1.4 | +0.22em | UPPERCASE |
| Label title | Tenor Sans | 13px | 400 | 1.45 | 0 | sentence, ink |
| Label medium | Tenor Sans | 12px | 400 | 1.45 | 0 | sentence, dim |
| Counter, pill | Tenor Sans | 12px | 400 | 1 | +0.14–0.16em | caps on pill |
Tenor Sans has one weight; hierarchy comes from size, tracking and the ink/dim split only.
Motion
| Element | Trigger | Property | From → To | Duration | Easing |
|---|---|---|---|---|---|
.work | pointer, scroll | translate | (mx·d, my·d − sy·d·0.004) px | 900ms | --ease-out |
.art g | pointer | translate | (mx·z, my·z) px (×2.2 in focus) | 900ms | --ease-out |
.frame shadow | pointer | box-shadow offset | (−8·mx, 16 − 6·my) | 900ms | --ease-out |
.frame | hover | translateY | 0 → −4px | 320ms | --ease |
.focus | open / close | opacity, visibility | 0 ↔ 1 | 320ms | --ease |
.big | open / close | transform (FLIP) | thumb rect → own rect (reverse on close) | 560ms | cubic-bezier(.16,1,.3,1) |
JS only writes three numbers (--mx, --my, --sy) in one rAF per frame; CSS does all the easing. Touch pointers are ignored so phones do not jitter while scrolling.
Reduced motion: all transitions off, .work and .art g transforms forced to none, FLIP skipped, focus mode opens and closes instantly. The wall is complete and every work is still openable.
States
- Rest: all layers centred.
- Hover frame: lifted 4px; no colour change.
- Focus-visible: 2px
--redoutline, 4px offset, on frames, nav links, Close and the round buttons. - Focus mode open: scrim, big work, panel; page behind is inert to pointer;
aria-hidden="false"on the dialog. - Round button / pill hover: outline darkens from
--ctl-lineto--ink. - Current nav item: red text,
aria-current="page".
Accessibility
- Every frame is a
<button>labelled “Focus, <year>”; the visible label below repeats the title for sighted readers. - SVG paintings are
aria-hidden; the label and the focus panel carry the description. - Focus mode is
role="dialog" aria-modal="true"labelled by its title; focus moves to Close on open, Tab and Shift+Tab cycle inside it, Esc closes, arrows step, and focus returns to the frame of the work last shown. - Contrast: ink on plaster 11.6:1, dim on plaster 5.6:1 and on the second wall 5.1:1, red 6.2:1. Control outlines
#7f7467are above 3:1 on plaster. - Hit targets: frames are large; prev/next are 44px; Close is 40px tall.
Responsive rules
- ≥ 1280: as specified.
- 1024–1279: the salon keeps absolute positions; the right-most column sits closer to the edge (still clear at 1024 because the salon is 792px wide).
- 900–1023: reduce the intro column to 300px; positions unchanged.
- < 900: the top nav shows only the current item. The hall becomes one column (padding 40 16 48). The salon turns into a two-column grid (gap 40px 20px) with the first work spanning both columns; works become static. Room two stacks. Focus mode stacks the big work over the panel, scrolls inside itself, with padding 80 16 24.
- Pointer parallax is off on touch; the layout never scrolls sideways at 390px.
Acceptance checklist
Always
- JS writes only
--mx,--my(−1…1) and--sy, once per animation frame; all easing is a 900ms CSS transition. - Each work has a frame depth
dand each painting three layer depthsz; front layers move most. - Frame shadows move opposite to the pointer.
- Touch pointers do not drive the parallax.
- Focus mode FLIPs from the clicked frame’s rect and back to the current work’s rect on close.
- Focus mode traps Tab, closes on Esc and scrim click, steps with arrow keys and returns focus to the frame.
- Reduced motion removes all movement and the FLIP but keeps focus mode.
- Text contrast at least 4.5:1 on both walls and on the scrim; nothing scrolls sideways at 390px.
This demo
- Salon of six works at the positions listed under Structure; two more in “The harbour years, 1968 to 1971”.
- Frames walnut
#2b2420or oak#b4966b, mats#f7f3ec, wall#ece6db. - Hall title “Weather Studies” at 84px; kicker “Hall 4 · Until 1 March 2027”.
- Focus counter reads “N of 8”; panel kicker “Agnes Ruoho ·
”.
Implementation notes
Depth in two places. The frame moves a little; the paint inside moves more. Use CSS variables so one pointer value feeds both:
.work { transform: translate3d(calc(var(--mx) * var(--d) * 1px),
calc(var(--my) * var(--d) * 1px + var(--sy) * var(--d) * -.004px), 0);
transition: transform 900ms var(--ease-out); }
.art { overflow: hidden; } /* layers must be clipped by the mat */
.art g { transform: translate(calc(var(--mx) * var(--z) * 1px), calc(var(--my) * var(--z) * 1px));
transition: transform 900ms var(--ease-out); }
Draw each SVG layer about 20 units larger than the viewBox on every side, so a moving layer never shows a gap at the edge.
FLIP from the wall:
const flip = (from, back) => {
const a = from.getBoundingClientRect(), b = big.getBoundingClientRect();
const k = `translate(${a.left - b.left}px,${a.top - b.top}px) scale(${a.width / b.width})`;
return big.animate([{ transform: k, transformOrigin: '0 0' }, { transform: 'none', transformOrigin: '0 0' }],
{ duration: 560, easing: 'cubic-bezier(.16,1,.3,1)', direction: back ? 'reverse' : 'normal' }).finished;
};
Pointer to numbers, once per frame:
addEventListener('pointermove', (e) => {
if (e.pointerType === 'touch') return;
mx = (e.clientX / innerWidth - .5) * 2; my = (e.clientY / innerHeight - .5) * 2;
if (!raf) raf = requestAnimationFrame(paint); // paint() sets --mx/--my/--sy on :root
}, { passive: true });
Common mistakes: moving the whole SVG instead of its layers (the picture slides inside the mat and shows its edge); a fast transition (under 400ms the wall feels nervous, not like a room); measuring the big frame before the dialog is visible (its rect is zero, so open first, then FLIP); forgetting to clip .art, so the front layer pokes over the mat.
Details
- Palette
- Type
- Tenor Sans
- Motion
- Subtle motion
- Build
- One session
- Tags
Studied
Susan AcharyaDraw one scene and let it move a little. The words stay calm on top.
All sources