← Back to the site

How this site was built

A walkthrough of the Mara Voss portfolio — the concept, how a fictional photographer's body of work was art-directed with AI image generation, the cursor-reveal index technique, and how to deploy it.

01 — Concept

Mara Voss is a fictional fine-art photographer, and the site is an exercise in radical minimalism: the photographs are everything, so the design's only job is to stay out of their way while still feeling engineered. The vocabulary is deliberately tiny:

#F3F1EC bone
#161512 ink
#8B8779 mute
#D9D5CA hairline

02 — Art-directing a fictional body of work

The whole site lives or dies on whether the photography reads as one photographer's practice, not nine random AI images. The trick is to invent the artist first, then generate the pictures. Mara got three series with distinct subjects but one sensibility — subtraction, negative space, weather, film:

SeriesSubjectGrading brief
Meridian (3)Brutalist architecture in fogNear-monochrome, Kodak Tri-X, cool greys, edges dissolving
Ballast (3)Lone figures in vast landscapesMuted Kodak Portra 160 color, tiny figure, enormous sky
Still Air (3)Still-life in hard lightBone/ivory palette, one sharp shadow, large-format feel

Every image was generated from a carefully art-directed brief — lighting, film stock, palette, composition and mood specified per frame, with a shared "house style" carried across all three series so the grading stays coherent. Orientations were mixed on purpose — 2:3, 3:2 and 3:4 — because the editorial grid needs rhythm.

Nine images were generated with Higgsfield's Soul 2.0 model (strong at editorial realism), then downloaded and recompressed to ~1600px JPEGs so the page stays light. The full prompt library behind the series is proprietary — available to clients on request.

Art direction lesson: don't prompt for "a beautiful photo". Prompt for a practice — the same weather, the same film, the same appetite for emptiness, nine times.

03 — The cursor-reveal index (the signature)

The works index is a plain typographic list. On desktop, hovering a title makes that work's photograph float beside the cursor; sliding to the next title crossfades the image without the frame ever leaving the pointer. Three parts make it feel physical rather than "JS demo":

a) One fixed frame, images stacked inside

<div class="cursor-frame" id="cursorFrame">
  <!-- one <img> per work, position:absolute, opacity:0 -->
</div>

All nine images are preloaded into a single position:fixed, pointer-events:none frame. Switching works just toggles an .is-active class — the crossfade is a plain CSS opacity transition (380ms). Because the images are already decoded, there's zero flash on first hover of each row.

b) Lag via gsap.quickTo

const xTo = gsap.quickTo(frame, "x", { duration: 0.55, ease: "power3" });
const yTo = gsap.quickTo(frame, "y", { duration: 0.55, ease: "power3" });

window.addEventListener("mousemove", e => {
  xTo(e.clientX + 28);
  yTo(e.clientY - frame.offsetHeight * 0.5);
});

quickTo retargets a single tween instead of spawning one per mousemove, which is what gives the frame its soft inertia — it's always easing toward the cursor, never snapping. The 28px offset keeps the image clear of the pointer and the row text.

c) Velocity-driven skew

const vx = (e.clientX - lastX) / dt;          // px per ms
const tilt = gsap.utils.clamp(-7, 7, vx * 9); // cap it
rTo(tilt); sTo(tilt * 0.6);                   // rotation + skewX, eased

Horizontal cursor velocity maps to a clamped rotation/skew, eased with its own quickTo. Fast sweeps make the image lean into the motion and settle back — the "physical" part. The clamp matters: past ±7° it stops reading as inertia and starts reading as a gimmick.

d) The touch fallback is a real layout, not a patch

Inside @media (hover:none), (pointer:coarse) the same markup re-lays itself out: each index row already contains an <img class="index-thumb"> that desktop hides; on touch, the list becomes a two-column tappable grid of thumbnail + numeral + title, and the cursor frame is display:none. No JS branching for layout — the media query does it, and the hover JS simply never binds on coarse pointers.

04 — Grid reveals & editorial layout

05 — Tech notes

06 — Deploying to Netlify

  1. The folder is already deployable — it's just static files (index.html, guide/, assets/).
  2. Drag-and-drop: zip the mara-voss folder and drop it on app.netlify.com/drop. Done.
  3. Or with the CLI:
    npm i -g netlify-cli
    cd mara-voss
    netlify deploy --prod --dir .
  4. No build command, no publish directory gymnastics — set publish dir to the folder root. Add a custom domain in Site settings if you want maravoss.com.