Tunnel Hero
A perspective tunnel, oversized event typography, and a centred call to action.
Make it yours.
Install the editable source and its dependencies into your React + Tailwind project.
Scroll horizontally for long lines
pnpm dlx shadcn@4.0.8 add http://localhost:3000/r/tunnel-hero.jsonUse it
Scroll horizontally for long lines
"use client";
import {TunnelHero} from '@/components/jez-ui/blocks/tunnel-hero';
export default function Example(){
return <><TunnelHero/></>;
}Props & types
Scroll horizontally for long lines
export type HeroProps = {
className?: string;
href?: string;
title?: React.ReactNode;
description?: React.ReactNode;
actionLabel?: React.ReactNode;
imageSrc?: string;
imageAlt?: string;
artwork?: { color?: string; speed?: number };
};
HeroPropsSource
View 9 source files
registry/blocks/tunnel-hero.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { cn } from "../ui/utils";
import { HeroLink, type HeroProps } from "./hero-parts";
import { HeroArt } from "./hero-art";
export function TunnelHero({
title,
actionLabel,
artwork,
className,
href = "/blocks",
}: HeroProps) {
return (
<section
className={cn(
"relative isolate overflow-hidden rounded-xl bg-[#030405] text-[#dce6f7]",
className,
)}
>
<div className="relative">
<HeroArt options={artwork} kind="tunnel" className="h-[590px]" />
<div className="pointer-events-none absolute inset-0 flex flex-col justify-between p-7 md:p-10">
<div className="flex justify-between text-xs tracking-widest">
<span>AFTERHOURS</span>
<span>SOUND / SPACE / POSSIBILITY</span>
</div>
<div className="text-center">
<p className="mb-4 text-xs uppercase tracking-[.4em]">
Leave the ordinary behind
</p>
<h1 className="font-display text-6xl font-semibold leading-none tracking-tighter md:text-8xl">
{title ?? <>GO DEEPER.</>}
</h1>
<div className="pointer-events-auto mt-8">
<HeroLink
href={href}
className="bg-[#030405]/70 backdrop-blur-sm"
>
{actionLabel ?? <>Explore the programme</>}
</HeroLink>
</div>
</div>
<span className="text-xs text-white/50">
An independent music & culture platform
</span>
</div>
</div>
</section>
);
}
registry/ui/utils.ts
Scroll horizontally for long lines
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...values: ClassValue[]) {
return twMerge(clsx(values));
}
registry/blocks/hero-parts.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { ArrowUpRight } from "lucide-react";
import { cn } from "../ui/utils";
export type HeroProps = {
className?: string;
href?: string;
title?: React.ReactNode;
description?: React.ReactNode;
actionLabel?: React.ReactNode;
imageSrc?: string;
imageAlt?: string;
artwork?: { color?: string; speed?: number };
};
export function HeroLink({
href = "/blocks",
children = "Explore the collection",
className,
}: {
href?: string;
children?: React.ReactNode;
className?: string;
}) {
return (
<a
href={href}
className={cn(
"inline-flex items-center gap-3 rounded-full border border-current/30 px-5 py-3 text-sm transition-colors hover:bg-current/10",
className,
)}
>
{children}
<ArrowUpRight size={16} />
</a>
);
}
registry/blocks/hero-art.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { Pause, Play } from "lucide-react";
import { cn } from "../ui/utils";
import { WebGLStage } from "../ui/webgl-stage";
import type { SceneKind } from "../ui/webgl-scenes";
export function HeroArt({
options,
kind,
color,
className,
}: {
options?: { color?: string; speed?: number };
kind: SceneKind;
color?: string;
className?: string;
}) {
const [paused, setPaused] = React.useState(false);
return (
<div className={cn("relative min-h-72", className)}>
<WebGLStage
kind={kind}
color={options?.color ?? color}
paused={paused}
speed={options?.speed ?? 0.45}
className="absolute inset-0 h-full rounded-none"
label={`Interactive ${kind} artwork`}
/>
<button
type="button"
aria-label={paused ? "Play artwork" : "Pause artwork"}
aria-pressed={paused}
onClick={() => setPaused(!paused)}
className="absolute bottom-4 right-4 z-20 grid size-10 place-items-center rounded-full border border-white/30 bg-black/35 text-white backdrop-blur-sm hover:bg-black/60"
>
{paused ? <Play size={14} /> : <Pause size={14} />}
</button>
</div>
);
}
registry/ui/webgl-stage.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { Canvas } from "@react-three/fiber";
import { WebGLScene, type SceneKind } from "./webgl-scenes";
import { RibbonPoster } from "./webgl-ribbons";
import { cn } from "./utils";
export type WebGLProps = {
className?: string;
color?: string;
speed?: number;
paused?: boolean;
label?: string;
imageSrc?: string;
composition?: "fold" | "orbit";
};
class Boundary extends React.Component<
{ children: React.ReactNode; fallback: React.ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
render() {
return this.state.failed ? this.props.fallback : this.props.children;
}
}
export function WebGLStage({
kind,
className,
color: suppliedColor,
speed = 1,
paused = false,
imageSrc,
composition = "fold",
label = "Interactive WebGL artwork",
}: WebGLProps & { kind: SceneKind }) {
const colors = {
silk: "#b7cdbb",
eclipse: "#edbd79",
tunnel: "#a6bce4",
constellation: "#a6d0c0",
particles: "#b9a4f8",
ribbons: "#8daed1",
liquid: "#737fd7",
orb: "#eab89d",
terrain: "#b2c08b",
distortion: "#d7dfcf",
};
const backgrounds = {
silk: "#030405",
eclipse: "#030405",
tunnel: "#030405",
constellation: "#030405",
particles: "#10101c",
ribbons: "#10151d",
liquid: "#1c2945",
orb: "#241c2b",
terrain: "#14221e",
distortion: "#d7dfcf",
};
const color = suppliedColor ?? colors[kind];
const ref = React.useRef<HTMLDivElement>(null);
const [visible, setVisible] = React.useState(false);
const [ready, setReady] = React.useState(false);
const [lost, setLost] = React.useState(false);
const [reduce, setReduce] = React.useState(true);
React.useEffect(() => {
const media = matchMedia("(prefers-reduced-motion: reduce)");
const change = () => setReduce(media.matches);
change();
media.addEventListener("change", change);
const canvas = document.createElement("canvas");
const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
setReady(!!gl);
gl?.getExtension("WEBGL_lose_context")?.loseContext();
const observer = new IntersectionObserver(
([entry]) => setVisible(entry.isIntersecting),
{ rootMargin: "80px" },
);
if (ref.current) observer.observe(ref.current);
return () => {
observer.disconnect();
media.removeEventListener("change", change);
};
}, []);
const fallback = (
<div
className="absolute inset-0 grid place-items-center"
data-webgl-fallback
>
{kind === "ribbons" ? (
<RibbonPoster color={color} />
) : kind === "terrain" ? (
<svg
viewBox="0 0 900 400"
preserveAspectRatio="xMidYMid slice"
className="h-full w-full"
aria-hidden="true"
>
{Array.from({ length: 34 }, (_, row) => {
const points = Array.from({ length: 80 }, (_, col) => {
const x = (col / 79) * 1000 - 50;
const z = row / 33;
const h =
90 * Math.exp(-(((x - 310) / 150) ** 2)) +
65 * Math.exp(-(((x - 610) / 110) ** 2));
const y =
140 +
z * 230 -
h * Math.sin(z * Math.PI) * 1.5 +
Math.sin(x * 0.013 + z * 5) * 12;
return `${x.toFixed(2)},${y.toFixed(2)}`;
});
return (
<polyline
key={row}
points={points.join(" ")}
fill="none"
stroke={color}
strokeWidth={1}
opacity={0.2 + row / 55}
/>
);
})}
</svg>
) : kind === "distortion" && imageSrc ? (
<img src={imageSrc} alt="" className="h-full w-full object-cover" />
) : (
<svg viewBox="0 0 600 400" className="h-full w-full" aria-hidden="true">
{kind === "particles" ? (
Array.from({ length: 700 }, (_, i) => {
const a = i * 2.399963,
r = 65 + Math.sqrt(i / 700) * 165;
return (
<circle
key={i}
cx={(300 + Math.cos(a) * r).toFixed(3)}
cy={(200 + Math.sin(a) * r * 0.52).toFixed(3)}
r={0.5 + (i % 4) * 0.3}
fill={color}
opacity={0.4 + (i % 6) * 0.1}
/>
);
})
) : kind === "distortion" ? (
<>
<rect width="600" height="400" fill="#d7dfcf" />
<text
x="300"
y="235"
textAnchor="middle"
fill="#28352d"
fontFamily="sans-serif"
fontWeight="700"
fontSize="150"
letterSpacing="-9"
>
FORM
</text>
<path d="M 80 275 H 520" stroke="#c36943" strokeWidth="8" />
</>
) : kind === "eclipse" || kind === "tunnel" ? (
<>
{Array.from({ length: kind === "eclipse" ? 8 : 18 }, (_, i) => (
<circle
key={i}
cx="300"
cy="200"
r={kind === "eclipse" ? 100 + i * 2 : 15 + i * i * 1.1}
fill="none"
stroke={color}
opacity={kind === "eclipse" ? 0.8 / (i + 1) : 0.15 + i * 0.03}
strokeWidth={kind === "eclipse" ? 1 : 1.5}
/>
))}
</>
) : kind === "constellation" ? (
<>
{Array.from({ length: 30 }, (_, i) => {
const x = 300 + Math.sin(i * 19.1) * 240,
y = 200 + Math.cos(i * 7.7) * 160;
return (
<g key={i}>
<line
x1={x}
y1={y}
x2={300 + Math.sin((i + 1) * 19.1) * 240}
y2={200 + Math.cos((i + 1) * 7.7) * 160}
stroke={color}
opacity=".15"
/>
<circle cx={x} cy={y} r="2" fill={color} />
</g>
);
})}
</>
) : kind === "orb" ? (
<>
<defs>
<radialGradient id="jez-orb-poster" cx="30%" cy="22%">
<stop stopColor="#fff4e4" />
<stop offset=".35" stopColor={color} />
<stop offset=".7" stopColor="#695786" />
<stop offset="1" stopColor="#1f2035" />
</radialGradient>
</defs>
<circle cx="300" cy="200" r="128" fill="url(#jez-orb-poster)" />
</>
) : (
Array.from({ length: 32 }, (_, i) => (
<path
key={i}
d={`M 0 ${70 + i * 9} Q 150 ${-30 + i * 13} 300 ${130 + i * 7} T 600 ${95 + i * 10}`}
fill="none"
stroke={color}
strokeWidth={1.1}
opacity={0.3 + (i % 5) * 0.13}
/>
))
)}
</svg>
)}
</div>
);
return (
<div
ref={ref}
role="img"
aria-label={label}
style={{ background: backgrounds[kind] }}
className={cn(
"relative h-[400px] w-full overflow-hidden rounded-xl",
className,
)}
>
{ready && !lost && !reduce && visible ? (
<Boundary fallback={fallback}>
<Canvas
dpr={[1, 1.5]}
frameloop={paused ? "demand" : "always"}
camera={{ position: [0, 0, 5], fov: 48 }}
gl={{ antialias: true, alpha: true, powerPreference: "low-power" }}
onCreated={({ gl }) => {
gl.domElement.addEventListener(
"webglcontextlost",
() => setLost(true),
{ once: true },
);
}}
>
<WebGLScene
kind={kind}
color={color}
speed={speed}
imageSrc={imageSrc}
composition={composition}
/>
</Canvas>
</Boundary>
) : (
fallback
)}
</div>
);
}
registry/ui/webgl-scenes.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
import { RibbonScene } from "./webgl-ribbons";
import {
screenVertex,
orbFragment,
liquidFragment,
distortionFragment,
terrainVertex,
terrainFragment,
particleVertex,
particleFragment,
} from "./webgl-shaders";
import { AtmosphereScene } from "./webgl-atmospheres";
export type SceneKind =
| "silk"
| "eclipse"
| "tunnel"
| "constellation"
| "particles"
| "ribbons"
| "liquid"
| "orb"
| "terrain"
| "distortion";
export type SceneProps = {
kind: SceneKind;
color: string;
speed: number;
imageSrc?: string;
composition?: "fold" | "orbit";
};
function Field({ color, speed }: { color: string; speed: number }) {
const material = React.useMemo(
() =>
new THREE.ShaderMaterial({
vertexShader: particleVertex,
fragmentShader: particleFragment,
uniforms: {
time: { value: 0 },
pointer: { value: new THREE.Vector2() },
tint: { value: new THREE.Color(color) },
},
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
}),
[color],
);
const geometry = React.useMemo(() => {
const n = 18000,
p = new Float32Array(n * 3),
s = new Float32Array(n);
for (let i = 0; i < n; i++) {
const a = i * 2.39996323;
const r = 0.7 + Math.pow((i + 0.5) / n, 0.7) * 1.6;
const warp = Math.sin(a * 3) * 0.18;
const seed = (Math.sin(i * 127.1) * 43758.5453) % 1;
const scatter = Math.abs(seed);
p[i * 3] = Math.cos(a) * r;
p[i * 3 + 1] = Math.sin(a) * r * 0.6 + Math.sin(r * 3) * 0.12;
p[i * 3 + 2] =
Math.sin(a * 2 + r * 3) * 0.42 + warp + (scatter - 0.5) * 0.12;
s[i] = scatter;
}
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(p, 3));
g.setAttribute("seed", new THREE.BufferAttribute(s, 1));
return g;
}, []);
React.useEffect(
() => () => {
geometry.dispose();
material.dispose();
},
[geometry, material],
);
useFrame(({ pointer }, d) => {
material.uniforms.time.value += Math.min(d, 0.05) * speed;
material.uniforms.pointer.value.lerp(pointer, 0.04);
});
return (
<points
geometry={geometry}
material={material}
rotation={[0.18, 0, -0.25]}
/>
);
}
function Surface({ kind, color, speed, imageSrc }: SceneProps) {
const { size, invalidate } = useThree();
const ref = React.useRef<THREE.Mesh>(null);
const material = React.useMemo(
() =>
new THREE.ShaderMaterial({
vertexShader: kind === "terrain" ? terrainVertex : screenVertex,
fragmentShader:
kind === "orb"
? orbFragment
: kind === "liquid"
? liquidFragment
: kind === "terrain"
? terrainFragment
: distortionFragment,
uniforms: {
time: { value: 0 },
aspect: { value: 1 },
pointer: { value: new THREE.Vector2() },
tint: { value: new THREE.Color(color) },
picture: { value: null },
imageAspect: { value: 1.5 },
},
side: THREE.DoubleSide,
}),
[kind, color],
);
React.useEffect(() => () => material.dispose(), [material]);
React.useEffect(() => {
material.uniforms.aspect.value = size.width / Math.max(1, size.height);
invalidate();
}, [material, size, invalidate]);
React.useEffect(() => {
if (kind !== "distortion") return;
let cancelled = false;
const canvas = document.createElement("canvas");
canvas.width = 900;
canvas.height = 600;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#dadfcf";
ctx.fillRect(0, 0, 900, 600);
ctx.fillStyle = "#28352d";
ctx.font = "bold 190px sans-serif";
ctx.fillText("FORM", 105, 350);
ctx.fillStyle = "#c36943";
ctx.fillRect(105, 405, 680, 14);
let texture: THREE.Texture = new THREE.CanvasTexture(canvas);
material.uniforms.picture.value = texture;
invalidate();
if (imageSrc)
new THREE.TextureLoader().load(
imageSrc,
(t) => {
if (cancelled) {
t.dispose();
return;
}
texture.dispose();
texture = t;
material.uniforms.picture.value = t;
material.uniforms.imageAspect.value = t.image.width / t.image.height;
invalidate();
},
undefined,
() => {
invalidate();
},
);
return () => {
cancelled = true;
texture.dispose();
};
}, [kind, imageSrc, material, invalidate]);
useFrame(({ pointer }, d) => {
material.uniforms.time.value += Math.min(d, 0.05) * speed;
material.uniforms.pointer.value.lerp(pointer, 0.06);
if (kind === "terrain" && ref.current) {
ref.current.rotation.z = THREE.MathUtils.damp(
ref.current.rotation.z,
-0.16 + pointer.x * 0.08,
3,
d,
);
}
});
return (
<mesh
ref={ref}
material={material}
rotation={kind === "terrain" ? [-0.58, 0, -0.16] : [0, 0, 0]}
>
{kind === "terrain" ? (
<planeGeometry args={[6.8, 4.2, 220, 160]} />
) : (
<planeGeometry args={[2, 2]} />
)}
</mesh>
);
}
export function WebGLScene(props: SceneProps) {
if (
props.kind === "silk" ||
props.kind === "eclipse" ||
props.kind === "tunnel" ||
props.kind === "constellation"
)
return <AtmosphereScene {...props} kind={props.kind} />;
if (props.kind === "ribbons") return <RibbonScene {...props} />;
if (props.kind === "particles") return <Field {...props} />;
return <Surface {...props} />;
}
registry/ui/webgl-ribbons.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
// The surface and its normal are evaluated together on the GPU. Geometry stays
// immutable; there are no per-frame buffers, React updates, or texture requests.
const vertex = /* glsl */ `
uniform float time;
uniform float aspect;
uniform float strand;
uniform float orbit;
uniform vec2 pointer;
varying vec3 vNormal;
varying vec3 vPosition;
varying vec2 vUv;
vec3 surface(vec2 p) {
float u = p.x;
float phase = strand * .19;
float wave = u * 6.28318 + time * .24 + phase;
float width = (.16 + .22 * pow(sin(u * 3.14159), 2.)) * (1. + strand * .045);
float twist = wave * .72 + strand * .32;
vec3 center = vec3(
(u - .5) * max(6., aspect * 5.4),
sin(wave) * .86 + (strand - 3.) * .28,
cos(wave + strand * .23) * .58
);
center.y += sin(u * 3.14159) * pointer.y * .4;
center.z += sin(u * 3.14159) * pointer.x * .5;
if (orbit > .5) {
float angle = u * 6.28318;
float radius = 1.1 + strand * .12;
center = vec3(cos(angle) * radius, sin(angle) * radius * .65, sin(angle * 2. + time * .24 + phase) * .55);
twist = angle * 2. + phase + time * .08;
}
return center + vec3(0., cos(twist), sin(twist)) * (p.y - .5) * width;
}
void main() {
vUv = uv;
vec3 p = surface(uv);
vec3 along = surface(uv + vec2(.0005, 0.)) - p;
vec3 across = surface(uv + vec2(0., .0005)) - p;
vNormal = normalize(normalMatrix * normalize(cross(along, across)));
vec4 view = modelViewMatrix * vec4(p, 1.);
vPosition = view.xyz;
gl_Position = projectionMatrix * view;
}`;
const fragment = /* glsl */ `
uniform vec3 tint;
uniform float strand;
varying vec3 vNormal;
varying vec3 vPosition;
varying vec2 vUv;
void main() {
vec3 n = normalize(vNormal) * (gl_FrontFacing ? 1. : -1.);
vec3 eye = normalize(-vPosition);
vec3 light = normalize(vec3(-.3, .9, 1.2));
float diffuse = max(dot(n, light), 0.);
float rim = pow(1. - abs(dot(n, eye)), 3.);
float specular = pow(max(dot(n, normalize(light + eye)), 0.), 42.);
float fold = pow(max(dot(n, normalize(vec3(.4, -.8, .9))), 0.), 8.);
vec3 base = mix(tint, vec3(.82, .88, .92), mod(strand, 3.) * .19);
vec3 color = base * (.13 + diffuse * .66) + vec3(.93, .97, 1.) * specular * .8;
color += base * fold * .25 + vec3(.7, .83, .94) * rim * .28;
// Fine longitudinal highlights suggest a surface, without a texture asset.
color *= .97 + .03 * sin(vUv.y * 210.);
gl_FragColor = vec4(color, 1.);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`;
function Ribbon({
index,
color,
speed,
orbit,
}: {
index: number;
color: string;
speed: number;
orbit: boolean;
}) {
const { size } = useThree();
const material = React.useMemo(
() =>
new THREE.ShaderMaterial({
vertexShader: vertex,
fragmentShader: fragment,
side: THREE.DoubleSide,
uniforms: {
time: { value: 0 },
aspect: { value: 1 },
strand: { value: index },
orbit: { value: orbit ? 1 : 0 },
pointer: { value: new THREE.Vector2() },
tint: { value: new THREE.Color(color) },
},
}),
[index, orbit, color],
);
React.useEffect(() => () => material.dispose(), [material]);
material.uniforms.aspect.value = size.width / Math.max(size.height, 1);
useFrame(({ pointer }, delta) => {
material.uniforms.time.value += Math.min(delta, 0.05) * speed;
material.uniforms.pointer.value.lerp(pointer, 1 - Math.exp(-delta * 3));
});
return (
<mesh material={material} frustumCulled={false}>
<planeGeometry args={[1, 1, 192, 6]} />
</mesh>
);
}
export function RibbonScene({
color,
speed,
composition,
}: {
color: string;
speed: number;
composition?: "fold" | "orbit";
}) {
return (
<group rotation={[0, 0, -0.16]}>
{Array.from({ length: 7 }, (_, i) => (
<Ribbon
key={i}
index={i}
color={color}
speed={speed}
orbit={composition === "orbit"}
/>
))}
</group>
);
}
export function RibbonPoster({ color }: { color: string }) {
const id = React.useId().replaceAll(":", "");
return (
<svg
viewBox="0 0 1200 480"
preserveAspectRatio="xMidYMid slice"
className="h-full w-full"
aria-hidden="true"
>
<defs>
<linearGradient id={id} x1="0" y1="0" x2="1" y2="1">
<stop stopColor={color} stopOpacity=".3" />
<stop offset=".45" stopColor={color} />
<stop offset=".7" stopColor="#e1edf4" />
<stop offset="1" stopColor={color} stopOpacity=".4" />
</linearGradient>
</defs>
{Array.from({ length: 7 }, (_, i) => (
<path
key={i}
d={`M -100 ${180 + i * 25} C 170 ${-120 + i * 12}, 310 ${570 - i * 14}, 610 ${290 + i * 14} S 960 ${70 + i * 12}, 1320 ${240 + i * 22}`}
fill="none"
stroke={`url(#${id})`}
strokeWidth={12 + i * 2}
/>
))}
</svg>
);
}
registry/ui/webgl-shaders.ts
Scroll horizontally for long lines
/** Procedural materials for the Jez WebGL scenes. No external textures required. */
export const screenVertex = `varying vec2 uvScreen; void main(){uvScreen=uv;gl_Position=vec4(position.xy,0.,1.);}`;
const common = `
varying vec2 uvScreen; uniform float time; uniform float aspect; uniform vec2 pointer; uniform vec3 tint;
float hash(vec3 p){p=fract(p*.3183099+vec3(.1,.2,.3));p*=17.;return fract(p.x*p.y*p.z*(p.x+p.y+p.z));}
float noise(vec3 p){vec3 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(mix(hash(i),hash(i+vec3(1,0,0)),f.x),mix(hash(i+vec3(0,1,0)),hash(i+vec3(1,1,0)),f.x),f.y),mix(mix(hash(i+vec3(0,0,1)),hash(i+vec3(1,0,1)),f.x),mix(hash(i+vec3(0,1,1)),hash(i+vec3(1,1,1)),f.x),f.y),f.z);}
float fbm(vec3 p){float v=0.,a=.5;for(int i=0;i<4;i++){v+=noise(p)*a;p=p*2.02+13.1;a*=.5;}return v;}
vec3 studio(vec3 r){float band=pow(max(0.,sin(r.y*5.+r.x*2.)),12.);vec3 c=mix(vec3(.035,.04,.085),vec3(.85,.90,1.),smoothstep(-.3,.9,r.y));c+=vec3(1.,.8,.6)*band*1.2;c+=vec3(.26,.38,.8)*pow(max(0.,r.x),6.);return c;}
`;
export const orbFragment = common + `
float shape(vec3 p){return length(p)-.96-sin(p.y*3.+time*.3)*sin(p.x*2.5+time*.12)*.045;}
void main(){vec2 q=(uvScreen-.5)*vec2(aspect,1.);q-=pointer*.045;vec3 ro=vec3(0,0,3.5),rd=normalize(vec3(q*2.5,-3.));float t=0.;bool hit=false;for(int i=0;i<60;i++){float d=shape(ro+rd*t);if(d<.0015){hit=true;break;}t+=d*.85;if(t>6.)break;}
vec3 bg=mix(vec3(.055,.045,.075),vec3(.17,.115,.16),clamp(1.-length(q)*.65,0.,1.));
if(hit){vec3 p=ro+rd*t;float e=.002;vec3 n=normalize(vec3(shape(p+vec3(e,0,0))-shape(p-vec3(e,0,0)),shape(p+vec3(0,e,0))-shape(p-vec3(0,e,0)),shape(p+vec3(0,0,e))-shape(p-vec3(0,0,e))));vec3 r=reflect(rd,n);float fres=pow(1.-max(0.,dot(n,-rd)),3.);float flow=sin(n.y*3.+n.x*2.+time*.12);vec3 metal=mix(tint,vec3(.3,.33,.62),smoothstep(-.4,.8,flow));vec3 c=studio(r)*metal*1.55;c+=vec3(.55,.67,.9)*fres*.65;bg=c/(c+.65);}
float grain=(hash(vec3(gl_FragCoord.xy,time*.01))-.5)/180.;gl_FragColor=vec4(bg+grain,1.);}
`;
export const liquidFragment = common + `
float water(vec2 p){vec2 c=pointer*vec2(aspect,1.)*.6;float d=length(p-c);return sin(p.x*3.+p.y*2.+time*.45)*.15+sin(p.y*5.-p.x*1.4-time*.3)*.10+sin(d*10.-time*1.3)*exp(-d*2.)*.012;}
void main(){vec2 p=(uvScreen-.5)*vec2(aspect,1.)*3.;float e=.008;float dx=(water(p+vec2(e,0))-water(p-vec2(e,0)))/(2.*e);float dy=(water(p+vec2(0,e))-water(p-vec2(0,e)))/(2.*e);vec3 n=normalize(vec3(-dx,-dy,1.));vec3 r=reflect(normalize(vec3(p*.08,-1.)),n);float line=pow(.5+.5*sin((r.x+r.y)*9.+water(p)*8.),8.);vec3 c=mix(vec3(.055,.075,.17),tint*.65,clamp(r.y*.65+.45,0.,1.));c+=studio(r)*.4;c=mix(c,vec3(.94,.79,.60),line*.25);c*=1.-length(uvScreen-.5)*.3;gl_FragColor=vec4(c/(c+.65),1.);}
`;
export const distortionFragment = common + `
uniform sampler2D picture; uniform float imageAspect;
void main(){vec2 p=uvScreen;vec2 center=.5+pointer*.5;vec2 delta=(p-center)*vec2(aspect,1.);float d=length(delta);float lens=exp(-d*d*9.);vec2 offset=normalize(delta+vec2(.001))*sin(d*30.-time*1.1)*.018*lens;offset+=vec2(sin(p.y*7.+time*.3),cos(p.x*6.+time*.2))*.004;vec2 cover=vec2(min(1.,aspect/imageAspect),min(1.,imageAspect/aspect));vec2 sampleUv=(p-.5+offset)*cover+.5;float split=.008*lens;vec3 c=vec3(texture2D(picture,sampleUv+vec2(split,0)).r,texture2D(picture,sampleUv).g,texture2D(picture,sampleUv-vec2(split,0)).b);gl_FragColor=vec4(c,1.);}
`;
export const terrainVertex = `varying vec3 vPos;varying float elevation;uniform float time;
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}float noise(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(hash(i),hash(i+vec2(1,0)),f.x),mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),f.x),f.y);}float hills(vec2 p){float v=0.,a=.5;for(int i=0;i<5;i++){v+=a*noise(p);p=p*2.03+3.1;a*=.5;}return v;}
void main(){vec3 p=position;float h=hills(p.xy*.85+vec2(time*.018,0));p.z=pow(h,2.)*2.-.5;elevation=p.z;vPos=p;gl_Position=projectionMatrix*modelViewMatrix*vec4(p,1.);}`;
export const terrainFragment = `varying vec3 vPos;varying float elevation;uniform vec3 tint;void main(){vec3 n=normalize(cross(dFdx(vPos),dFdy(vPos)));float light=.65+.35*abs(dot(n,normalize(vec3(-.5,.7,1.))));float contours=abs(fract(elevation*18.)-.5);float ink=1.-smoothstep(.018,.06,contours);vec3 c=mix(vec3(.07,.14,.13),tint,clamp(elevation*.9+.15,0.,1.));c=mix(c,vec3(.88,.88,.66),ink*.4);c*=light;gl_FragColor=vec4(c,1.);}`;
export const particleVertex = `attribute float seed;varying float brightness;uniform float time;uniform vec2 pointer;void main(){vec3 p=position;float a=time*.06+(length(p.xy))*.17;mat2 r=mat2(cos(a),-sin(a),sin(a),cos(a));p.xy=r*p.xy;p.z+=sin(seed*35.+time*.3)*.08;p.x+=pointer.x*.12;p.y+=pointer.y*.08;vec4 mv=modelViewMatrix*vec4(p,1.);brightness=.45+.55*seed;gl_PointSize=(1.2+seed*1.8)*(4./-mv.z);gl_Position=projectionMatrix*mv;}`;
export const particleFragment = `varying float brightness;uniform vec3 tint;void main(){float d=length(gl_PointCoord-.5);if(d>.5)discard;vec3 c=mix(tint,vec3(.92,.94,1.),brightness*.7);gl_FragColor=vec4(c,(1.-smoothstep(.12,.5,d))*brightness);}`;
registry/ui/webgl-atmospheres.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
export type AtmosphereKind = "silk" | "eclipse" | "tunnel" | "constellation";
const fragments: Record<AtmosphereKind, string> = {
silk: `float v=0.; for(int i=0;i<7;i++){float f=float(i);float y=p.y+.19*sin(p.x*2.8+t*.3+f*.42)+.08*sin(p.x*6.-t*.2);v+=.006/(abs(y-f*.075+.24)+.007);} col=tint*v*.45;`,
eclipse: `float r=length(p);float a=atan(p.y,p.x);float ring=exp(-abs(r-.49)*55.);float corona=exp(-abs(r-.5)*9.)*.18*(.6+.4*sin(a*9.+t*.25));col=tint*(ring+corona);col+=vec3(1.,.82,.5)*exp(-length(p-vec2(.35,.35))*23.);col*=smoothstep(.455,.48,r);`,
tunnel: `float r=max(length(p),.015);float a=atan(p.y,p.x);float z=1./r+t*.4;float rings=pow(.5+.5*cos(z*5.),24.);float rays=pow(.5+.5*cos(a*14.+sin(z)*.3),34.);col=tint*(rings*.65+rays*.3)*smoothstep(.03,.35,r)*(.7+.3*sin(a+t*.15));`,
constellation: `for(int i=0;i<30;i++){float f=float(i);vec2 q=vec2(sin(f*19.1+t*.025),cos(f*7.7+t*.035))*.72;float d=length(p-q);col+=tint*.00055/(d*d+.0004);vec2 q2=vec2(sin((f+1.)*19.1+t*.025),cos((f+1.)*7.7+t*.035))*.72;vec2 pa=p-q,ba=q2-q;float h=clamp(dot(pa,ba)/dot(ba,ba),0.,1.);col+=tint*.012*exp(-length(pa-ba*h)*300.);}`,
};
export function AtmosphereScene({
kind,
color,
speed,
}: {
kind: AtmosphereKind;
color: string;
speed: number;
}) {
const { size, invalidate } = useThree();
const material = React.useMemo(
() =>
new THREE.ShaderMaterial({
vertexShader: `varying vec2 uv0;void main(){uv0=uv;gl_Position=vec4(position.xy,0.,1.);}`,
fragmentShader: `precision highp float; varying vec2 uv0; uniform float time; uniform float aspect; uniform vec3 tint; uniform vec2 pointer;void main(){vec2 p=(uv0-.5)*2.;p.x*=aspect;p-=pointer*.08;float t=time;vec3 col=vec3(.012,.016,.02);${fragments[kind]}gl_FragColor=vec4(col,1.);}`,
uniforms: {
time: { value: 0 },
aspect: { value: 1 },
tint: { value: new THREE.Color(color) },
pointer: { value: new THREE.Vector2() },
},
}),
[kind, color],
);
React.useEffect(() => () => material.dispose(), [material]);
React.useEffect(() => {
material.uniforms.aspect.value = size.width / Math.max(size.height, 1);
invalidate();
}, [material, size, invalidate]);
useFrame(({ pointer }, delta) => {
material.uniforms.time.value += Math.min(delta, 0.05) * speed;
material.uniforms.pointer.value.lerp(pointer, 0.035);
});
return (
<mesh material={material}>
<planeGeometry args={[2, 2]} />
</mesh>
);
}
Dependencies
clsx@2.1.1 · tailwind-merge@3.5.0 · lucide-react@0.577.0 · @react-three/fiber@9.7.0 · three@0.183.2
Accessibility & behaviour
Provide meaningful labels and preserve keyboard focus styles. Check contrast when customising theme colours.
Read the accessibility and performance guide →