Design Lounge
Install

Nº 137 of 520

Photographer horizontal gallery

A 10-plate Wadden-coast series that scrolls sideways: 500px CSS photographs, 9% lerp drag, a 10-tick scrub bar and a 01/10 index.

Preview

Open full size
1280 × 800 · 13.5 KB · 157 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 "Photographer horizontal gallery" (piece portfolio-photographer-horizontal) 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

222 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

A full-page portfolio for photographer Yuki Halloran, series Salt Year. The viewport is a horizontal track: a 500px-wide intro, ten 500px-tall plates at three widths (720 / 560 / 380), and a 520px closer. Wheel, drag, arrow keys and a 10-tick scrubber all ease the track with a 9% lerp. The plate nearest the centre is opacity 1; others 0.5. Each plate is a CSS “photograph” (gradients, not files) with a 5% opposite parallax. A fractal-noise grain overlay at 12% overlay-blend sits on top. Gold #C9A66B is the only accent. The detail worth copying is treating vertical wheel delta as horizontal travel, so a mouse wheel “just works” on a sideways gallery.

Reference behaviour

  1. Initial state: intro in view. Title Salt / Year (second word padded 96px). Hint arrow nudges 8px every 2.4s. Index reads 01 / 10 and tick 01 is aria-current. While x < 200 the first two figures also get .on so the open does not look dim.
  2. Wheel: preventDefault, add 1.1 * (the larger of |deltaY| and |deltaX|) to target tx, clamped [0, max]. max = track.scrollWidth − view.clientWidth.
  3. Pointer drag: grabbing cursor, capture pointer, tx = startTx − (clientX − startX) * 1.4.
  4. Each frame: x += (tx − x) * (reduce ? 1 : 0.09). Stop the rAF when |tx − x| < 0.3. Apply translate3d(-x, 0, 0) on the track.
  5. For each figure, compute the delta from plate centre to viewport centre. The nearest is current: pad the index to two digits, set aria-current="true" on that tick, .on on that figure (and on 0–1 while x < 200). Inner photo i parallax translate3d((-d * 0.05)px, 0, 0). Fill bar width = x / max * 100%.
  6. Tick buttons: to(i) sets tx so plate i is centred (offsetLeft + width/2 − view.clientWidth/2).
  7. Keyboard when .view is focused: ArrowRight / ArrowLeft step one plate; Home tx = 0; End tx = max.
  8. Resize remeasures max and reclamps.
  9. document.fonts.ready remeasures so Bodoni metrics do not leave the track short.
  10. Reduced motion: lerp factor 1 (snap), hint animation none, transitions 1ms. Parallax still updates (it is a position, not a loop).

Structure

1280 × 800  overflow hidden, bg #0E0D0B
┌ header 72, grid 1fr auto 1fr, pad 0 48 ─────────────────────────────────┐
│ Yuki Halloran     SALT YEAR  INTERIORS  PORTRAITS  INDEX     [Enquiries]│
├ view (inset 72 0 96 0), cursor grab, tabindex 0 ────────────────────────┤
│ track: flex, align centre, gap 72, pad 0 48, width max-content          │
│ [intro 500] [720] [380] [560] [380] [380] [720] [560] [720] [380] [720] [outro 520]
│  Salt Year   01    02    03    04    05    06    07    08    09    10   Fin.
├ footer 96, grid 200px 1fr 260px, pad 0 48 ────────────────────────────────┤
│ 01 / 10          [10 ticks on a 1px rule, gold fill]     SCROLL · DRAG · ← → │
└ grain SVG, mix-blend overlay, opacity .12, pointer-events none ─────────┘
  • <header> — italic 22px name, <nav aria-label="Series"> four +0.22em uppercase links (Salt Year aria-current="page"), .enq outlined “Enquiries”.
  • #view.view role="region" tabindex="0" aria-label="Salt Year gallery. Use arrow keys to move between photographs."
  • #track — .intro, ten <figure>, .outro.
  • Figures: .w-l 720 / .w-m 560 / .w-p 380. .ph height 500, overflow hidden. Inner <i class="pN"> is the image. figcaption: index + italic title / place-or-time.
  • .outro — “Fin.” 96px italic, show copy, gold “Request the price list” button.
  • <footer> — #n live index aria-live="polite", #ticks (buttons injected), .help.
  • Grain: SVG feTurbulence baseFrequency=".9" numOctaves="2".

