It started with a blank editor and a blinking cursor.
It started with a blank editor and a blinking cursor. My first pages worked. They also just sat there. So I started asking what a page could do if it moved. Every experiment was a still frame. Put enough of them together and they start to move.
move
Before anything moves, it has to make sense.
It starts with tokens: colour, type, space and grid.
Colour
ink
bone
crimson
marigold
cobalt
mint
Type
Aa72 display
Aa48 heading
Aa32 title
Aa18 body
Aa12 mono
Space
4
8
16
24
32
56
Grid
12 columns
gutter 24
baseline 8
radius 0
Tokens snap into components.
The same few values, combined on purpose, every time.
crimson+Geist 500 16+space 28 / 56=
fill crimson
padding 28 / 56
See the worklabel Geist 500 16
focus 2 bone
surface ink-raised
media 16:10
lets-mattertitle 32
body 18
bar 64
prgynwordmark
WorkJourneyContactlinks 15
Components become layouts that hold at any size.
Same blocks, same tokens, a different system for every screen.
prgyn
Every canvas turns into art.
See the work
phone4 columns360
Layouts become a system you can build a whole site on.
One set of decisions, carried through every page.
Home
Work
Case study
Journey
Contact
TokensComponentsLayoutsSystem
import { useCallback, useImperativeHandle, useRef, useState, type Ref } from "react";import { gsap, ScrollTrigger, useGSAP } from "@/utils/gsap";import { HERO_COPY } from "@/constants";import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion";import { useProximityType } from "@/hooks/useProximityType";import InkButton from "@/components/InkButton";import InkTray from "./InkTray";import { MATH_ART } from "@/utils/hero/mathArt";import { ArtStage } from "./artStage";import { ScribbleBoard } from "./scribbleBoard";import { Typewriter } from "./typewriter";export type HeroHandle = { /** Where the preloader's caret should land. */ getCaretRect: () => DOMRect | null; /** Called by the preloader when it hands over: starts typing. */ start: () => void;};const GHOST_DELAY = 1.5;const QUOTE_HOLD = 4.2; // seconds a quote stays before the next one is typedconst DECODE = "abcdefghijklmnopqrstuvwxyz0123456789φθπ·=+";/** * Chapter "Blank": the page is a drawing surface. Visitors scribble in marker inks, each stroke snaps into a * wireframe element. The headline types one quote after another, and in every one the word "art" is a * button: click it and a ripple floods the hero with the next piece of mathematical art (the golden ratio, * phyllotaxis, a harmonograph, rose curves, a spirograph, a Maurer rose), each in its own palette, drawing * itself in and moving. */export default function Hero({ ref }: { ref?: Ref<HeroHandle> }) { const reduce = usePrefersReducedMotion(); const [hint, setHint] = useState("draw anything"); const rootRef = useRef<HTMLElement>(null); const svgRef = useRef<SVGSVGElement>(null); const shapesRef = useRef<SVGGElement>(null); const morphsRef = useRef<SVGGElement>(null); const strokesRef = useRef<SVGGElement>(null); const gridRef = useRef<HTMLDivElement>(null); const nibRef = useRef<HTMLDivElement>(null); const copyRef = useRef<HTMLDivElement>(null); const headRef = useRef<HTMLHeadingElement>(null); const typeRootRef = useRef<HTMLSpanElement>(null); const subRef = useRef<HTMLParagraphElement>(null); const ctaRef = useRef<HTMLDivElement>(null); const artRef = useRef<HTMLDivElement>(null); const capRef = useRef<HTMLParagraphElement>(null); const boardRef = useRef<ScribbleBoard | null>(null); const writerRef = useRef<Typewriter | null>(null); const beginRef = useRef<(() => void) | null>(null); const startRequested = useRef(false); useProximityType(headRef, { enabled: !reduce }); useImperativeHandle(ref, () => ({ getCaretRect: () => writerRef.current?.caret.getBoundingClientRect() ?? null, start: () => { startRequested.current = true; beginRef.current?.(); }, })); useGSAP( (_ctx, contextSafe) => { const root = rootRef.current; const head = headRef.current; const tray = root?.querySelector<HTMLElement>("[data-hero-tray]"); if (!root || !head || !tray || !contextSafe) return; tl.add(() => { inkStore.set(gp.ink); this.ghostStroke = this.startStroke(INKS[gp.ink].hex); fed = 0; progress.i = 0; }); tl.to(progress, { i: gp.pts.length - 1, duration: gp.dur, ease: "power1.inOut", onUpdate: () => { const stroke = this.ghostStroke; if (!stroke) return; const upto = Math.floor(progress.i); while (fed <= upto) { const q = gp.pts[fed]; addPoint(stroke, q.x, q.y, fed * 16); // synthetic time: one point per frame fed++; } const tip = gp.pts[Math.min(upto, gp.pts.length - 1)]; gsap.set(nib, { x: tip.x - 3, y: tip.y - 20 }); this.renderStroke(stroke); }, }); tl.add(() => { const stroke = this.ghostStroke; this.ghostStroke = null; if (stroke) this.finishStroke(stroke, gp.kind, true); }); }); tl.to(nib, { opacity: 0, duration: 0.3 }, "+=0.2"); tl.add(() => { inkStore.set(INK_INDEX.crimson); this.ghostTl = null; }); } /** Reduced motion: the same three demo shapes, placed instantly. */ placeStaticDemo() { for (const gp of this.ghostPaths()) { const box = snapBox(gp.kind, gp.pts, this.columns(), this.els.root.clientHeight); this.shapes.push(this.makeShape(box, INKS[gp.ink].hex, true)); } this.lastInk = INKS[INK_INDEX.cobalt].hex; } /** Stops the demo; `dropShapes` also fades out the ghost's shapes. */ stopGhost(dropShapes: boolean) { this.ghostTl?.kill(); this.ghostTl = null;
import { useCallback, useImperativeHandle, useRef, useState, type Ref } from "react";import { gsap, ScrollTrigger, useGSAP } from "@/utils/gsap";import { HERO_COPY } from "@/constants";import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion";import { useProximityType } from "@/hooks/useProximityType";import InkButton from "@/components/InkButton";import InkTray from "./InkTray";import { MATH_ART } from "@/utils/hero/mathArt";import { ArtStage } from "./artStage";import { ScribbleBoard } from "./scribbleBoard";import { Typewriter } from "./typewriter";export type HeroHandle = { /** Where the preloader's caret should land. */ getCaretRect: () => DOMRect | null; /** Called by the preloader when it hands over: starts typing. */ start: () => void;};const GHOST_DELAY = 1.5;const QUOTE_HOLD = 4.2; // seconds a quote stays before the next one is typedconst DECODE = "abcdefghijklmnopqrstuvwxyz0123456789φθπ·=+";/** * Chapter "Blank": the page is a drawing surface. Visitors scribble in marker inks, each stroke snaps into a * wireframe element. The headline types one quote after another, and in every one the word "art" is a * button: click it and a ripple floods the hero with the next piece of mathematical art (the golden ratio, * phyllotaxis, a harmonograph, rose curves, a spirograph, a Maurer rose), each in its own palette, drawing * itself in and moving. */export default function Hero({ ref }: { ref?: Ref<HeroHandle> }) { const reduce = usePrefersReducedMotion(); const [hint, setHint] = useState("draw anything"); const rootRef = useRef<HTMLElement>(null); const svgRef = useRef<SVGSVGElement>(null); const shapesRef = useRef<SVGGElement>(null); const morphsRef = useRef<SVGGElement>(null); const strokesRef = useRef<SVGGElement>(null); const gridRef = useRef<HTMLDivElement>(null); const nibRef = useRef<HTMLDivElement>(null); const copyRef = useRef<HTMLDivElement>(null); const headRef = useRef<HTMLHeadingElement>(null); const typeRootRef = useRef<HTMLSpanElement>(null); const subRef = useRef<HTMLParagraphElement>(null); const ctaRef = useRef<HTMLDivElement>(null); const artRef = useRef<HTMLDivElement>(null); const capRef = useRef<HTMLParagraphElement>(null); const boardRef = useRef<ScribbleBoard | null>(null); const writerRef = useRef<Typewriter | null>(null); const beginRef = useRef<(() => void) | null>(null); const startRequested = useRef(false); useProximityType(headRef, { enabled: !reduce }); useImperativeHandle(ref, () => ({ getCaretRect: () => writerRef.current?.caret.getBoundingClientRect() ?? null, start: () => { startRequested.current = true; beginRef.current?.(); }, })); useGSAP( (_ctx, contextSafe) => { const root = rootRef.current; const head = headRef.current; const tray = root?.querySelector<HTMLElement>("[data-hero-tray]"); if (!root || !head || !tray || !contextSafe) return; tl.add(() => { inkStore.set(gp.ink); this.ghostStroke = this.startStroke(INKS[gp.ink].hex); fed = 0; progress.i = 0; }); tl.to(progress, { i: gp.pts.length - 1, duration: gp.dur, ease: "power1.inOut", onUpdate: () => { const stroke = this.ghostStroke; if (!stroke) return; const upto = Math.floor(progress.i); while (fed <= upto) { const q = gp.pts[fed]; addPoint(stroke, q.x, q.y, fed * 16); // synthetic time: one point per frame fed++; } const tip = gp.pts[Math.min(upto, gp.pts.length - 1)]; gsap.set(nib, { x: tip.x - 3, y: tip.y - 20 }); this.renderStroke(stroke); }, }); tl.add(() => { const stroke = this.ghostStroke; this.ghostStroke = null; if (stroke) this.finishStroke(stroke, gp.kind, true); }); }); tl.to(nib, { opacity: 0, duration: 0.3 }, "+=0.2"); tl.add(() => { inkStore.set(INK_INDEX.crimson); this.ghostTl = null; }); } /** Reduced motion: the same three demo shapes, placed instantly. */ placeStaticDemo() { for (const gp of this.ghostPaths()) { const box = snapBox(gp.kind, gp.pts, this.columns(), this.els.root.clientHeight); this.shapes.push(this.makeShape(box, INKS[gp.ink].hex, true)); } this.lastInk = INKS[INK_INDEX.cobalt].hex; } /** Stops the demo; `dropShapes` also fades out the ghost's shapes. */ stopGhost(dropShapes: boolean) { this.ghostTl?.kill(); this.ghostTl = null;
Late nights, React, Next.js.And a lot of broken builds.Then things started to ship.
prgyn
Seethework
lets-matter
Unhandled Runtime Error1 of 1
TypeError: Cannot read properties of undefined (reading 'x')
41 gsap.to(title, {
42 y: title.offset.x,
43 duration: 1.2,
at Hero (src/components/hero/Hero.tsx:42:18)
- y: title.offset.x,
+ y: -24,
terminal23:14
$ next build
✓ Compiled successfully
$ next build
✗ Failed to compile
TypeError: reading 'x' of undefined
$ next build
✓ Compiled successfully
$ npm run deploy
✓ Live at prgyn.dev
deployprgyn.dev
Your turn. Change the code, watch it move.
Drag a number sideways, or click a word to change it. Arrow keys work too.
motion.ts
gsap.to(".prgyn span", {
y: -40,
rotate: 8,
wght: 800,
wdth: 100,
duration: 0.90,
ease: ,
stagger: 0.08,
color: ,
repeat: -1, yoyo: true,
});
prgyn
elastic.out(1, 0.35)
Then it moved.
The tools I build with, and what they can do. Throw them around, or click one to see it work.
ScrubGSAP ScrollTrigger
scrub
0%
A timeline you can hold in your hand. move across it
Physicsmatter-js
Weight, friction, collision. drag, throw, click to add
The Climate Question. A forum on how Nepal prepares for a climate future it is already living, from the Himalaya to the Terai.
Built with
Next.js, GSAP, matter-js
First edition
Birtamode, Jhapa · 23 Asoj 2083
Case notes
The problem
Climate change is already being written into Nepal's mountains, rivers, farms and cities, but scientists, policymakers, communities and young people rarely share one room.
What I built
The forum's site: one question told as a scroll story, four conversations from The Climate Reality to Hridayamsha, a room of 14 expert fields you can drag, and a journey map across seven cities.
Three hours of stream. The thirty seconds that matter. AI that watches the picture, the chat and the sound, on one clock.
Built with
Python, Whisper, ffmpeg, Next.js
For
Gaming, creators, live events, news
Case notes
The problem
Three hours of stream hide the thirty seconds that matter, and chat always reacts late.
What I built
cfos watches the picture, the sound and the chat on one clock and flags moments with a confidence score and the proof attached. You keep or skip; clipforge hands back 9:16 clips with captions and levelled audio, every file checked.
This page. A portfolio told as a story, from a blinking cursor to a living interface.
Built with
Next.js, GSAP, matter-js, Canvas
Chapters
Seven, each one touchable
Case notes
The problem
Most portfolios list the work. I wanted one that performs it.
What I built
Scribble recognition, a fuse-lit flipbook, a 3D workbench, a live compiler, a physics lab and this control room. Everything you scrolled through is the case study.