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:
- Bone ground, hairline rules. No cards, no shadows, no rounded corners. Structure comes from 1px rules and whitespace.
- One typeface, few sizes. Archivo (a neo-grotesque) in exactly four roles: giant display, work titles, body, and 11px uppercase labels/numerals with tabular figures.
- Tiny index numerals.
01–09everywhere — the connective tissue between the index, the grid captions, and the section heads. - The restraint is the flex. All the craft budget goes into interactions: the cursor-led index, clip-path image reveals, an editorial grid with confident offsets.
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:
| Series | Subject | Grading brief |
|---|---|---|
| Meridian (3) | Brutalist architecture in fog | Near-monochrome, Kodak Tri-X, cool greys, edges dissolving |
| Ballast (3) | Lone figures in vast landscapes | Muted Kodak Portra 160 color, tiny figure, enormous sky |
| Still Air (3) | Still-life in hard light | Bone/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
- Clip-path masks: every grid figure starts at
clip-path: inset(0 0 100% 0)and reveals toinset(0)withpower4.inOutover 1.25s, while the image inside settles fromscale(1.12)to 1 — a wipe with weight, triggered once via ScrollTrigger attop 82%. - Editorial grid: a 12-column grid where each work is hand-placed (
grid-column: 8 / span 5; margin-top: 22vhetc.). Negative top margins let portrait and landscape frames interlock like a magazine spread. Below 900px it simplifies to alternating widths; below 640px it stacks. - Index → grid linking: each index row anchors to its figure in the grid, so the list doubles as navigation.
- Reduced motion:
prefers-reduced-motionunsets the masks, transforms and entrance animations entirely.
05 — Tech notes
- Plain HTML/CSS/JS in one file — no build step. GSAP 3.12 + ScrollTrigger from jsDelivr.
- Archivo from Google Fonts, weights 400/500 only.
font-feature-settings:"tnum"keeps numerals tabular so the index columns align. overflow-x: cliponhtml, body— the offset grid items and the fixed cursor frame can mathematically overhang the viewport; clip kills the scrollbar without breakingposition:sticky/fixedlikehiddencan.- The header uses
mix-blend-mode: darkenso it stays legible while photographs pass beneath it. - Images lazy-load; the cursor-frame copies are the only eager decodes, and they're the small recompressed files anyway.
06 — Deploying to Netlify
- The folder is already deployable — it's just static files (
index.html,guide/,assets/). - Drag-and-drop: zip the
mara-vossfolder and drop it on app.netlify.com/drop. Done. - Or with the CLI:
npm i -g netlify-cli cd mara-voss netlify deploy --prod --dir . - 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.