Plates (width class, title, meta, image recipe):

#classtitlemetarecipe
01w-lLow water, Rømø05:12 · Marchsand-to-mud horizon, 46px moon + glow
02w-pNet shedHavnebydark interior, ochre door slit
03w-mSalt pansLæsø · Junepink pans, 120/90px grid
04w-pFog, 06:40Tønder marshpale fog over fence posts
05w-pKirsten, boatwrightEsbjergbrown oval “head and shoulders”
06w-lBrineLakolk · Augustteal water, three pale highlights
07w-mMarramFanøgold dune grass hatch
08w-lLast ferrySønderho · 23:50night water, three window lights
09w-pFleur de selByrummultiply conic crystal
10w-lEbb, 21:04Rømø · Decembersplit dusk / wet sand

Intro: Series · 2024–2026 · 10 plates. Deck: “Twelve months on the Wadden coast, from Rømø to Læsø, photographed only in the hour either side of low water.” Outro: on show Galleri Havn, Copenhagen, 14 November 2026 to 10 January 2027. Pigment prints editions of 7, from DKK 9,800.

Tokens

:root {
  /* colour — warm black, bone type, one gold */
  --bg: #0e0d0b;
  --bg-2: #17150f;        /* plate backing */
  --ink: #ede6da;
  --ink-2: #a39a8c;
  --ink-3: #6e675d;
  --line: #2c2924;
  --accent: #c9a66b;

  /* type */
  --serif: "Bodoni Moda", Didot, serif;
  --sans: "Tenor Sans", system-ui, sans-serif;

  /* layout */
  --ph: 500px;
  --gap: 72px;
  --head: 72px;
  --foot: 96px;
  --w-l: 720px;
  --w-m: 560px;
  --w-p: 380px;
  --intro: 500px;
  --outro: 520px;

  /* motion */
  --t-micro: 200ms;
  --t-in: 900ms;
  --ease: cubic-bezier(.2, .7, .2, 1);
  --ease-out: cubic-bezier(.16, 1, .3, 1);
  --lerp: .09;
  --parallax: .05;
  --wheel: 1.1;
  --drag: 1.4;
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
BodyTenor Sans13px4001.5+0.02emsentence
NameBodoni Moda22px400 italic10title
Nav / Enquiries / hint / helpTenor Sans11px4001+0.22em / +0.2emUPPERCASE
Series kickerTenor Sans11px4001+0.24emUPPERCASE
TitleBodoni Moda156px4000.84−0.035emtitle
Intro / outro pTenor Sans14px4001.70sentence
Caption metaTenor Sans11px4001+0.14emUPPERCASE
Caption titleBodoni Moda15px400 italic10sentence
IndexBodoni Moda54px4001−0.02emlining
Index /10Tenor Sans13px4001+0.1emmixed
Fin.Bodoni Moda96px400 italic0.9−0.03emtitle
Price CTATenor Sans11px4001+0.22emUPPERCASE

Enquiries: 11px pad 11 18, border --line, hover border+colour --accent. Price CTA: --bg type on --accent fill, pad 14 22.

Motion

ElementTriggerPropertyFrom → ToDuration / factorEasingNotes
Trackwheel/drag/keystranslate3d xlerp toward tx0.09 / frame—1.0 if reduced
Figure opacitynearestopacity0.5 → 1600ms--easetwo on while x<200
Photo parallaxscrolltranslate3d x−0.05 × (centre − mid)per frame—inner i is inset −40px so it can slide
Hint arrowlooptranslateX0 ↔ 8px2.4s--easeinfinite
Tick markhover/currentheight, colour9px ink-3 → 17px accent200ms--ease
Fill barscrollwidth %0 → 100 of maxper frame—1px gold on the rule
Enquirieshoverborder, colorline/ink → accent200ms--ease

Wheel listener { passive: false }. View class .drag while pointer is down (cursor: grabbing).

States

  • Resting plates: opacity 0.5. Current (and 01–02 at start): opacity 1.
  • Current tick: aria-current="true", 17px gold mark.
  • Nav current / hover: --ink (from --ink-2).
  • Enquiries hover: gold border and type.
  • View focus: 1px gold outline, 4px offset (the gallery itself is a region).
  • Drag: .drag grabbing cursor.
  • Index: 01–10, live region polite.

Accessibility

  • Gallery region has a name that mentions arrow keys. It is tabindex="0" so keyboard users can move it.
  • Ticks are buttons with aria-label="Go to plate N". Index is aria-live="polite".
  • Series nav uses aria-current="page" on Salt Year.
  • Grain SVG aria-hidden. Hint SVG aria-hidden.
  • Tab order: name → 4 series links → Enquiries → view (then arrows operate) → 10 ticks → price-list link (when scrolled to the end; it is in the track).
  • Contrast: bone on near-black; --ink-2 for 14px deck (~6:1); --ink-3 for 11–13px meta.
  • Hit targets: Enquiries ~40px tall; ticks are 28px tall flex-1 (wide). The view is the remaining viewport.

Responsive rules

  • ≥ 1280: as specified, photo height 500, gap 72, side pad 48.
  • 1024–1279: photo height 440; .w-l 640; name 20px; title 120px.
  • 768–1023: photo height 360; gap 48; intro 380; hide the help legend or wrap it. Drag remains the primary gesture.
  • < 640: photo height 280; .w-l 100vw − 48px; ticks stay 10 buttons (thin). Keep horizontal-only — do not restack into a vertical masonry.
  • On every size, remeasure max on resize and after fonts.ready.
  • Reduced motion snaps (lerp = 1) and kills the hint loop.

Acceptance checklist

  • Header 72px, footer 96px, photo height 500px, gap 72px at 1280×800.
  • Ten plates at widths 720 / 380 / 560 / 380 / 380 / 720 / 560 / 720 / 380 / 720 in that order.
  • Wheel (vertical or horizontal) moves the track; drag with 1.4× multiplier; lerp 0.09.
  • Current plate is the one whose centre is nearest the viewport centre; index is two digits; ticks aria-current.
  • Inner photographs parallax by 5% against their centre delta.
  • While x < 200, plates 01 and 02 are both at opacity 1.
  • Arrow keys step plates; Home/End jump; view is focusable.
  • Footer fill bar equals x / max; grain overlay is fractal noise at 12% opacity.
  • Title is 156px Bodoni; “Year” is on the second line padded 96px.
  • Copy matches Salt Year, Wadden, Galleri Havn, DKK 9,800, editions of 7.
  • Reduced motion snaps without the 9% glide.
  • Focus rings are 1px gold, 4px offset.

Implementation notes

One rAF loop, target vs position — never set transform from the wheel handler directly:

function tick() {
  x += (tx - x) * (reduce ? 1 : .09);
  if (Math.abs(tx - x) < .3) x = tx;
  render();
  raf = x !== tx ? requestAnimationFrame(tick) : 0;
}
view.addEventListener('wheel', (e) => {
  e.preventDefault();
  const d = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
  tx = Math.max(0, Math.min(max, tx + d * 1.1));
  if (!raf) raf = requestAnimationFrame(tick);
}, { passive: false });

Centre a plate from ticks or arrows:

function centreOf(i) {
  const f = figs[i];
  return f.offsetLeft + f.offsetWidth / 2 - view.clientWidth / 2;
}

Parallax on the inner image, which is wider than the frame (inset: 0 -40px) so edges do not show:

iEl.style.transform = `translate3d(${(-d * .05).toFixed(1)}px,0,0)`;

Common mistakes: using overflow-x: auto native scroll (you lose lerp, parallax and the custom ticks); applying wheel to window without preventDefault (the iframe will bounce); measuring max before fonts load (intro 156px Bodoni changes width); hiding off-centre plates with display:none (breaks offsetLeft); raster <img> instead of the CSS plates.

Details

Palette
Type
Bodoni Moda · Tenor Sans
Motion
Rich motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

GMX Digital

Keep the headline still and let the scenes change behind it. A thin line counts down each one.

All sources