An interactive WebGL portfolio for Ritish Saini — backend engineer, Postgres, security. Live at ritishsaini.tech.
Instruments of resolution. Every project here takes something messy and untrusted and returns something you can rely on. So the site is one machined instrument, under one lighting rig, that reconfigures itself per project.
The usual failure of a 3D portfolio is a different toy per section and no world. This does the inverse — continuity of world, variety of event. The object never cuts between sections; it transforms.
| § | Project | Mechanism, and what scroll drives |
|---|---|---|
| 00 | — | sealed housing; eight wedges hinge open as 01 arrives inside them |
| 01 | carabiner | ratchet — one tooth per step, hot sparks. Scroll back and the pawl lifts: a ratchet is released, never reversed |
| 02 | firedrill | vessel — the struck teeth become rivets sealing a drum; a gauge needle searches, then only settles and lights once a restore has genuinely been read back |
| 03 | recur | sorting rotor — recurring charges lock into rings, noise is flung outward |
| 04 | LabLedger | spectrometer — bands snap to LOINC rows; one holds amber and escalates |
| 05 | QuizNest | tumbler — five notched rings; the beam passes only when all align |
| 06 | AI-Recipe-Maker | dosing manifold — five pistons fire in sequence into one output |
| 07 | Wizcommerce | core sample — five barrels converge and lie down into one column; a sonde runs its length and each band takes the beam as it is read |
| 08 | what I reach for | feeler gauge — the bands come off the column and swing out around one rivet. Seven leaves: one shape, ground seven ways |
| 09 | — | the housing seals; ends where it started |
npm install --prefix web
npm run dev --prefix web| Script | Does |
|---|---|
npm run build |
typecheck, bundle, SSR-prerender the markup into index.html |
npm run shots |
screenshot every section headlessly, report clipped text |
npm run fonts |
re-download the self-hosted woff2, regenerate src/fonts.css |
npm run og |
re-render og.html → public/og.png |
CI is gated by carabiner — this
repository is scanned by the scanner in it, SARIF is uploaded to code scanning,
and the drill re-runs weekly. Actions are pinned to commit SHAs with a
read-only default token. See SECURITY.md.
shots exists because dev preview surfaces blank out on scroll, and headless
Chrome without a GL backend silently falls back to the no-WebGL path — so you
end up auditing the wrong page. Pass a viewport: npm run shots -- 1440 900.
shots and og find Chrome per platform (web/scripts/chrome.mjs);
CHROME_PATH overrides.
Palette. Six tokens. The signal colour is light inside the scene, not decoration on the UI — which is what keeps this off the acid-accent-on-black pile.
| Token | Rule | |
|---|---|---|
--void |
#08090C |
ground |
--chalk |
#EDECE8 |
primary type |
--alloy |
#98A3B1 |
secondary type; the instrument's own value |
--beam |
#86E9DE |
measurement light and live values only — never a border or a fill |
--escalate |
#E8873B |
a human decides. Appears twice on the whole site |
--hair |
rgba(255,255,255,.07) |
every rule and panel edge. Never brighter |
Type. Archivo variable carries the identity through its width axis (62–125) — statements set wide, labels condensed. That is a typographic idea, not a font pairing. Instrument Serif italic is one gesture, reserved for human-judgment moments. JetBrains Mono for every number and identifier.
Contrast is bought with separation, not colour. White type is only 2.39:1 over bright metal, so no palette fixes copy sitting on the instrument. Desktop crops the object to one side; narrow screens lift it into the top quarter and put a ground scrim under the text. Every token clears WCAG AA against the ground on its own — nothing under 4.5:1 ships, however good it looks.
Materials. One alloy; a procedural studio of lightformers rather than an
HDRI. On machined metal the environment is the material, and shaped softboxes
give sharper, art-directable specular than a photographic probe, at zero bytes
and with no third-party origin. All geometry is generated in code — no .glb.
Pacing. Each section owns ~150vh; cramped scroll is the biggest amateur tell. Camera moves on eased curves only, and roughly 60% of every frame stays empty.
- Content is readable with the canvas dead.
prerender.jsbakes the markup intoindex.htmland the client hydrates it. The experience layer loads after idle and is skipped onsave-data,prefers-reduced-motion, or no WebGL. - A lost WebGL context can never take the page down —
lib/ExperienceBoundary.tsx. - The render loop stops when the tab is hidden — measured at 414 draw calls per 1.5s visible, 0 hidden. It never pauses before the first frame, or a page opened in a background tab would show an empty canvas when focused.
- No preloader, no autoplay audio, no third-party requests. The loading state is a technical drawing of the instrument, in the space it will occupy; it gates nothing and reports no fake progress.
- Everything is same-origin, so the CSP is
default-src 'none'with the rest'self'. Fonts are self-hosted, unmodified and deliberately un-subsetted — a dropped glyph is a worse failure than a few kB. - The canvas is
aria-hidden. It is purely decorative — the prerendered DOM above already carries every word — so a screen reader must not announce an unlabelled graphic instead of skipping straight to the content.
GitHub, LinkedIn, email and CV sit in the header, not only in section 09. The
rail already lets a keyboard or screen-reader user jump to "Contact" in one
step, but a visitor scanning the page visually has no reason to know that —
the four destinations most worth reaching (an evaluator's first move,
usually) were otherwise a nine-section scroll away. Same four links, same
order, as the closing section: landing at the top is not shown a different
set than the one waiting at the end. Icons only, each with its own
aria-label distinct from section 09's plain-text versions of the same
links — check-build.mjs already asserts every link's accessible name is
unique sitewide, which is what caught the first attempt reusing "GitHub" and
"LinkedIn" verbatim. 44×44px targets under 820px, matching the floor the
rest of the mobile layout already holds itself to.
React · React Three Fiber · drei · three.js · GSAP ScrollTrigger · Lenis ·
@react-three/postprocessing, built with Vite.
Netlify, not GitHub Pages: Pages cannot set response headers, which rules out a
real CSP, HSTS and frame-ancestors. netlify.toml sets base web/;
public/_headers carries the policy. Base directory must be web.
Measured on the live site — desktop 94 / 94 / 100 / 100, mobile 97 / 100 / 100 / 100, CLS 0, TBT 0 ms.
CI also runs Lighthouse against every build (.github/workflows/ci.yml,
lighthouse job) and keeps the report as a build artifact — informational,
not a gate. A GitHub-hosted runner has no GPU, so its score on this page's
WebGL-heavy hero is a different measurement from a real browser against the
live CDN; asserting the numbers above as a CI threshold would fail on the
runner's own limits, not a real regression. What the job does check is that
a report exists for every commit, rather than the numbers above being the
only measurement anyone can point to.
live is the full experience. off (save-data, or no WebGL) drops the canvas
entirely and the prerendered page stands alone. prefers-reduced-motion gets
still — the same instrument, lighting and composition, held in a chosen
pose per section and rendered once when the section changes. Reduced motion
should not mean a stripped page. Smooth scrolling is handed back to the OS,
the entrance and reveals are off, and sparks never fire. Verified: 0 pixels
change over 2.5s of idle.
The right-hand rail is a readout, not a menu — so it has to do the two things a readout does.
It says where you are. The active link takes --beam, the colour reserved
for live values, and holds the extended hairline that hover only borrows. It
also carries aria-current, which is the half that is not decoration: ten
links reading 01 carabiner … 09 Contact are, to a screen reader, ten
identical destinations with nothing saying which one you are standing in. The
marker is written straight to the DOM from the scroll handler for the same
reason S is not React state — re-rendering the tree to move one hairline
would put the whole page on the scroll path.
Clicking it travels. A bare #s4 is a native hash jump, and the scroll
position is the only input the scene has, so moving it in one step is a cut —
the one control on the site that broke the rule the rest of it is built around.
Rail and skip-link clicks go through lenis.scrollTo instead, eased on the same
power3.out used everywhere, over a duration that grows with the distance and
caps at 1.6s. Continuity is the point, not holding somebody hostage to their own
navigation.
Focus follows the jump, which preventDefault would otherwise take away — and
which the native jump never did either: it left focus on <body>, so the skip
link scrolled the page and then sent the next Tab back to the top. Under
prefers-reduced-motion none of this is wired at all; there is no Lenis to
route through, and the instant jump is the correct behaviour there rather than
a fallback.
Every boundary is a transformation, not a cross-fade. Each mechanism begins in the shape of the one before it:
- ratchet → vessel — the struck teeth fly inward and become the rivets sealing a drum shut
- vessel → rotor — the rivets let go again, back onto the exact ring they came from; the teeth become the transactions from there, same as always — the vessel borrows the ring for the length of its own section, and neither neighbour has to know it was there
- rotor → spectrometer — the cloud collapses onto one axis; that line is the beam the prism splits
- spectrometer → tumbler — the flat spectral rows curl into concentric rings
- tumbler → manifold — the rings extrude into the piston barrels
- manifold → core — the barrels converge on the axis and lie down; the collector's single output becomes the sixth band
- core → gauge — the bands come off the column and swing out around one rivet, drawn long and ground thin
- gauge → rack — the leaves fold shut; a dozen small modules grow out of that same origin point and fan into a shelf
- rack → gyroscope — the modules gather back onto the vertical axis as one line, and three gimbal rings unfold out of it, outermost first
- housing → ratchet — eight lathe wedges hinge open around it
One primitive carries three of those: the cylinder that is a manifold barrel is the same cylinder that is a core band, a gauge leaf, and then one of the rack's own modules at a fifth the scale — tall and narrow, short and wide, long and thin, small and plain.
Every project section has a second, quieter control under its main link:
"Look inside →" opens one real excerpt from that repository, unedited except
for dedenting a class method to its own margin -- never rewritten for the
panel, never trimmed without saying so (a comment # ... where something real
was cut) -- next to a small schematic of that system's actual shape. The
pitch is not enough to take on faith; this is the receipt for it.
The schematic is one primitive reused six times, the same discipline the 3D
side already holds itself to: a labelled box, a hairline connector, the last
box in --beam -- the palette's one colour for a live value, here standing
for the thing that comes out the other end. Six different systems read as one
visual grammar instead of six unrelated diagrams.
Pure <dialog>-shaped behaviour with none of the plumbing a library would
add: inert on <main> while it's open (native, no hand-rolled focus trap),
Escape and a backdrop click both close it, focus lands on the close control
and returns to whichever project's link opened it. No new dependency, no
animation library -- the panel appears and disappears in one frame, which
needed nothing to reconcile with prefers-reduced-motion because there was
never anything to reduce.
The six full project sections used to be a curated subset with nothing on
the page saying so -- an audit correctly flagged that as the single
biggest reason the site hadn't kept pace with a 17-repo portfolio.
Inventing twelve more bespoke instruments would have been months of the
same hand-built work every other mechanism here took (scene/geometry.ts
per shape, scene/Instrument.tsx's morphs hand-tuned per handover -- see
Handovers, above), and would very likely have diluted the six that
actually earn a close look rather than added to the site's case.
The shape that fits the honest version of that tradeoff: one more real
instrument -- Rack, section 09 -- built once for all twelve, not twelve
built separately. A dozen small modules, the same cylinder every other
mechanism already reuses at a fifth the scale, arrive on a shelf and are
read left to right exactly once, each linking straight to its own repo.
It costs one geometry and one handover, the same as any other section,
instead of twelve.
The closing section changed too: it used to reopen the exact housing the
page started in, the same shape playing twice. It's a real, different
final shape now -- Gyroscope, three nested gimbals and a flywheel. The
rings tumble loosely while the section is read, then settle into orthogonal
rest as the page bottom is reached, the flywheel spins up and the core
lights: "something that holds up", shown as the one mechanism whose whole
job is holding its orientation.
Knock it — a fast flick across its side of the screen, or a tap anywhere on the contact section that isn't a control — and it wobbles off true, the core dims, and it comes back to rest on its own.
Under it, "How this page works" lists every handover above; each one replays by jumping to just before that boundary and travelling through it slowly.
Off by default. Set UMAMI_WEBSITE_ID in Netlify (a free Umami Cloud site)
and the build adds the tracker. Both the script and its beacon are proxied
through /u/ (netlify.toml), so the browser still only talks to this
origin and the same-origin CSP is unchanged: no cookies, no third party.
ritishsaini.tech (DNS at Hostinger: A @ 75.2.60.5, CNAME www -> ritishsaini.netlify.app). SITE_URL in Netlify sets og:url, og:image,
robots.txt and sitemap.xml; web/site.config.js falls back to the same
origin. www and the old netlify.app address both 301 to it, the latter
via netlify.toml, so old links (the CV included) keep working.