⟵ Back to Ashford & Vale Build Guide

How Ashford & Vale was built

A single-page boutique law-firm site in the register of quiet-luxury editorial authority — deep ink navy, warm cream, one restrained oxblood accent, and typographic detailing borrowed from finely set legal journals. Plain HTML/CSS/JS, GSAP + ScrollTrigger from a CDN, no build step. This guide covers the concept, the exact techniques, the imagery art direction, and how to deploy it.

1 · The concept

The brief: communicate precision and calm power — “the web equivalent of a beautifully set engraved letterhead.” Three decisions carry the whole design:

2 · Palette

#10192E
ink navy — text & approach band
#F5EFE3
warm cream — page ground
#EFE7D6
deep cream — panels
#6E1A26
oxblood — the one accent
#4C101B
deep oxblood — results band
#D9A08C
faded sanguine — numerals on navy

The discipline is one accent: oxblood appears in the ampersand, the numerals, the kickers, the corner ticks and the consultation link — never as a large surface except the single results band. Ratios roughly 70% cream / 22% navy / 8% oxblood.

3 · Typography

4 · Signature techniques

4a · Extending rule lines

Every hairline is an <hr class="rule"> (or a .vrule span for verticals) that starts collapsed and draws itself when it enters the viewport. CSS holds the initial state so there is no flash; GSAP only ever animates to the resolved state:

.rule{height:1px;background:var(--rule);
      transform:scaleX(0);transform-origin:left center}

document.querySelectorAll(".rule").forEach(r => {
  gsap.to(r, {scaleX:1, duration:1.4, ease:"power3.inOut",
    scrollTrigger:{trigger:r, start:"top 88%"}});
});

Why gsap.to and not from? If the user reloads mid-page, a from tween can flash the line at full width before the plugin initialises. Starting collapsed in CSS makes the un-animated state the safe one — and it degrades perfectly under prefers-reduced-motion, where a media query simply forces transform:none.

4b · Masked serif line reveals

Headlines rise into view line by line from behind an overflow:hidden mask. No SplitText plugin — a ~40-line splitter wraps each word in a span, groups words into rendered lines by comparing offsetTop, then wraps each line in a mask + inner pair:

// group words into visual lines
const words=[...el.querySelectorAll(".w")];
let lines=[],cur=null,top=null;
words.forEach(w=>{
  if(top===null||Math.abs(w.offsetTop-top)>4){cur=[];lines.push(cur);top=w.offsetTop}
  cur.push(w);
});
// each line → <span class="line-mask"><span class="line-inner">…
gsap.from(el.querySelectorAll(".line-inner"),
  {yPercent:112, duration:1.15, ease:"power4.out", stagger:.12,
   scrollTrigger:{trigger:el, start:"top 86%"}});

Two gotchas: split after document.fonts.ready resolves (web-font metrics change the line breaks), and give the mask a hair of block padding (padding-block:.06em cancelled by a negative margin) so descenders of the serif italic aren't clipped.

4c · The deckle / paper grain

A fixed full-viewport overlay carries an inline-SVG feTurbulence noise at 5% opacity with mix-blend-mode:multiply — the whole page feels printed on laid stock, for ~300 bytes and zero requests:

body::before{content:"";position:fixed;inset:0;pointer-events:none;
  opacity:.05;mix-blend-mode:multiply;
  background-image:url("data:image/svg+xml,…feTurbulence…")}

4d · The engraved plate

The hero sits inside a 1px hairline frame with 14px oxblood corner ticks — drawn with the frame's own ::before/::after plus one extra empty span for the bottom pair. It is the letterhead conceit in one element. Photographs reveal with a clip-path: inset(0 0 100% 0) wipe while the image inside settles from scale(1.08) via a scrubbed ScrollTrigger — a curtain-lift, not a fade.

5 · Imagery — art direction

All seven images were generated with Higgsfield Soul v2 (soul_2, 2K), each from a carefully art-directed brief specifying the lighting, lens character, palette, composition and mood — repeating the same Rembrandt lighting recipe across the three portraits is what makes strangers look like partners in the same firm. Every brief was iterated until the frame matched the engraved-letterhead register of the brand. Outputs were compressed to JPEG q80–85 at delivery size with ffmpeg.

The full prompt library behind these plates is proprietary to our studio — available to clients on request.

6 · Guardrails

7 · Deploying to Netlify

The site is a static folder — nothing to build.

  1. Drag-and-drop: go to app.netlify.com/drop and drop the ashford-vale/ folder. Done — you get a URL immediately.
  2. CLI:
    npm i -g netlify-cli
    cd ashford-vale
    netlify deploy --prod --dir .
  3. Git-connected: push the folder to a repo, “Import from Git” in Netlify, set the publish directory to ashford-vale, leave the build command empty.

Optional hardening: add a _headers file with Cache-Control: public, max-age=31536000, immutable for /assets/*.