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.
The brief: communicate precision and calm power — “the web equivalent of a beautifully set engraved letterhead.” Three decisions carry the whole design:
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.
.amp{font-style:italic;color:var(--oxblood)}. That one glyph is the logo..68rem with letter-spacing:.32em — at that size and tracking the effect reads as engraved small caps.clamp(3.1rem,10.5vw,8.2rem) with line-height:.98.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.
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.
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…")}
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.
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.
overflow-x:clip on both html and body — bleed elements and 112% line-reveal offsets can never cause a horizontal scrollbar.prefers-reduced-motion collapses every transform and transition; the page is fully legible with JavaScript disabled because all initial states except the rules are visible, and the rules are decorative.The site is a static folder — nothing to build.
app.netlify.com/drop and drop the ashford-vale/ folder. Done — you get a URL immediately.npm i -g netlify-cli
cd ashford-vale
netlify deploy --prod --dir .ashford-vale, leave the build command empty.Optional hardening: add a _headers file with Cache-Control: public, max-age=31536000, immutable for /assets/*.