- 3,200 particles, 30 kBThe night sky is raw WebGL, written by hand — the drifting background sheets included. The original needs Three.js, React Three Fiber and a post-processing pass: 3.6 MB of JavaScript.
- 19 animations measuredRead straight out of the original's bundle — durations, curves, offsets, thresholds — then rebuilt without GSAP, without ScrollTrigger, without Lenis.
- 0 external requestsNo dependency, no CDN, no build step. Open the file and it works.
What it is
The portfolio of a creative developer, in three pages. The home page: a full-height hero, a statement, three projects with two sticky rails, a pinned scene where nine services scroll past in three panels, a four-step process, an oversized closing call, a footer. The about page: the story, the tools in a full negative section, the experience. The work page: the three projects as a full-screen carousel. Behind all three, the same field of particles, reshaping itself section by section.
The design follows that of a working design engineer's site, of which nothing has been kept — neither the images nor the code. The original is a Next.js app carrying Three.js, React Three Fiber, GSAP with ScrollTrigger and SplitText, Lenis and opentype.js: 3.6 MB of JavaScript for the home page alone. Here the same page runs on 42 kB.
That's the whole point: this template isn't here to be admired, it's here to be read, broken and rebuilt.
What you change first
The colours — four lines
Right at the top of assets/css/style.css:
--night: #070b1a; /* the background, and the glow rising from the bottom */
--halo: #243c7e; /* the light behind the page */
--spark: #4a5a9e; /* the particles */
--ink: #f4f6fa; /* the text */
One thing to grasp before touching anything else: the particle colour exists twice. A shader cannot read a CSS variable, so --spark is repeated in field.js as three numbers between 0 and 1. Change one, change both.
The name
Search for “Noray” in index.html. No CSS class carries the brand name, so a find-and-replace can't break anything. One extra place: sampleGlyph('N') in field.js — that single letter is the shape the footer particles gather into.
The sizes
The type scale is declared once, then redeclared at every breakpoint. So a heading never gets its own media query: you change --h2 and the whole page follows.
The 19 effects, and how they’re built
The original doesn't hide its settings: they sit in its bundle as named constants and plain GSAP calls. Everything below comes from there, then measured in the browser — never guessed by eye.
| Effect | Where | How |
|---|---|---|
| Lines rising into place | 26 blocks of text | the text is split into real lines by measuring, each one masked; yPercent 320 → 0 with a 10° tilt, 0.4 s, 0.07 s apart |
| Text that prints itself | 4 headings | split into characters, opacity 0.1 → 1 driven by scroll; JS writes one progress value, clamp() does the rest |
| Steps that brighten | the process | opacity follows the distance from the middle of the screen — the step in the centre is the one you can read |
| Pinned scene | services | same mechanic, but three screens instead of six: one screen of scrolling per panel. At six the section takes twice as long as it should to go past |
| Live covers | work, work page | each cover loads the real neighbouring site in a frame as the card comes into view — rendered at 1440 wide then scaled, so it serves its desktop layout. The still image stays underneath as fallback and placeholder |
| Twin rails | selected work | the project nearest the middle of the screen names itself on the left and offers its link on the right |
| Particle field | the whole page | 3,200 points in raw WebGL; shapes computed as superquadrics, not modelled — a star, a cut diamond, a flame, a letter |
| Field stepping back | reading sections | not in the original: over a section marked data-field-quiet the canvas drops to 30% opacity. A dense cloud between the eye and a paragraph looks good for a second and is unreadable after that |
| Star field | the whole page | 45% of the points sit on a far sheet drawn in its own pass: it does not turn with the shape, or half the stars would swing behind the camera twice per turn |
| Drifting background | the whole page | a full-screen rectangle, two octaves of fractal noise sliding between --night and --halo; scrolling down speeds it up, scrolling back slows it |
| Letter made of particles | footer | the glyph is drawn once into a 2D canvas and its pixels read back: 30 lines instead of a 90 kB font library |
| Six turns of scroll | the whole page | the cloud rotates −2160° from top to bottom, and keeps turning for a moment after the page stops |
| Weight under the pointer | logo, menu, footer links | Red Hat Display is a variable font: the wght axis follows the cursor, 400 → 900 within 80 px |
| Custom cursor | everywhere | four bars in mix-blend-difference that pull back into a ring over a link |
| Folding menu | top right | clip-path rather than a height, plus inert so the closed panel leaves the tab order |
| Full-size peek | project covers | a cover opens the neighbouring site live in a native <dialog>: it scrolls and clicks for real. Same-origin relative address, loaded on click and never on page load; the still image stays as the fallback |
| Page transition | between the three pages | the content leaves in 0.36 s, the cloud scatters, then the link goes through. Only a plain navigation is intercepted — with scripting off, a link stays a link |
| Negative section | about page | the sky really does flip: field.js interpolates both shaders — particles included — towards their pale variant. A CSS sheet doubles it, and carries the section on its own if WebGL is missing |
| Projects carousel | work page | a real horizontal scroller with scroll-snap: it slides, swipes and answers the arrow keys before any script runs. The script adds the perspective, the pointer drag, the vertical wheel turned sideways, and the counter |
What you should know
- No external requests. Every resource is local, fonts included. The original pulls its bundles and its fonts from its own CDN.
- The sky is CSS, not canvas. The gradient is a real background; the canvas only draws the particles. With JavaScript off, with WebGL missing, or with the context lost, there is still a night sky.
- A lost WebGL context paints white. A laptop waking from sleep is enough. The canvas listens for
webglcontextlost, hides itself, and rebuilds everything when the context returns. - Scroll reveals can't hide the content. The hidden state only exists once the JavaScript has run. Checked by removing the
<script>tags: all 26 blocks stay visible and the three service panels stack up one under the other. - Zero inline styles, zero inline scripts. The template honours the site's strict security policy, checked by serving it with that policy in front — not assumed.
- Checked at 390, 1440 and 2560 px, on load and after scrolling all the way down, with no horizontal overflow. The bench ships with it:
node _mesures/banc.mjs.
What isn’t perfect
Better said here than left for you to find out.
- The three project covers are abstract renders, not screenshots. The original's covers are real brand logotypes: impossible to reuse, and a generated image is not allowed to invent one. The project name is written in HTML over the image instead — which at least makes it translatable.
- The videos of the original's work page are not reused. It plays three animated captures of 3–4 MB each as WebGL textures. They belong to its clients and cannot be redistributed: the carousel shows the same still covers as the home page. You lose the movement inside the cards, you save 11 MB.
- The cards do not deform the way the original's do. There, a shader bends the plane as it slides. Here it is a CSS perspective plus a shear proportional to speed: the curved wall is there, the elasticity is not.
- “See project” points at the project's own anchor. The three projects are invented, so there is no honest destination. Swap the three
hrefs for your own URLs. - No post-processing. The original runs the scene through a bloom pass. Here the glow lives in the point's own shader — a core, a halo, an additive blend. The difference shows mostly on the largest points, a little less diffuse.
- The language switch is decorative. The original has a full Spanish version; a one-page template has one language.
- The contact form sends nothing. It's a native
<dialog>and the send button hands the fields to the visitor's mail client. Where to plug your own endpoint in is marked inmain.js.
It’s waiting for you in the library.
Noray comes with the subscription, alongside the rest of the library and the full course.
Immediate access · No commitment · Cancel in one click