Section
Work Index
A project index whose headings, rules and rows animate in on scroll through a shared data-attribute convention.
Work
Sites and products built end to end — design, motion, and the code underneath. Each one shipped, each one still standing.
Dream SoftPlay
2025
Bilingual storefront with a configurator front and centre — pick a layout, price it, ship it.
- Next.js
- Commerce
- i18n
Morni
2025
A scroll-driven food editorial built on GSAP timelines, where every dish arrives on cue.
- GSAP
- Editorial
- Motion
Orbit
2024
Investor-facing site carried by kinetic type and a dark UI that stays legible on a projector.
- Astro
- Type
- Dark UI
Sands & Souls
2024
Sneaker commerce with per-product motion states — each drop gets its own choreography.
- React
- Three.js
- Commerce
Kinematic Studio
2024
The studio index. Next.js and GSAP doing the heavy lifting behind a deceptively calm page.
- Next.js
- GSAP
- Framer
Kerna
2023
Desktop accounting dashboard, offline-first, built for people who close books on a plane.
- Electron
- Offline
- Data
import Work from "@/components/ui/work";
export default function WorkDemo() {
return (
<div className="w-full overflow-hidden rounded-card border border-line">
<Work />
</div>
);
}Installation
1. Install the dependencies.
terminal npm install gsap clsx tailwind-merge2. Add the
cnhelper, if you don't already have it.lib/utils.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into your project.
components/ui/work.tsx "use client"; import "@/styles/portfolio/tokens.css"; import "@/styles/portfolio/sections.css"; import { useRef, useState } from 'react'; import { useScrollFX } from '@/lib/use-scroll-fx'; const PROJECTS = [ { n: '01', title: 'Dream SoftPlay', img: '/images/work/laptop/dsp-1600.jpg', year: '2025', blurb: 'Bilingual storefront with a configurator front and centre — pick a layout, price it, ship it.', tags: ['Next.js', 'Commerce', 'i18n'], c: 'var(--color-purposium-blue)', }, { n: '02', title: 'Morni', img: '/images/work/laptop/morni-1600.jpg', year: '2025', blurb: 'A scroll-driven food editorial built on GSAP timelines, where every dish arrives on cue.', tags: ['GSAP', 'Editorial', 'Motion'], c: 'var(--color-orange)', }, { n: '03', title: 'Orbit', img: '/images/work/laptop/orbit-1600.jpg', year: '2024', blurb: 'Investor-facing site carried by kinetic type and a dark UI that stays legible on a projector.', tags: ['Astro', 'Type', 'Dark UI'], c: 'var(--color-deep-teal)', }, { n: '04', title: 'Sands & Souls', img: '/images/work/laptop/sandssouls-1600.jpg', year: '2024', blurb: 'Sneaker commerce with per-product motion states — each drop gets its own choreography.', tags: ['React', 'Three.js', 'Commerce'], c: 'var(--color-brick)', }, { n: '05', title: 'Kinematic Studio', img: '/images/work/laptop/kine-1600.jpg', year: '2024', blurb: 'The studio index. Next.js and GSAP doing the heavy lifting behind a deceptively calm page.', tags: ['Next.js', 'GSAP', 'Framer'], c: 'var(--color-yellow)', light: true, }, { n: '06', title: 'Kerna', img: '/images/work/laptop/kerna-1600.jpg', year: '2023', blurb: 'Desktop accounting dashboard, offline-first, built for people who close books on a plane.', tags: ['Electron', 'Offline', 'Data'], c: 'var(--color-navy-blue)', }, ]; const TOTAL = String(PROJECTS.length).padStart(2, '0'); export default function Work() { const ref = useRef<HTMLDivElement>(null); /* one open at a time, the first by default — hover, focus or tap to switch */ const [active, setActive] = useState(0); useScrollFX(ref); /* Deliberately mousemove, not mouseenter: opening a row reflows the stack, and the row that slides under a still pointer fires enter — which would hijack the selection and cascade. A reflow fires no move, so this only follows the pointer actually going somewhere. */ const follow = (i: number) => () => setActive(i); return ( <div id="work" ref={ref}> {/* the screen the hero's wipe dissolves onto */} <div className="work-inner"> <div className="work-intro"> <div className="sec-head" data-anim="fade"> <span> <b>02</b> // Selected Work </span> <span>Six of them</span> </div> <div className="sec-rule" data-anim="grow" /> <h2 className="sec-title work-title" data-anim="chars" data-stagger="0.03" data-hover="jitter"> Work </h2> <p className="sec-lede" data-anim="rise" data-delay="0.12"> Sites and products built end to end — design, motion, and the code underneath. Each one shipped, each one still standing. </p> <div className="work-count" data-anim="fade" data-delay="0.3"> <span>2023 — 2026</span> <i /> <span>Scroll</span> </div> </div> </div> {/* anchor target for nav links: #work's own top is the exact scroll position where the hero's wipe is at full cover, so jumping there would land on a black screen */} <div className="work-list" id="work-list"> <div className="work-stack" data-skew="0.4"> {PROJECTS.map((p, i) => ( <article key={p.title} className={`wrow${active === i ? ' is-open' : ''}${p.light ? ' wrow--light' : ''}`} style={{ ['--c' as string]: p.c }} data-anim={i % 2 ? 'right' : 'left'} data-delay={(i * 0.07).toFixed(2)} data-cursor="view" tabIndex={0} aria-label={`${p.title}, ${p.year}`} onMouseMove={follow(i)} onFocus={() => setActive(i)} onClick={() => setActive(i)} > <div className="wrow-body"> <div className="wrow-side wrow-side--l"> <span className="wrow-idx"> {p.n} / {TOTAL} </span> <h3>{p.title}</h3> <span className="wrow-year">{p.year}</span> </div> <div className="wrow-media"> <div className="wrow-poster" data-hover="tilt spotlight" data-strength="0.5"> {/* only fetched when a row is opened for the first time — six laptop shots eagerly loaded would cost more than the rest of the page put together */} <img className="wrow-shot" src={p.img} alt={`${p.title} — site`} loading="lazy" decoding="async" /> <div className="wrow-frame"> <span className="wrow-corner"> <em>jswnth</em> <em>{p.year}</em> </span> <span className="wrow-corner"> <em>N° {p.n}</em> <em>Par Avion</em> </span> </div> </div> {/* the collapsed strip: a sliver of the stamp with its name on it */} <div className="wrow-label"> <span>N° {p.n}</span> <strong>{p.title}</strong> <span className="wrow-label-y">{p.year}</span> </div> </div> <div className="wrow-side wrow-side--r"> <p>{p.blurb}</p> <ul className="wrow-tags"> {p.tags.map((t) => ( <li key={t} data-hover="magnet" data-strength="0.6"> {t} </li> ))} </ul> </div> </div> </article> ))} </div> </div> </div> ); }lib/use-scroll-fx.ts "use client"; import { useLayoutEffect, useRef, type RefObject } from 'react'; import { gsap } from 'gsap'; import { SplitText } from 'gsap/SplitText'; gsap.registerPlugin(SplitText); /* ── Scroll choreography for everything below the hero. ──────────────────── Two engines, deliberately kept apart: 1. One-shot entrances, driven by IntersectionObserver. Each element picks its move with data-anim; data-delay and data-stagger tune it. 2. Scroll-linked drift, driven by a rAF loop reading getBoundingClientRect. Neither uses ScrollTrigger, and that is on purpose. The hero pins itself across ~2.8 viewports and Work is pulled up over it by a negative margin, so the page geometry ScrollTrigger resolves its start/end against moves underneath it. A one-shot reveal whose start ends up unreachable strands its element at autoAlpha 0 — invisible for good. getBoundingClientRect is measured live against the viewport, so it cannot go stale the same way. Parallax writes the standalone CSS `translate` property rather than `transform`, so it composes with (instead of clobbering) the transform an entrance tween leaves behind on the same element. Same trick the resting card tilts use with `rotate`. */ type Anim = | 'rise' | 'fade' | 'left' | 'right' | 'post' | 'unfold' | 'mask' | 'grow' | 'words' | 'chars' | 'count' | 'draw' | 'stagger'; const num = (v: string | undefined, fallback: number) => { const n = Number.parseFloat(v ?? ''); return Number.isFinite(n) ? n : fallback; }; /* Splits "30+" into ["30", "+"] so a counter can run the digits and keep the suffix. Anything without a leading number (say "∞") gets no counter. */ const parseCount = (text: string) => { const m = text.trim().match(/^(-?[\d.,]+)(.*)$/); if (!m) return null; const value = Number.parseFloat(m[1].replace(/,/g, '')); return Number.isFinite(value) ? { value, suffix: m[2] ?? '' } : null; }; /* Builds the paused entrance for one element. Returning null means "nothing to animate" — the element is left exactly as authored, never hidden. */ function build(el: HTMLElement): gsap.core.Animation | null { const kind = (el.dataset.anim || 'rise') as Anim; const delay = num(el.dataset.delay, 0); const dur = num(el.dataset.dur, 0.9); const ease = el.dataset.ease || 'power3.out'; const common = { duration: dur, delay, ease, paused: true } as const; switch (kind) { case 'fade': return gsap.from(el, { ...common, autoAlpha: 0 }); case 'left': case 'right': return gsap.from(el, { ...common, autoAlpha: 0, x: kind === 'left' ? -64 : 64 }); /* arrives like something that came through the post: dropped in, slightly askew, settling flat */ case 'post': return gsap.from(el, { ...common, duration: dur * 1.15, autoAlpha: 0, y: 96, scale: 0.93, rotation: num(el.dataset.spin, 5), ease: 'expo.out', }); /* a sheet of paper hinged at the top edge, tipping down flat */ case 'unfold': return gsap.from(el, { ...common, duration: dur * 1.25, autoAlpha: 0, y: 40, rotateX: -26, transformPerspective: 1100, transformOrigin: 'top center', }); /* a wipe down the element with the inner image drifting back to rest, so the picture looks developed rather than faded in */ case 'mask': { const inner = el.querySelector<HTMLElement>('img, video, .mask-inner'); const tl = gsap.timeline({ paused: true, delay }); tl.fromTo( el, { clipPath: 'inset(0% 0% 100% 0%)' }, { clipPath: 'inset(0% 0% 0% 0%)', duration: dur * 1.3, ease: 'power4.inOut' }, ); if (inner) tl.from(inner, { scale: 1.22, duration: dur * 1.5, ease: 'power3.out' }, 0); return tl; } /* rules and underlines drawing themselves in from the left */ case 'grow': return gsap.from(el, { ...common, duration: dur * 1.1, scaleX: 0, transformOrigin: el.dataset.origin || 'left center', ease: 'power4.inOut', }); case 'words': case 'chars': { const split = new SplitText(el, { type: kind, wordsClass: 'fx-word', charsClass: 'fx-char', }); const parts = (kind === 'words' ? split.words : split.chars) as HTMLElement[]; if (!parts.length) { split.revert(); return gsap.from(el, { ...common, autoAlpha: 0, y: 30 }); } /* SplitText hands back inline-block parts, so a y offset can be clipped by an overflow:hidden parent for a proper typeset roll-up */ return gsap.from(parts, { ...common, duration: kind === 'chars' ? dur * 0.8 : dur, yPercent: 118, autoAlpha: 0, rotate: kind === 'chars' ? 4 : 2, stagger: num(el.dataset.stagger, kind === 'chars' ? 0.018 : 0.055), ease: 'power4.out', onComplete: () => split.revert(), }); } case 'count': { const parsed = parseCount(el.textContent || ''); if (!parsed) return gsap.from(el, { ...common, autoAlpha: 0, y: 24 }); const { value, suffix } = parsed; const decimals = (el.textContent || '').includes('.') ? 1 : 0; const box = { n: 0 }; return gsap.to(box, { n: value, duration: num(el.dataset.dur, 1.5), delay, ease: 'power2.out', paused: true, onUpdate: () => { el.textContent = box.n.toFixed(decimals) + suffix; }, }); } /* stroke-drawn SVG. pathLength="1" on the path normalises the dash maths so this works whatever the real geometry is. */ case 'draw': { const paths = Array.from(el.querySelectorAll<SVGGeometryElement>('path, line, polyline, circle')); if (!paths.length) return null; gsap.set(paths, { strokeDasharray: 1, strokeDashoffset: 1 }); return gsap.to(paths, { strokeDashoffset: 0, duration: num(el.dataset.dur, 1.4), delay, ease: 'power2.inOut', stagger: num(el.dataset.stagger, 0.12), paused: true, }); } case 'stagger': { const kids = Array.from(el.children) as HTMLElement[]; if (!kids.length) return null; return gsap.from(kids, { ...common, autoAlpha: 0, y: num(el.dataset.dy, 34), scale: 0.96, stagger: num(el.dataset.stagger, 0.07), }); } case 'rise': default: return gsap.from(el, { ...common, autoAlpha: 0, y: num(el.dataset.dy, 46) }); } } export function useScrollFX(ref: RefObject<HTMLElement | null>) { useLayoutEffect(() => { const root = ref.current; if (!root) return; if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; /* scoped to the root on purpose: gsap.context scopes selector strings in tweens, but not a document-wide query — a page-wide match here would give every section a duplicate tween for every other section's items */ const items = Array.from(root.querySelectorAll<HTMLElement>('[data-anim]')); const drifters = Array.from(root.querySelectorAll<HTMLElement>('[data-parallax]')); let io: IntersectionObserver | null = null; let exits: IntersectionObserver | null = null; let gate: IntersectionObserver | null = null; let atEnd: (() => void) | null = null; let raf = 0; const skewers = Array.from(root.querySelectorAll<HTMLElement>('[data-skew]')); const ctx = gsap.context(() => { const plays = new Map<Element, gsap.core.Animation>(); for (const el of items) { const anim = build(el); if (!anim) continue; /* from() has already written the start state, so the element is hidden the moment it exists — no flash of the finished layout */ anim.progress(0).pause(); plays.set(el, anim); } const pending = new Set(plays.keys()); const played = new Set<Element>(); const fire = (el: Element) => { if (!pending.delete(el)) return; io?.unobserve(el); plays.get(el)?.play(); played.add(el); exits?.observe(el); }; /* ── exits ───────────────────────────────────────────────────────── Entrances are one-shot, but nothing should simply sit there once it has scrolled off. After an element has played in, it is watched a second time: leaving the viewport drifts it out in the direction of travel, coming back eases it home. Only autoAlpha and y are touched, so whatever transform the entrance left (a rule's scaleX, a card's resting rotate) is kept. data-exit="none" opts out; "fade" skips the drift. */ exits = new IntersectionObserver( (entries) => { for (const e of entries) { const el = e.target as HTMLElement; if (!played.has(el)) continue; const kind = el.dataset.exit || 'drift'; if (kind === 'none') continue; if (e.isIntersecting) { gsap.to(el, { autoAlpha: 1, y: 0, duration: 0.85, ease: 'power3.out', overwrite: 'auto' }); } else { const above = e.boundingClientRect.top < 0; gsap.to(el, { autoAlpha: 0, y: kind === 'fade' ? 0 : above ? -56 : 56, duration: 0.55, ease: 'power2.in', overwrite: 'auto', }); } } }, { rootMargin: '-4% 0px -4% 0px' }, ); io = new IntersectionObserver( (entries) => { for (const e of entries) if (e.isIntersecting) fire(e.target); }, /* a touch inside the fold, so things aren't caught mid-move at the very bottom edge of the screen */ { rootMargin: '0px 0px -12% 0px' }, ); for (const el of pending) io.observe(el); /* That negative bottom margin carves out a dead strip at the foot of the viewport — and an element sitting in it when the page has already hit its last scroll position can never climb out, so it would stay at autoAlpha 0 for good. Once there's no scroll left to give, anything still on screen has waited long enough. */ atEnd = () => { if (!pending.size) return; const doc = document.documentElement; if (window.scrollY + window.innerHeight < doc.scrollHeight - 2) return; for (const el of [...pending]) { if (el.getBoundingClientRect().top < window.innerHeight) fire(el); } }; window.addEventListener('scroll', atEnd, { passive: true }); window.addEventListener('resize', atEnd); atEnd(); if (drifters.length || skewers.length) { const speeds = drifters.map((el) => num(el.dataset.parallax, 0.12)); const skewF = skewers.map((el) => num(el.dataset.skew, 0.3)); const skewTo = skewers.map((el) => gsap.quickSetter(el, 'skewY', 'deg')); let live = false; let lastY = window.scrollY; let vel = 0; const tick = () => { const vh = window.innerHeight; /* scroll velocity, eased so the lean decays once you stop */ const dy = window.scrollY - lastY; lastY = window.scrollY; vel += (Math.max(-40, Math.min(40, dy)) - vel) * 0.14; for (let i = 0; i < skewers.length; i += 1) { skewTo[i](Math.abs(vel) < 0.05 ? 0 : -vel * skewF[i] * 0.12); } for (let i = 0; i < drifters.length; i += 1) { const el = drifters[i]; const r = el.getBoundingClientRect(); if (r.bottom < -160 || r.top > vh + 160) continue; /* -1 below the fold, 0 dead centre, 1 above it */ const p = (r.top + r.height / 2 - vh / 2) / (vh / 2 + r.height / 2); el.style.translate = `0 ${(-p * speeds[i] * vh).toFixed(2)}px`; } raf = requestAnimationFrame(tick); }; /* the loop only spins while this section is anywhere near the screen */ gate = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting && !live) { live = true; raf = requestAnimationFrame(tick); } else if (!entry.isIntersecting && live) { live = false; cancelAnimationFrame(raf); } }, { rootMargin: '25% 0px 25% 0px' }, ); gate.observe(root); } }, root); return () => { io?.disconnect(); exits?.disconnect(); gate?.disconnect(); if (atEnd) { window.removeEventListener('scroll', atEnd); window.removeEventListener('resize', atEnd); } cancelAnimationFrame(raf); for (const el of drifters) el.style.translate = ''; ctx.revert(); }; }, [ref]); } /* ── Scroll-linked progress. ─────────────────────────────────────────────── Reports 0 → 1 as `el` travels from "its top at the viewport top" to "its bottom at the viewport bottom" — the same window framer-motion's useScroll takes by default, so a scrub written against that library ports over unchanged. Same rAF + getBoundingClientRect basis as the drift above, and for the same reason: the hero's pin moves the page geometry a scroll- position-based library would resolve against. Under reduced motion the scrub is resolved to its finished state once and the loop never starts — otherwise the content would sit forever in the scattered pose the animation exists to resolve. */ export function useScrollProgress(ref: RefObject<HTMLElement | null>, onProgress: (p: number) => void) { /* held in a ref so a caller passing an inline arrow doesn't tear the loop down and rebuild it on every render */ const cb = useRef(onProgress); cb.current = onProgress; useLayoutEffect(() => { const el = ref.current; if (!el) return; if (matchMedia('(prefers-reduced-motion: reduce)').matches) { cb.current(1); return; } let raf = 0; let live = false; let last = -1; const tick = () => { const r = el.getBoundingClientRect(); const span = r.height - window.innerHeight; const p = span <= 0 ? (r.top <= 0 ? 1 : 0) : Math.min(1, Math.max(0, -r.top / span)); if (Math.abs(p - last) > 0.0004) { last = p; cb.current(p); } raf = requestAnimationFrame(tick); }; const io = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting && !live) { live = true; raf = requestAnimationFrame(tick); } else if (!entry.isIntersecting && live) { live = false; cancelAnimationFrame(raf); } }, { rootMargin: '20% 0px 20% 0px' }, ); io.observe(el); /* settle the opening pose before first paint, so nothing flashes through its resolved position on the way in */ tick(); cancelAnimationFrame(raf); if (!live) raf = 0; return () => { io.disconnect(); cancelAnimationFrame(raf); }; }, [ref]); }styles/portfolio/sections.css /* ── The rest of the page, in the hero's postal language: perforated sheets, typewritten labels, ink postmarks, paper on a desk. Section grounds run light → pale blue → navy → black so the scroll reads as one journey. ─── */ /* ═══ shared primitives ══════════════════════════════════════════════════ */ .sec { position: relative; z-index: 2; padding: clamp(88px, 13vh, 150px) var(--pad); background: var(--bg); } .sec-wrap { max-width: 1180px; margin-inline: auto; } /* the running head lifted from the brand doc: "02 // Selected Work" */ .sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; font: 500 clamp(10px, 1vw, 11px) / 1 var(--mono); letter-spacing: 0.22em; text-transform: uppercase; color: var(--dim); } .sec-head b { color: var(--accent); font-weight: 700; } .sec-rule { height: 1px; margin: 14px 0 clamp(26px, 5vh, 54px); background: currentColor; opacity: 0.16; } .sec-title { margin: 0; font-family: var(--display); font-size: clamp(42px, 8.6vw, 122px); font-weight: 400; line-height: 0.9; letter-spacing: -0.03em; } .sec-lede { max-width: 46ch; margin: clamp(18px, 3vh, 30px) 0 0; font: 400 clamp(13px, 1.15vw, 15px) / 1.75 var(--mono); color: var(--dim); } /* dark grounds re-point the tokens so every primitive follows */ .sec--dark { --ink: var(--color-base-white); --dim: rgba(255, 255, 255, 0.55); --accent: var(--color-soft-blue); background: var(--color-navy-blue); color: var(--color-base-white); } /* a sheet of paper */ .paper { position: relative; background: var(--color-base-white); } .paper::after { content: ""; position: absolute; inset: 0; /* just enough tooth to read as stock — any heavier and white goes grey */ opacity: 0.14; mix-blend-mode: multiply; background-image: var(--paper-noise); background-size: 220px; pointer-events: none; } /* perforated stamp edge. A mask clips box-shadow, so shadows go on a wrapper. */ .perf { --s: 13px; --r: 5.6px; -webkit-mask: radial-gradient(var(--r), #0000 98%, #000) round calc(-1 * var(--s)) calc(-1 * var(--s)) / calc(2 * var(--s)) calc(2 * var(--s)), linear-gradient(#000 0 0) no-repeat 50% / calc(100% - 2 * var(--s)) calc(100% - 2 * var(--s)); mask: radial-gradient(var(--r), #0000 98%, #000) round calc(-1 * var(--s)) calc(-1 * var(--s)) / calc(2 * var(--s)) calc(2 * var(--s)), linear-gradient(#000 0 0) no-repeat 50% / calc(100% - 2 * var(--s)) calc(100% - 2 * var(--s)); } .drop { filter: drop-shadow(0 22px 30px rgba(27, 27, 57, 0.22)) drop-shadow(0 3px 6px rgba(27, 27, 57, 0.1)); } /* round ink postmark, reused wherever something needs cancelling */ .postmark { color: var(--accent); mix-blend-mode: multiply; pointer-events: none; } .postmark svg { display: block; width: 100%; height: auto; } .postmark text { font-family: var(--mono); font-weight: 700; letter-spacing: 0.16em; fill: currentColor; } /* ═══ 02 — WORK ══════════════════════════════════════════════════════════ */ #work { position: relative; /* opaque in its own right: it slides over the pinned hero, and a transparent section would let the hero show straight through */ background: var(--bg); padding: 0 0 clamp(80px, 12vh, 140px); } /* set by the hero's pin as it releases — see Hero.tsx */ #work.is-front { z-index: 2; } /* the screen the hero's wipe dissolves onto */ .work-inner { min-height: 100svh; display: flex; flex-direction: column; justify-content: center; padding: clamp(96px, 14vh, 150px) var(--pad) clamp(40px, 6vh, 70px); } .work-intro { max-width: 1180px; margin-inline: auto; width: 100%; } .work-intro .sec-title { font-size: clamp(64px, 15vw, 210px); } .work-count { display: flex; align-items: baseline; gap: 14px; margin-top: clamp(20px, 4vh, 38px); font: 500 11px / 1 var(--mono); letter-spacing: 0.22em; text-transform: uppercase; color: var(--dim); } .work-count i { flex: 1; height: 1px; background: currentColor; opacity: 0.24; } .work-list { /* The rows enter from alternating sides, so a row that has never been scrolled into view is parked one offset outside the page box — invisible, but still widening the document. Clip on the inline axis only: `clip` (unlike `hidden`) leaves the other axis visible without making this a scroll container, which sticky and the wipe hand-off both depend on. */ overflow-x: clip; max-width: 1180px; margin-inline: auto; padding-inline: var(--pad); } /* ── the stack: hover a strip and it swells, info flanking the stamp ───── Same interaction as the reference accordion, done with React state plus CSS transitions — a height/opacity tween doesn't need a motion library. */ .work-stack { display: flex; flex-direction: column; gap: 10px; } .wrow { position: relative; height: 3rem; cursor: pointer; transition: height 0.55s cubic-bezier(0.4, 0, 0.2, 1); } .wrow.is-open { height: clamp(15rem, 25vw, 21rem); } .wrow:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; } .wrow-body { display: flex; align-items: stretch; height: 100%; } /* the flanking copy: zero-width and faded until the row opens */ .wrow-side { flex: 0 0 0%; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 10px; overflow: hidden; opacity: 0; transition: flex-basis 0.55s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease, padding 0.55s cubic-bezier(0.4, 0, 0.2, 1); } .wrow.is-open .wrow-side { flex-basis: 28%; opacity: 1; /* let the width open first, then bring the words in */ transition-delay: 0s, 0.16s, 0s; } .wrow-side--l { align-items: flex-end; text-align: right; } .wrow.is-open .wrow-side--l { padding-right: clamp(14px, 1.8vw, 30px); } .wrow-side--r { align-items: flex-start; } .wrow.is-open .wrow-side--r { padding-left: clamp(14px, 1.8vw, 30px); } .wrow-idx { font: 700 clamp(10px, 1vw, 11px) / 1 var(--mono); letter-spacing: 0.24em; color: var(--accent); white-space: nowrap; } .wrow-side h3 { margin: 0; font-family: var(--display); font-size: clamp(24px, 3.2vw, 46px); font-weight: 400; line-height: 0.98; letter-spacing: -0.025em; } .wrow-year { font: 500 clamp(10px, 1vw, 11px) / 1 var(--mono); letter-spacing: 0.2em; color: var(--dim); } .wrow-side p { margin: 0; max-width: 34ch; font: 400 clamp(12px, 1.05vw, 14px) / 1.7 var(--mono); color: var(--dim); } .wrow-tags { display: flex; flex-wrap: wrap; gap: 7px; margin: 0; padding: 0; list-style: none; } .wrow-tags li { padding: 5px 11px; border: 1px solid rgba(27, 27, 57, 0.28); border-radius: 999px; font: 500 10px / 1 var(--mono); letter-spacing: 0.12em; text-transform: uppercase; color: var(--dim); white-space: nowrap; } /* the stamp itself — full width when shut, centre column when open */ .wrow-media { position: relative; flex: 1 1 auto; min-width: 0; } .wrow-poster { --s: 8px; --r: 3.4px; height: 100%; background: var(--c, var(--accent)); -webkit-mask: radial-gradient(var(--r), #0000 98%, #000) round calc(-1 * var(--s)) calc(-1 * var(--s)) / calc(2 * var(--s)) calc(2 * var(--s)), linear-gradient(#000 0 0) no-repeat 50% / calc(100% - 2 * var(--s)) calc(100% - 2 * var(--s)); mask: radial-gradient(var(--r), #0000 98%, #000) round calc(-1 * var(--s)) calc(-1 * var(--s)) / calc(2 * var(--s)) calc(2 * var(--s)), linear-gradient(#000 0 0) no-repeat 50% / calc(100% - 2 * var(--s)) calc(100% - 2 * var(--s)); } .wrow-frame { position: absolute; inset: clamp(9px, 1.1vw, 16px); /* the two corner rails push to the edges on their own — a 3-row grid only held them apart while the glyph filled the middle row */ display: flex; flex-direction: column; justify-content: space-between; padding: clamp(8px, 1vw, 15px); border: 1.5px solid rgba(255, 255, 255, 0.5); color: #fff; opacity: 0; transition: opacity 0.3s ease; } .wrow.is-open .wrow-frame { opacity: 1; transition-delay: 0.18s; } .wrow-corner { display: flex; justify-content: space-between; font: 700 clamp(8px, 0.8vw, 10px) / 1 var(--mono); letter-spacing: 0.2em; text-transform: uppercase; } .wrow-corner em { font-style: normal; } /* the shot fills the stamp once the row opens; the tint underneath is what shows on the shut strip, so it stays visible behind a still-loading image */ .wrow-shot { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity 0.4s ease; } .wrow.is-open .wrow-shot { opacity: 1; transition-delay: 0.12s; } /* a scrim under the frame's corner type — over a photograph the plain white labels would sit on whatever happens to be behind them */ .wrow-poster::after { content: ""; position: absolute; inset: 0; opacity: 0; background: linear-gradient(180deg, rgba(10, 10, 26, 0.42) 0%, rgba(10, 10, 26, 0.06) 34%, rgba(10, 10, 26, 0.06) 66%, rgba(10, 10, 26, 0.46) 100%); transition: opacity 0.4s ease; pointer-events: none; } .wrow.is-open .wrow-poster::after { opacity: 1; transition-delay: 0.12s; } .wrow-frame { z-index: 1; } /* name plate on the shut strip */ .wrow-label { position: absolute; inset: 0; display: flex; align-items: center; gap: clamp(10px, 1.6vw, 22px); padding: 0 clamp(16px, 2vw, 28px); font: 500 clamp(9px, 0.95vw, 11px) / 1 var(--mono); letter-spacing: 0.2em; text-transform: uppercase; color: rgba(255, 255, 255, 0.78); transition: opacity 0.25s ease; } .wrow-label strong { flex: 1; font-family: var(--display); font-size: clamp(15px, 1.7vw, 22px); font-weight: 400; letter-spacing: -0.01em; text-transform: none; color: #fff; } .wrow-label-y { color: rgba(255, 255, 255, 0.66); } .wrow--light .wrow-label { color: rgba(27, 27, 57, 0.7); } .wrow--light .wrow-label strong, .wrow--light .wrow-label-y { color: var(--color-navy-blue); } .wrow.is-open .wrow-label { opacity: 0; } /* ═══ 03 — ABOUT ═════════════════════════════════════════════════════════ */ #about { background: var(--color-pale-blue); } .about-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); gap: clamp(28px, 5vw, 72px); align-items: start; } /* a typewritten letter on lined paper */ .letter { rotate: -0.7deg; } .letter-paper { padding: clamp(26px, 3.4vw, 46px) clamp(24px, 3vw, 44px) clamp(30px, 4vw, 52px) clamp(38px, 4.6vw, 66px); background: var(--color-base-white) repeating-linear-gradient(transparent 0 31px, rgba(27, 27, 57, 0.09) 31px 32px); background-position: 0 clamp(26px, 3.4vw, 46px); font: 400 clamp(13px, 1.15vw, 15px) / 32px var(--mono); color: var(--ink); } /* red margin rule, like a legal pad */ .letter-paper::before { content: ""; position: absolute; inset-block: 0; left: clamp(26px, 3.2vw, 46px); width: 1px; background: rgba(255, 109, 71, 0.5); } .letter-paper h3 { margin: 0 0 32px; font: 700 clamp(15px, 1.5vw, 19px) / 32px var(--mono); letter-spacing: 0.02em; } .letter-paper p { margin: 0 0 32px; } .letter-paper p:last-of-type { margin-bottom: 0; } .letter-paper mark { background: linear-gradient(transparent 62%, var(--color-yellow) 62% 92%, transparent 92%); color: inherit; } .letter-sign { margin-top: 32px; font-family: var(--display); font-size: clamp(26px, 3.4vw, 42px); line-height: 1; rotate: -3deg; color: var(--accent); } .about-side { display: flex; flex-direction: column; gap: clamp(20px, 3vh, 34px); } .about-polaroid { align-self: center; width: min(100%, 300px); rotate: 3deg; } .about-polaroid figure { margin: 0; padding: 11px 11px 15px; background: var(--color-base-white); } .about-polaroid img { display: block; width: 100%; aspect-ratio: 1 / 0.86; object-fit: cover; filter: saturate(0.9) contrast(1.04) sepia(0.06); } .about-polaroid figcaption { margin-top: 12px; display: flex; justify-content: space-between; font: 500 10px / 1 var(--mono); letter-spacing: 0.14em; text-transform: uppercase; color: var(--dim); } .about-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 0; padding: 0; } .about-stats div { padding: clamp(14px, 1.6vw, 20px) 10px; text-align: center; background: var(--color-base-white); border: 1px solid rgba(27, 27, 57, 0.1); } .about-stats dt { font-family: var(--display); font-size: clamp(26px, 3.2vw, 42px); line-height: 1; color: var(--accent); } .about-stats dd { margin: 8px 0 0; font: 500 9.5px / 1.4 var(--mono); letter-spacing: 0.16em; text-transform: uppercase; color: var(--dim); } /* ═══ 05 — TESTIMONIALS ══════════════════════════════════════════════════ */ .notes { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: clamp(20px, 3vw, 34px); } .note-card { rotate: var(--rot, 0deg); transition: rotate 0.45s ease, translate 0.45s ease; } .note-card:hover { rotate: 0deg; translate: 0 -8px; } .note-paper { display: flex; flex-direction: column; gap: 20px; height: 100%; padding: clamp(24px, 2.6vw, 34px); color: var(--color-navy-blue); } .note-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; } .note-quotemark { font-family: var(--display); font-size: 54px; line-height: 0.6; color: var(--color-purposium-blue); } /* the little franked stamp in the corner of each card */ .note-stamp { flex: 0 0 auto; width: 46px; aspect-ratio: 0.82; display: grid; place-items: center; background: var(--color-pale-blue); border: 1.4px solid var(--color-purposium-blue); font: 700 8px / 1.3 var(--mono); letter-spacing: 0.08em; text-align: center; color: var(--color-purposium-blue); rotate: 4deg; } .note-paper blockquote { flex: 1; margin: 0; font-family: var(--display); font-size: clamp(19px, 1.9vw, 25px); line-height: 1.28; letter-spacing: -0.015em; } .note-by { padding-top: 16px; border-top: 1px dashed rgba(27, 27, 57, 0.28); font: 500 10.5px / 1.7 var(--mono); letter-spacing: 0.16em; text-transform: uppercase; color: #6b6b76; } .note-by strong { display: block; font-weight: 700; color: var(--color-navy-blue); } /* ═══ 05 — FOOTER ════════════════════════════════════════════════════════ */ #colophon { --ink: var(--color-base-white); --dim: rgba(255, 255, 255, 0.5); --accent: var(--color-soft-blue); background: var(--cta); color: var(--color-base-white); padding-bottom: clamp(28px, 5vh, 46px); } .foot-cta { display: block; margin: clamp(10px, 2vh, 20px) 0 clamp(46px, 8vh, 90px); font-family: var(--display); font-size: clamp(40px, 9.6vw, 148px); line-height: 0.88; letter-spacing: -0.035em; color: var(--color-base-white); text-decoration: none; } .foot-cta span { display: inline-block; transition: translate 0.4s cubic-bezier(0.34, 1.4, 0.64, 1), color 0.3s ease; } .foot-cta:hover span { translate: 0 -6px; color: var(--color-soft-blue); } .foot-cta small { display: block; margin-top: clamp(14px, 2vh, 22px); font: 500 11px / 1 var(--mono); letter-spacing: 0.24em; text-transform: uppercase; color: var(--dim); } .foot-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: clamp(26px, 4vw, 54px); align-items: start; } .foot-col h4 { margin: 0 0 16px; font: 700 10px / 1 var(--mono); letter-spacing: 0.24em; text-transform: uppercase; color: var(--accent); } .foot-col ul { margin: 0; padding: 0; list-style: none; } .foot-col li + li { margin-top: 10px; } .foot-col a, .foot-col address { font: 400 13px / 1.75 var(--mono); font-style: normal; color: rgba(255, 255, 255, 0.78); text-decoration: none; } .foot-col a { display: inline-flex; align-items: center; gap: 9px; transition: color 0.25s ease; } .foot-col a::before { content: ""; width: 14px; height: 1px; background: currentColor; opacity: 0.5; transition: width 0.25s ease; } .foot-col a:hover { color: #fff; } .foot-col a:hover::before { width: 26px; } /* the address panel, printed like a mailing label */ .foot-label { padding: clamp(16px, 2vw, 22px); background: rgba(255, 255, 255, 0.06); border: 1px dashed rgba(255, 255, 255, 0.28); } .foot-label strong { display: block; margin-bottom: 8px; font: 700 10px / 1 var(--mono); letter-spacing: 0.24em; text-transform: uppercase; color: var(--accent); } .foot-bottom { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; margin-top: clamp(44px, 7vh, 80px); padding-top: 20px; border-top: 1px solid rgba(255, 255, 255, 0.16); font: 400 10.5px / 1.6 var(--mono); letter-spacing: 0.16em; text-transform: uppercase; color: var(--dim); } /* ═══ responsive ═════════════════════════════════════════════════════════ */ @media (max-width: 860px) { .about-grid { grid-template-columns: 1fr; } /* no room to flank the stamp — stack the copy under it instead */ .wrow.is-open { height: 26rem; } .wrow-body { flex-direction: column; } .wrow-media { flex: 1 1 auto; width: 100%; min-height: 0; } .wrow-side { flex: 0 0 auto; width: 100%; height: 0; padding: 0; align-items: flex-start; text-align: left; gap: 8px; } .wrow.is-open .wrow-side { flex-basis: auto; height: auto; } .wrow.is-open .wrow-side--l { padding: 0 0 10px; } .wrow.is-open .wrow-side--r { padding: 10px 0 0; } .wrow-side p { max-width: none; } .about-polaroid { width: min(100%, 260px); } } @media (prefers-reduced-motion: reduce) { .wrow, .wrow-side, .wrow-frame, .wrow-label, .note-card, .foot-cta span { transition: none; } } /* ═══ scroll choreography hooks ══════════════════════════════════════════ */ /* The section titles roll their characters up out of nothing, so the line box has to clip whatever is still sitting below the baseline. */ .work-title, .about-title, .notes-title { overflow: hidden; padding-bottom: 0.08em; } /* stats are counted up, so the digits must not reflow the row as they widen */ .about-stats dt { font-variant-numeric: tabular-nums; }styles/portfolio/tokens.css /* Design tokens from the jswnth.com portfolio. Global element rules from its theme.css are deliberately left out so nothing leaks into a host page. */ :root { /* primary palette */ --color-navy-blue: #1b1b39; --color-purposium-blue: #3965fa; --color-soft-blue: #99b7fc; --color-pale-blue: #e9ebff; --color-base-white: #ffffff; --color-light-gray: #ededed; --color-dark-gray: #202020; /* secondary palette */ --color-deep-teal: #215452; --color-green: #8cc63e; --color-pale-green: #e0ffb7; --color-yellow: #ffd16b; --color-pale-yellow: #fff2d6; --color-orange: #ff6d47; --color-brick: #6f3432; --bg: #f4f4f6; --ink: var(--color-navy-blue); --dim: #6b6b76; --accent: var(--color-purposium-blue); /* buttons + the hero's scroll wipe share this one */ --cta: #000000; --pad: clamp(20px, 4vw, 56px); --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; --display: "Caacupe One", system-ui, sans-serif; /* printed-paper tooth, reused by every sheet on the page */ --paper-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='260'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
Props
| Prop | Type | Default | Description |
|---|