@uxfront/scene
v0.4.1
Published
The scroll-driven WebGL particle scene behind the uxfront.com family of homepages: a pluggable formation pipeline, a formation catalog and a framework-agnostic scroll controller.
Maintainers
Readme
@uxfront/scene
The scroll-driven WebGL particle scene behind the uxfront.com family of homepages (UXFront, Open Components, Unframework, Styleframe, Inkline). One GPU particle system turns into a new formation for each section of the page as you scroll. The package has no framework dependency: @uxfront/ui wraps it in Vue components, and any other page can drive it directly.
- A pipeline. Morphs between formations under curl noise, an intro that rises from a floor line, a cursor lens, a click shockwave, a mirror floor, light trails, bloom and a filmic composite.
- A catalog. Eight formations:
monolith,plates,prism,tokens,ink,corridor,stackandthreshold. - A controller.
createExperiencemaps the page's sections to formations, reveals copy, steps through highlights and pins DOM labels to the formations.
Pages stay fast. The engine and each formation's GLSL are separate chunks, imported only once the page is idle. Shaders compile without blocking the main thread. Slow GPUs get fewer particles at a lower resolution, and without a capable GPU (no WebGL2, a software rasterizer, a lost context) the state switches to "fallback", so the page can show a static image instead.
Install
pnpm add @uxfront/sceneIn a Nuxt app, use @uxfront/layer-ui instead: it wires up everything below.
Usage
Mark up one section per formation, in order, then start the experience on the client:
<canvas id="stage" style="position: fixed; inset: 0; width: 100%; height: 100lvh"></canvas>
<main id="page">
<section data-stage>…</section>
<section data-stage data-steps="3">…</section>
<section data-stage>…</section>
</main>import { createExperience } from "@uxfront/scene";
import { corridor, monolith, plates } from "@uxfront/scene/formations";
const experience = createExperience({
root: document.getElementById("page")!,
canvas: document.querySelector("canvas")!,
formations: [
monolith({ label: "Foundation" }),
plates({ label: "Open Components" }),
corridor({ label: "Build on it" }),
],
});
experience.subscribe(({ chapter, status, motion }) => {
// Update a HUD, a nav, the fallback…
});
experience.setMotion(false); // pause the clock
experience.destroy();state holds chapter (the formation held), activeId (its section's id), status (idle, loading, ready or fallback), motion, particles and fps. Motion starts off when the visitor prefers reduced motion.
Page contract
| Markup | Effect |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| [data-stage] under root | One section per formation, in DOM order, about a screen tall. It holds its formation while it fills the screen, give or take HOLD_MARGIN. |
| data-steps="n" on a section | While it holds its formation, the section gets data-step="0…n-1" as it is read, to highlight [data-step-item]s, each once it's on screen. |
| [data-reveal] under root | Gets .is-in once scrolled into view. |
| [data-anchor="key:id"] under anchorRoots | Positioned (transform, opacity, visibility) over the formation's anchor while it shows. |
| [data-progress] under root | Gets --ux-progress: page scroll, 0 → 1. |
Sections scroll freely, so don't pin them: the copy should move with the page. The scene holds a formation from a quarter of a screen (HOLD_MARGIN) before its section fills the screen until a quarter after, and morphs into the next formation in between, while both sections share the screen.
Portrait, squarish and small screens use each formation's mobile framing, which leaves the top of the screen to the formation. Lay the copy out with the same breakpoints, exported as STACKED_QUERY.
Catalog
Every factory takes { label, key?, views?, look? }. label is shown in the HUD. views overrides the framing, for example to put a formation on the other side of the copy: tokens({ label, views: { desktop: { shift: [0.42, 0.02] } } }). key is only needed to use the same formation twice.
| Formation | Shows | Anchors |
| ----------- | -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| monolith | A 1:4:9 slab of light over a mirror floor, the UXFront mark inlaid in it. | None |
| plates | Three glass plates with line art, drifting apart. art swaps the drawings. | plates:0–plates:2, at the plates' edges |
| prism | A beam split into seven rails that grow as the chapter is read. | prism:0–prism:6, at the rails' ends |
| tokens | A framed token matrix re-themed in waves. next() skips to the next theme. | tokens:theme, writes into [data-theme-name] |
| ink | Coloured inks poured into water. Scrolling speeds the pour up, or drives it without motion. | None |
| corridor | A corridor of streaking light with long trails. Suits a closing section. | None |
| stack | Seven glass cards dealt as the chapter is read, one per rail, each holding the same component. art swaps it. | stack:0–stack:6, in the cards' headers |
| threshold | The prism's seven beams cross a soap film of light and converge into one white stream. | threshold:stream, above the stream |
Writing a formation
A formation is a small synchronous description plus a lazily loaded shader:
import { defineFormation } from "@uxfront/scene";
export const rings = (label: string) =>
defineFormation({
key: "rings",
label,
views: {
desktop: { eye: [0, 1, 10], target: [0, 0, 0], fov: 30, span: [2, 2], shift: [0.3, 0] },
mobile: { eye: [0, 1, 10], target: [0, 0, 0], fov: 34, span: [2, 2], shift: [0, 0.5] },
},
look: { bloom: 1.1 },
shader: () => import("./rings.shader").then((m) => m.shader),
});// rings.shader.ts
import type { FormationShader } from "@uxfront/scene";
export const shader: FormationShader = {
glsl: /* glsl */ `
uniform float $spin;
Particle $main(uint id, float sel, Frame f) {
uint s = seedOf(id, 0x5EEDu);
float a = rnd(s) * TAU + $spin * f.time;
Particle P = blank();
P.pos = vec3(cos(a), sin(a), 0.0) * mix(1.0, 2.0, f.local);
P.col = vec3(0.7, 0.8, 1.0) * 0.1;
return P;
}
`,
setup: ({ gl, get }) => gl.uniform1f(get("spin"), 0.2),
};$nameidentifiers are private: the scene rewrites them tokey_name, so formations never clash.$mainreturns the position, colour, size andrefl(mirror reflectivity) of particleid.selspreads evenly over [0, 1) across the particles, to split them between the parts of a formation.Frame fholdslocal(progress while the section holds the formation: 0 before, 1 after),approach(0 → 1 while the section scrolls in) andtime.- Shared helpers:
hashU,rnd,seedOf,gauss,noised,curl,bitangent,bezier3,rrectPoint,shapePoint,PI,TAUandFLOOR_Y. glslFloat,glslVecandglslArraywrite numbers into the GLSL as constants.packShapespacks line art (segments, rounded rectangles, circles, discs and fills) into rows forshapePoint, which places a particle on it.stackandplatesdraw their art this way.- A formation that follows the prism can take its
RAILSandSPECTRUMfrom@uxfront/scene/formations, so railikeeps its colour. setupuploads uniforms once.update(uniforms, frame)runs every frame with the formation'slocal,approach,presence,time,dt,motionandscrolled.anchorspin DOM labels:place(progress, out)writes a world position and returns extra visibility, andrender(el, progress)can update the label's content.
composeParticleShader builds the complete vertex shader, handy for testing a formation's GLSL.
