As a baby, I first noticed the iMac G3 in an artwork ebook. Its translucent shell and distinctive interface made me discover the visible design of digital merchandise lengthy earlier than I knew what that area was known as. Appleās hey (once more) marketing campaign stayed with me too, and have become one of many beginning factors for this website.
Immediately I work throughout design techniques and design engineering. Frontend growth started as a method to understand my designs, then step by step turned a part of the work itself. I replace my private website now and again to gather what I’ve been fascinated by and making. It’s an index of my work, but in addition a design and growth undertaking in its personal proper.
This model got here collectively over about three to 5 months, in bits of spare time. Throughout the earlier two years, I had been utilizing AI as a method to study extra about shaders and Three.js. This time, I wished to carry that information into one coherent expertise as a substitute of presenting a set of remoted results. The visible route grew round hey, glass, coloured gentle, and retro-futurism. The central technical query was find out how to give DOM and WebGL distinct jobs whereas conserving them collectively by way of scroll and interplay.
Technical Overview
- Subsequent.js + React
- Lenis
- Movement
- Three.js / React Three Fiber / Drei
- Customized shaders / post-processing
- Spline for 3D fashions
- Figma for drawing stickers
1. Preserving DOM and WebGL on the Identical Body
One scroll supply for DOM and WebGL
The positioning scrolls vertically within the regular method. I wished DOM to deal with textual content and typography, whereas a set canvas carries the glass mannequin and picture results. Scrolling naturally strikes the DOM, so the primary activity was to make each object within the canvas comply with the identical place.
Within the first model, Lenis up to date the DOM whereas R3F learn window.scrollY inside useFrame and up to date WebGL. It regarded nice at low speeds, however a quick scroll revealed a constant one-frame delay in WebGL. The rationale was that Lenis and R3F every owned a requestAnimationFrame loop. If R3F ran first, it learn the earlier scroll worth. Lenis would solely advance afterwards and transfer the DOM for the present body. A customized scroll container made the mismatch extra apparent as a result of window.scrollY was not essentially the worth Lenis was sustaining. No quantity of interpolation tuning may resolve an issue attributable to execution order and an unreliable information supply.
After taking a look at Lenisā guide raf strategy and JOYCOās WebGL Scroll Sync, I moved scrolling and rendering into one body loop. Lenisā personal loop is disabled. R3F calls lenis.raf by way of addEffect, then a ScrollBus information Lenisā scroll worth for that body. Each later useFrame client reads the identical snapshot. DOM and WebGL now agree on each the info and the second it turns into present.
// scroll_root.tsx
operate ScrollShell({ kids }: { kids: React.ReactNode }) {
return (
<ReactLenis choices={{ /* ... */, autoRaf: false }}>
<LenisScrollEnvBridge />
{kids}
</ReactLenis>
)
}
operate LenisScrollEnvBridge() {
const lenis = useLenis()
useEffect(() => {
bindLenisScrollBus(lenis ?? null)
return () => bindLenisScrollBus(null)
}, [lenis])
useEffect(() => {
if (!lenis) return
return addEffect((time: quantity) => {
lenis.raf(time)
})
}, [lenis])
return null
}
As soon as lenis.raf advances the scroll, Lenis emits its scroll occasion and updates the ScrollBus in the identical body. WebGL elements that run afterwards can learn the contemporary snapshot straight.
// lenis_scroll_bus.ts
import sort Lenis from "lenis"
// The manufacturing snapshot additionally contains restrict, progress, velocity,
// route, and viewportHeight.
sort ScrollSnapshot = { scrollTop: quantity }
let snapshot: ScrollSnapshot = { scrollTop: 0 }
const listeners = new Set<() => void>()
let unbind: (() => void) | null = null
export const bindLenisScrollBus = (lenis: Lenis | null) => {
unbind?.()
unbind = null
if (!lenis) return
const onScroll = ({ scroll }: { scroll: quantity }) => {
snapshot = { scrollTop: scroll }
for (const listener of listeners) listener()
}
lenis.on("scroll", onScroll)
unbind = () => lenis.off("scroll", onScroll)
snapshot = { scrollTop: lenis.scroll }
}
export const getLenisScrollSnapshot = () => snapshot
export const subscribeLenisScroll = (listener: () => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
WebGL already runs body by body, so it may well merely learn the newest worth. React elements that want scroll state for DOM output subscribe by way of useSyncExternalStore. The remainder of the element tree stays untouched.
// WebGL reads throughout useFrame with out triggering React renders.
const scrollY = getLenisScrollSnapshot().scrollTop
// React subscribes solely the place the worth impacts DOM output.
const SERVER_SCROLL_SNAPSHOT = { scrollTop: 0 }
const scroll = useSyncExternalStore(
subscribeLenisScroll,
getLenisScrollSnapshot,
() => SERVER_SCROLL_SNAPSHOT,
)
Within the side-by-side demo, the left retains the 2 impartial loops whereas the precise makes use of the shared body loop and ScrollBus. The one-frame slip disappears on the precise, even throughout a quick scroll.
One pointer coordinate system for each impact
When interplay stays inside a canvas, R3Fās normalized state.pointer is often sufficient. On this website, the identical pointer additionally drives DOM coordinate readouts, digital camera parallax, the glass rim gentle, and a fluid impact. Earlier variations let every function pay attention for pointer enter by itself. They labored, however each new impact needed to repeat the identical coordinate conversion, Y-axis inversion, and leave-state dealing with. The enter path grew extra fragmented with each addition.
I reused the ScrollBus thought and constructed a worldwide PointerBus. It converts browser coordinates right into a 0-to-1 UV as soon as, and retains an inside flag to say whether or not the pointer continues to be within the window. When the pointer leaves, the web page loses focus, or the tab turns into hidden, the UV returns to the middle. Results can settle again to their preliminary state as a substitute of leaping from a stale coordinate when the pointer comes again.
A single write updates each a mutable Vector2 for WebGL and an immutable snapshot for React. Something new can eat the PointerBus with out including one other listener or inventing its personal coordinate guidelines.
sort PointerSnapshot = {
x: quantity
y: quantity
inside: boolean
}
// One write retains DOM and WebGL on the identical x / y / inside state.
const updatePointer = (subsequent: PointerSnapshot) => {
snapshotRef.present = subsequent // React snapshot
uv.set(subsequent.x, subsequent.y) // WebGL
insideRef.present = subsequent.inside // WebGL
scheduleNotify() // React, at most as soon as per body
}
With scroll and pointer enter unified, I first put the system to work on the undertaking grid. It holds the locationās primary content material and has essentially the most overlap between DOM format and WebGL results, which made it a helpful take a look at for the entire hybrid strategy.
2. DOM for Format, WebGL for the Sudden
Not like the hero, the undertaking listing must be straightforward to learn and browse earlier than it does the rest. I didn’t need to sacrifice the work for an impact, however I additionally didn’t need one other acquainted picture grid. DOM and CSS Grid due to this fact personal the construction, responsive conduct, and accessibility. Clear picture placeholders are measured and mirrored into the canvas, the place WebGL steps in just for states that might be awkward to create with common DOM. The expertise additionally doesn’t ask guests to allow a browser flag for the experimental HTML-in-Canvas API.
Mirroring a DOM grid in WebGL
Every undertaking picture retains a clear DOM placeholder with a ref. CSS Grid decides its place and dimensions. The browserās Ingredient.getBoundingClientRect() offers me that rectangle, however the manufacturing website doesn’t ask each card to learn format on each body. One sampler maintains a shared rectangle cache as a substitute.
Throughout a scroll, the sampler first corrects cached rectangles by the scroll delta. Playing cards close to the viewport are measured each body so they continue to be aligned after a format change. Distant playing cards refresh as soon as each 12 frames, staggered throughout the listing, which avoids bunching all DOM reads into the identical body.
// Simplified DomTargetRectSampler.
useFrame(() => {
const rects = targetRectMapRef.present
const scrollTop = getScrollTop()
const deltaY = scrollTop - lastScrollTop
lastScrollTop = scrollTop
// Scroll strikes cached viewport rects with out one other format learn.
for (const rect of Object.values(rects)) {
rect.high -= deltaY
rect.backside -= deltaY
}
layers.forEach((layer, index) => )
body += 1
}, -3)
The sampler runs earlier than the picture elements, so a close-by picture reads a freshly measured DOM rectangle in the identical body. The cache lives in a ref map and by no means causes a React render. A mesh hides and stops updating when its texture isn’t prepared, its rectangle is invalid, or the picture is much outdoors the viewport. Its reveal progress additionally resets offscreen, so it’s prepared when the cardboard returns.
I exploit one fullscreen mesh per picture to maintain the coordinate math easy. Quite than transferring 3D geometry to match the DOM, I write the rectangleās place and dimension into uRect. The shader turns display UV into card-local coordinates, conserving the canvas picture aligned with its placeholder. Fullscreen meshes do add overdraw, so solely photos close to the viewport are rendered.
// dom_sync.frag.glsl
uniform vec4 uRect; // xy origin, zw dimension
uniform sampler2D map;
vec4 sampleDomImage(vec2 screenUv) {
vec2 localUv = (screenUv - uRect.xy) / uRect.zw;
vec2 edge = min(localUv, 1.0 - localUv);
float inside = step(0.0, edge.x) * step(0.0, edge.y);
vec4 coloration = texture2D(map, clamp(localUv, 0.0, 1.0));
coloration.a *= inside;
return coloration;
}
Writing uRect solely requires one coordinate-system correction. Display coordinates start on the high left, whereas shader UV begins on the backside left, so Y needs to be flipped. After that, CSS stays free to vary columns, gaps, and card ratios. WebGL solely follows the ensuing rectangles.
The surprising begins on hover
I wished every undertaking card to carry extra visible data, so each card has two photos. A plain crossfade nonetheless felt too acquainted. As an alternative, the shader divides the display into a set grid. The reveal spreads from the middle of the cardboard whereas a sq. grows inside every cell, step by step uncovering the second picture. This dot-matrix language later discovered its method into loading, web page transitions, and the cell menu.
The shader does the work in three steps. First, uRect transforms fullscreen UV into native card UV so each photos match the identical DOM placeholder. Subsequent, display area is split into fixed-size cells. Lastly, the cardboardās facet ratio is compensated for whereas hover progress expands a round area from the middle. Inside that area, every cellās sq. grows. The ensuing masks blends the 2 textures.
// Inputs shared by each photos.
uniform sampler2D map;
uniform sampler2D mapHover;
uniform vec4 uRect;
uniform float uHoverRevealProgress;
uniform float uDotPixelSize;
uniform vec2 uViewportPx;
vec4 revealHoverImage(vec2 screenUv) {
// 1. Map the full-screen UV into the DOM card.
vec2 localUv = (screenUv - uRect.xy) / uRect.zw;
float rectWidthPx = max(uRect.z * uViewportPx.x, 1.0);
float rectHeightPx = max(uRect.w * uViewportPx.y, 1.0);
// 2. Divide display area into fixed-size cells.
vec2 viewportPx = max(uViewportPx, vec2(1.0));
vec2 cellSizeUv = vec2(max(2.0, uDotPixelSize)) / viewportPx;
vec2 cellUv = fract(screenUv / cellSizeUv);
float squareDist = max(abs(cellUv.x - 0.5), abs(cellUv.y - 0.5));
// 3. Broaden from the cardboard middle and develop a sq. in every cell.
float rectAspect = rectWidthPx / rectHeightPx;
vec2 centered = localUv * 2.0 - 1.0;
centered.x *= rectAspect;
float distToCenter = size(centered);
float maxRadius = size(vec2(rectAspect, 1.0));
float progress = clamp(uHoverRevealProgress, 0.0, 1.0);
float radius = progress * (maxRadius + 0.12);
float develop = 1.0 - smoothstep(radius - 0.12, radius + 0.12, distToCenter);
develop *= step(0.0001, progress);
float squareExtent = combine(0.0, 0.5, develop);
float squareAa = max(fwidth(squareDist), 0.0001);
float squareMask = 1.0 - smoothstep(
squareExtent - squareAa,
squareExtent + squareAa,
squareDist
);
// Combine the aligned textures with the generated masks.
vec4 baseColor = texture2D(map, clamp(localUv, 0.0, 1.0));
vec4 hoverColor = texture2D(mapHover, clamp(localUv, 0.0, 1.0));
vec4 coloration = combine(baseColor, hoverColor, squareMask);
vec2 edge = min(localUv, 1.0 - localUv);
coloration.a *= step(0.0, edge.x) * step(0.0, edge.y);
return coloration;
}
Growing the picture because it enters the body
This concept got here from a photographerās web site I as soon as noticed. The shift from a unfavorable again to full coloration felt proper as a picture entrance, so I introduced it into the undertaking grid. As soon as a card enters the viewport, its picture develops over 0.8 seconds. The progress returns to zero when the cardboard leaves fully, able to play once more on its subsequent go to. With prefers-reduced-motion enabled, the transition is skipped and the unique coloration seems instantly.
uniform float uPolarityPositive; // 0 = unfavorable, 1 = unique
// Mix from the unfavorable picture again to its unique coloration.
vec3 applyPolarity(vec3 rgb) {
float t = clamp(uPolarityPositive, 0.0, 1.0);
return combine(1.0 - rgb, rgb, t);
}
Making scroll pace seen with a shader
After hover and the doorway impact had been in place, I wished one final WebGL conduct for the grid. Quick scrolling ought to really feel quicker. The space travelled between two frames, divided by time, offers me velocity. I normalize it into uCurlStrength, so the photographs flex barely alongside the horizontal axis in response to hurry moderately than accumulating distortion with scroll distance.
Trackpads introduce quite a lot of small velocity fluctuations. To maintain them from turning into visible noise, the energy makes use of two time constants with a quick assault and a slower launch. delta is clamped as properly, stopping an excessive worth when a backgrounded web page wakes up.
// dom_sync_img.tsx
operate createCurlStrengthSampler() {
let previousScrollY: quantity | null = null
let exercise = 0
return (scrollY: quantity, delta: quantity) => {
const dt = THREE.MathUtils.clamp(delta, 1 / 240, 0.1)
const velocity = previousScrollY == null
? 0
: Math.abs(scrollY - previousScrollY) / dt
previousScrollY = scrollY
// Normalize scroll pace into the goal curl exercise.
const goal = THREE.MathUtils.clamp(velocity / 800, 0, 1)
// Quick assault and sluggish launch easy small trackpad fluctuations.
const tau = goal > exercise ? 0.025 : 0.175
const alpha = 1 - Math.exp(-dt / tau)
exercise += (goal - exercise) * alpha
// Map the smoothed exercise to the utmost curl energy.
return 0.06 * exercise
}
}
The sampler retains the earlier scroll place and present energy between frames. That reminiscence is what makes an ongoing velocity measurement and its smoothing doable. Within the shader, a semicircular profile controls the horizontal UV compression. The center of the picture strikes little or no, whereas the highest and backside bend additional as uCurlStrength rises.
// dom_sync.frag.glsl
uniform float uCurlStrength;
vec2 applyCurl(vec2 screenUv) {
float centered = 2.0 * screenUv.y - 1.0;
float profile = 1.0 - sqrt(max(0.0, 1.0 - centered * centered));
// Larger pace will increase uCurlStrength and compresses X close to the highest and backside.
float uvScale = 1.0 - profile * uCurlStrength;
float distortedX = (screenUv.x - 0.5) * uvScale + 0.5;
return vec2(distortedX, screenUv.y);
}
3. Turning hey right into a Glass Centerpiece
As soon as the undertaking grid was working, I moved on to the locationās visible centerpiece, the glass hey. The geometry is straightforward sufficient that I didn’t use Blender. I constructed the textual content in Spline, exported it as GLTF, and saved solely the geometry. Three.js takes care of lighting and materials.
The glass shader builds on Maxime Heckelās tutorial, Refraction, dispersion, and different shader gentle results. I began with its refraction, chromatic dispersion, and Fresnel concepts, then added interplay, theme-aware tinting, and extra management over rendering value.
Refraction occurs in two passes. First, the glass is excluded from an FBO whereas the scene behind it’s rendered right into a texture. The principle scene then attracts the glass, whose shader samples that texture alongside barely totally different refraction instructions to create distortion and dispersion. The glass makes use of its personal Three.js layer so the FBO by no means captures the article itself. The falling stickers additionally want to take a seat behind the glass if they’re to look within the refracted pattern.
Letting the spotlight comply with the pointer with out leaving the rim
My first rim gentle adopted the pointer straight. When the pointer approached the middle of the display, the mapped gentle moved onto the face of the glass. It created a broad, brilliant patch and weakened the contour of the letters. Appleās Liquid Glass presentation gave me a greater reference, with a spotlight that travels across the edge. I saved the route of the pointer however discarded its distance from the middle.
Pointer UV is raycast onto a airplane in entrance of the mannequin to provide mappedX and mappedY. atan2 turns that place into an angle, and the sunshine is at all times positioned on a circle with a set radius.
Angles can’t be interpolated like odd numbers. Crossing from pi to unfavorable pi might ship a daily linear interpolation across the longer arc. dampAngle first wraps the distinction into the vary from unfavorable pi to pi, then applies exponential smoothing.
operate createRingLightFollower() {
const defaultLight = { x: 4, y: 9 }
const radius = Math.hypot(defaultLight.x, defaultLight.y)
const defaultAngle = Math.atan2(defaultLight.y, defaultLight.x)
let targetAngle = defaultAngle
let currentAngle = defaultAngle
const dampAngle = (present: quantity, goal: quantity, lambda: quantity, dt: quantity) => {
const shortest = Math.atan2(
Math.sin(goal - present),
Math.cos(goal - present),
)
return present + shortest * (1 - Math.exp(-lambda * dt))
}
// mappedX / mappedY come from raycasting pointer UV onto the mannequin airplane.
return (mappedX: quantity, mappedY: quantity, inside: boolean, delta: quantity) => {
if (inside && mappedX * mappedX + mappedY * mappedY > 1e-6) {
targetAngle = Math.atan2(mappedY, mappedX)
} else if (!inside) {
targetAngle = defaultAngle
}
currentAngle = dampAngle(currentAngle, targetAngle, 6, delta)
return {
x: radius * Math.cos(currentAngle),
y: radius * Math.sin(currentAngle),
}
}
}
The spotlight nonetheless responds to the pointerās route, however it may well now not drift onto the entrance face of the glass.
Coloured glass for each gentle and darkish modes
I wished the coloured glass to maintain a way of thickness in opposition to a lightweight background, whereas remaining brilliant and saturated sufficient in darkish mode. For the sunshine theme, the tint takes its cue from the Beer-Lambert regulation, extra strictly written as T = I / I0 = 10^(-epsilon cl) (IUPACās Beer-Lambert regulation entry). The positioning doesn’t simulate an actual spectrum or an entire optical path. As an alternative, an RGB tint represents the colour that survives transmission, and pow(tint, thickness) acts as an approximation of transmittance.
That very same operation regarded too dim in opposition to a darkish background, so darkish mode makes use of Arduous Mild to raise the colour. uDark strikes between the Beer-Lambert-inspired transmission and the art-directed mix. This isn’t one unified bodily mannequin. It’s a visible adjustment for 2 very totally different backgrounds.
The lowered instance under retains solely the 2 mixing paths. The manufacturing materials additionally blends two tints alongside the mannequinās native Y coordinate, and estimates variation in thickness from the angle between the view route and the traditional.
uniform vec3 uTintColor;
uniform float uTintAmount;
uniform float uThickness;
uniform float uDark;
vec3 hardLight(vec3 base, vec3 mix) {
vec3 low = 2.0 * base * mix;
vec3 excessive = 1.0 - 2.0 * (1.0 - base) * (1.0 - mix);
return combine(low, excessive, step(vec3(0.5), mix));
}
vec3 applyGlassTint(vec3 coloration) {
vec3 tintColor = clamp(uTintColor, 0.001, 1.0);
float quantity = clamp(uTintAmount, 0.0, 1.0);
// Mild mode: Beer-Lambert-inspired absorption.
vec3 transmittance = pow(tintColor, vec3(max(uThickness, 0.01)));
vec3 beerColor = combine(coloration, coloration * transmittance, quantity);
// Darkish mode: an art-directed Arduous Mild tint.
vec3 hardColor = combine(
coloration,
hardLight(clamp(coloration, 0.0, 1.0), tintColor),
quantity
);
return combine(beerColor, hardColor, clamp(uDark, 0.0, 1.0));
}
The comparability demo reveals the tint in each themes, alongside the distinction between direct pointer monitoring and the ring-constrained gentle.
Giving the refraction one thing to work with
As soon as the rim gentle and tint had been in place, the glass nonetheless wanted coloration and movement behind it earlier than its refraction and dispersion may grow to be apparent. I drew a set of colourful stickers in Figma and positioned them behind the letters. They fall by way of a slim space that overlaps the phrase. A zOffset retains them contained in the scene sampled by the FBO, whereas the CPU updates particle movement and lifetimes.
Giving each sticker its personal mesh and texture would create a collection of draw calls and materials switches, and the FBO would repeat that value. As an alternative, the entire PNGs are packed into one CanvasTexture and drawn with a single InstancedMesh. The atlas solely wants a uvRect and facet ratio for every sticker. Whereas writing the UV rectangles, I flip the Y-axis between Canvas and WebGL and inset the bounds by half a pixel so linear filtering doesn’t decide up clear padding.
sort AtlasImage = CanvasImageSource & { width: quantity; peak: quantity }
operate drawAtlasEntry(
ctx: CanvasRenderingContext2D,
picture: AtlasImage,
x: quantity,
y: quantity,
atlasWidth: quantity,
atlasHeight: quantity,
) {
ctx.drawImage(picture, x, y, picture.width, picture.peak)
// Canvas is top-left. WebGL UV is bottom-left.
// The half-pixel inset avoids sampling clear atlas padding.
const uvRect = new THREE.Vector4(
(x + 0.5) / atlasWidth,
1 - (y + picture.peak - 0.5) / atlasHeight,
(picture.width - 1) / atlasWidth,
(picture.peak - 1) / atlasHeight,
)
return { uvRect, facet: picture.width / picture.peak }
}
Every occasion represents one particle. Place, rotation, and scale go into instanceMatrix, whereas a customized occasion attribute shops the corresponding uvRect within the atlas. The vertex shader maps native UV to that sticker with uvRect.xy + uv * uvRect.zw.
// Write CPU particle state into GPU occasion attributes.
for (let i = 0; i < visibleCount; i++) {
const particle = renderParticles[i]
const facet = atlas.elements[particle.textureIndex]
const uvOffset = particle.textureIndex * 4
const baseScale = config.scale * particle.scale
matrixObject.place.copy(particle.place)
matrixObject.rotation.set(0, 0, particle.rotation)
matrixObject.scale.set(baseScale * facet, baseScale, 1)
matrixObject.updateMatrix()
mesh.setMatrixAt(i, matrixObject.matrix)
uvAttribute.setXYZW(
i,
atlas.uvRects[uvOffset],
atlas.uvRects[uvOffset + 1],
atlas.uvRects[uvOffset + 2],
atlas.uvRects[uvOffset + 3],
)
}
mesh.instanceMatrix.needsUpdate = true
uvAttribute.needsUpdate = true
Inside a set occasion funds, the sticker area now shares one texture, one materials, and one instanced draw. The transferring coloration makes refraction and dispersion a lot simpler to learn. Right here, enhancing the scene behind the glass did greater than including one other spherical of shader complexity.
4. Ending with a Retro-Futurist Visible Language
With the glass centerpiece completed, I resisted including one other point of interest. As an alternative, I returned to the locationās smaller particulars and gave them a standard reference. The route was retro-futurism. A part of it comes from optical artifacts in stage footage, and half from the dot matrices and character suggestions of early digital interfaces. The previous makes the glass really feel extra filmed. The latter shapes picture adjustments, fullscreen transitions, and the way in which textual content seems.
Making glass really feel filmed with a Star 6 filter
Refraction, dispersion, and the transferring stickers gave the glass coloration and movement, however its highlights nonetheless regarded like a clear digital render. I saved fascinated by star filters in music movies and stage footage from the 80s and 90s. That led me so as to add the sample of a Star 6 filter to a customized lens flare go.
The go retains the intense core and coloured path of a lens flare. Three mounted axes add six rays across the highlights, making the glass really feel a bit extra like one thing filmed by way of a digital camera.
First, a luminance threshold isolates brilliant sources within the body. Most of them come from the glass specular. streak samples each instructions alongside one axis. A vertical axis and two axes at plus and minus 30 levels produce the six rays. This lowered model reveals the spotlight extraction and star sample. The manufacturing go provides additional management over sizzling spots, path coloration, and the circumstances that allow the impact.
float luma(vec3 coloration) {
return dot(coloration, vec3(0.2126, 0.7152, 0.0722));
}
float brightMask(float luminance) {
// Maintain solely highlights above the configured threshold.
float worth = max(luminance - uThreshold, 0.0);
worth /= max(1.0 - uThreshold, 1e-5);
return smoothstep(0.0, 1.0, clamp(worth, 0.0, 1.0));
}
vec3 sampleBright(vec2 uv) {
vec3 coloration = texture2D(tDiffuse, uv).rgb;
return coloration * brightMask(luma(coloration));
}
vec3 streak(vec2 route) {
vec3 end result = vec3(0.0);
// Pattern either side of 1 axis.
for (int i = 1; i <= 8; i++) {
float distancePx = float(i) * 1.5;
float weight = 1.0 / (1.0 + distancePx * 0.22);
weight *= weight;
vec2 offset = route * distancePx;
end result += sampleBright(vUv + offset) * weight;
end result += sampleBright(vUv - offset) * weight;
}
return end result;
}
vec3 base = texture2D(tDiffuse, vUv).rgb;
vec3 flare = base * brightMask(luma(base)) * 1.2;
vec2 px = (1.0 / uResolution) * uStreakScale;
// Three axes produce six rays.
flare += streak(vec2(0.0, px.y));
flare += streak(vec2(px.x * 0.8660254, px.y * 0.5));
flare += streak(vec2(px.x * 0.8660254, -px.y * 0.5));
The star texture renders at half decision and refreshes each different body, then composites with the total scene every body. When the intense part containing the glass is outdoors the viewport, your entire go stops. There is no such thing as a cause to maintain paying for samples that can’t produce a visual end result.
Dot matrices and character decoding as one system of suggestions
Star 6 establishes the optical character of the picture. The dot matrix carries the identical reference into interactions and transitions. A card hover divides the display into cells and adjustments photos by rising a sq. in each. Loading, route adjustments, and the cell menu use a radial masks, with the alpha of every cell controlling the radius of a circle. They don’t share one shader. What they share is a visible rule that turns steady progress into the scale of a form on a set grid. A state change feels associated whether or not it occurs inside one card or throughout the entire web page.
// Card hover: develop a sq. inside every screen-space cell.
vec2 cardCellUv = fract(screenUv / cellSize);
vec2 fromCenter = abs(cardCellUv - vec2(0.5));
float squareExtent = combine(0.0, 0.5, develop);
float squareDistance = max(fromCenter.x, fromCenter.y);
float squareAa = fwidth(squareDistance) * 1.5;
float squareMask = 1.0 - smoothstep(
squareExtent - squareAa,
squareExtent + squareAa,
squareDistance
);
// Full-screen transition: use the radial masks to develop a circle per cell.
vec2 cellId = flooring(uv / pixelSizeUv);
vec2 cellCenter = (cellId + vec2(0.5)) * pixelSizeUv;
float cellAlpha = radialMaskAlpha(cellCenter);
float radius = 0.8 * cellAlpha;
float circleDistance = distance(fract(uv / pixelSizeUv), vec2(0.5));
float circleAa = fwidth(circleDistance) * 1.5;
float circleMask = 1.0 - smoothstep(
radius - circleAa,
radius + circleAa,
circleDistance
);
Textual content follows the identical route with out repeating the dot sample. ScrambleLines briefly cycles every character by way of capital letters, numbers, and symbols earlier than settling into the meant copy, like a CLI decoding a message. All textual content situations share one 40ms ticker. They subscribe solely after getting into the viewport and after the fullscreen transition begins to open, then unsubscribe as quickly because the animation is finished.
Its geometry is totally different from the dot matrix, however each specific steady change by way of discrete items. Star 6 offers the glass the optical hint of a digital camera. Dots and decoded characters outline the interface response. The code is totally different, however the impression belongs to 1 website. Materials, transition, and typography all level in the identical retro-futurist route.
Reflections
An important lesson from this undertaking was to present DOM and WebGL dependable scroll and pointer information earlier than deciding the place an impact ought to seem. CSS owns construction and accessibility. WebGL is available in when curl, refraction, or a transition can add one thing CSS would battle to specific. Within the completed website, shared state and a constant visible rule mattered greater than the variety of results.
AI helped me take a look at shader concepts, examine issues, and transfer by way of early implementations quicker. It didn’t make the ultimate selections. These nonetheless got here right down to design judgment. If I had been beginning once more, I might set the cell efficiency funds and the shutoff circumstances for every impact a lot earlier.
After launch, the location turned my first undertaking to obtain Awwwards Website of the Day, FWA of the Day, and CSSDesignAwards Web site of the Day. Lenis additionally included it of their Showcase. I worth that recognition, however I’m proudest that the completed website nonetheless carries the design intent and degree of craft I got down to obtain.

