Design Lounge
Install

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

Open full size
1280 × 800 · 17.2 KB · 200 lines

Use it

Free to use · credit appreciated

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

Brief

201 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 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   │
└──────────────────────────────────────────────────────────────────────┘

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 has aria-current="page".
  • <section class="hall">: grid 360px 1fr, gap 32, padding 56 48 64. .intro then .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 .label span (title + year bold line, medium and size).
  • <section class="room2">: kicker, h2, .row grid, .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

RoleFamilySizeWeightLine-heightTrackingCase
Hall titleTenor Sans84px (56px < 900)4000.98−0.02emTitle Case
Room titleTenor Sans44px (34px < 900)4001.050sentence
Focus titleTenor Sans34px4001.10sentence
BodyTenor Sans15px4001.550sentence
BrandTenor Sans15px4001+0.24emUPPERCASE
NavTenor Sans13px4001+0.08emTitle Case
KickerTenor Sans11px4001.4+0.22emUPPERCASE
Label titleTenor Sans13px4001.450sentence, ink
Label mediumTenor Sans12px4001.450sentence, dim
Counter, pillTenor Sans12px4001+0.14–0.16emcaps on pill

Tenor Sans has one weight; hierarchy comes from size, tracking and the ink/dim split only.

Motion

ElementTriggerPropertyFrom → ToDurationEasing
.workpointer, scrolltranslate(mx·d, my·d − sy·d·0.004) px900ms--ease-out
.art gpointertranslate(mx·z, my·z) px (×2.2 in focus)900ms--ease-out
.frame shadowpointerbox-shadow offset(−8·mx, 16 − 6·my)900ms--ease-out
.framehovertranslateY0 → −4px320ms--ease
.focusopen / closeopacity, visibility0 ↔ 1320ms--ease
.bigopen / closetransform (FLIP)thumb rect → own rect (reverse on close)560mscubic-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 --red outline, 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-line to --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 #7f7467 are 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 d and each painting three layer depths z; 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 #2b2420 or 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
Designed bySusan AcharyaKathmandu

Studied

Susan Acharya

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

All sources