A dry garden seen from directly above: pale sand combed into even lines, three dark stones, and five rings raked around each stone where the straight lines stop. A low sun rakes across it, so every ridge has a lit side and a shaded side, the stones throw long shadows over the furrows, and single grains glitter. The sun is not fixed. It swings round and sinks over about half a minute and comes back, which makes the shadows turn and stretch like a sundial. In the top corner the soft shade of an overhanging branch shifts as if in a light wind. Point at the block and the sun moves to stand where the pointer is. Apart from one small caption the block is empty. On phones the stones keep their place as a share of the block, so all three stay in frame.
import { useEffect, useRef, useState } from "react";
// Everything about the garden. Colours are red, green, blue from 0 to 1.
const garden = {
speed: 1, // how fast the sun and the branch move; 0 stops the afternoon
grooves: 34, // rake lines across the short side of the block
rings: 5, // rake lines circling each stone before the straight lines take over
relief: 0.24, // how deep the rake cut; 0.15 is a light comb, 0.45 a heavy one
canopy: 0.8, // darkness of the branch shadow in the corner; 0 clears the sky
quality: 0.9, // share of the device pixels that are drawn
maxPixels: 1000000, // the canvas never draws more pixels than this
sand: [0.93, 0.85, 0.71],
skylight: [0.64, 0.61, 0.66], // the colour of the shadows
stone: [0.25, 0.25, 0.27],
// x and y place a stone, from -0.5 to 0.5 across the block. size is in rake lines, stretch makes it long,
// turn spins it (in radians) and height says how far it stands out of the sand.
stones: [
{ x: 0.2, y: 0.08, size: 3.3, stretch: 1.45, turn: 0.5, height: 3.4 },
{ x: -0.27, y: -0.2, size: 2.1, stretch: 1.2, turn: -0.9, height: 2.0 },
{ x: -0.14, y: 0.27, size: 1.25, stretch: 1.3, turn: 2.2, height: 1.1 },
],
};
const STONES = garden.stones.length;
const VERTEX = `
attribute vec2 position;
void main() { gl_Position = vec4(position, 0.0, 1.0); }`;
const FRAGMENT = `
precision highp float;
uniform vec2 uRes;
uniform float uTime;
uniform float uGrooves;
uniform float uRings;
uniform float uRelief;
uniform float uCanopy;
uniform vec2 uSun;
uniform vec4 uStones[${STONES}];
uniform vec2 uShape[${STONES}];
uniform vec3 uSand;
uniform vec3 uSky;
uniform vec3 uStone;
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y);
}
// One stone. x is the distance from its edge in rake lines (negative inside); y runs from 0 at its
// middle to 1 at its edge. The outline is an ellipse with a few lumps, so no two stones match.
vec2 stoneEdge(vec2 p, vec4 s, vec2 shape) {
vec2 d = p - s.xy + 0.001;
float c = cos(shape.x);
float si = sin(shape.x);
d = vec2(d.x * c + d.y * si, -d.x * si + d.y * c);
float a = atan(d.y, d.x * 0.8);
float lumps = 1.0 + 0.05 * sin(3.0 * a + shape.x * 7.0) + 0.03 * sin(5.0 * a + 1.3 + shape.x * 3.0);
vec2 r = s.z * lumps * vec2(s.w, 1.0);
float k1 = length(d / r);
float k2 = length(d / (r * r));
return vec2(k1 * (k1 - 1.0) / k2, k1);
}
// Height of the garden at a point: x is the height, y the distance to the nearest stone.
vec2 ground(vec2 p) {
float near = 1000.0;
float rock = 0.0;
for (int i = 0; i < ${STONES}; i++) {
vec2 d = stoneEdge(p, uStones[i], uShape[i]);
near = min(near, d.x);
// A river stone: steep where it meets the sand, nearly flat on top.
float t = min(d.y, 1.0);
rock = max(rock, uShape[i].y * sqrt(1.0 - t * t * t * t));
}
// Straight lines pulled by hand are never quite straight.
float line = p.y * 0.9 + p.x * 0.44 + 0.3 * sin(p.x * 0.19 + 1.3) + 0.14 * sin(p.x * 0.47 + p.y * 0.13);
float c = near < uRings ? near : line;
float s = sin(3.14159265 * c);
float furrow = 1.0 - sqrt(s * s + 0.035);
// The comb lifts where the straight lines meet the outermost ring, which leaves a smooth seam.
float sandH = uRelief * furrow * smoothstep(0.0, 0.5, near) * smoothstep(0.0, 0.3, abs(near - uRings));
sandH += 0.34 * exp(-max(near, 0.0) * 2.2);
sandH += 0.12 * sin(p.x * 0.11 + 0.7) * sin(p.y * 0.14 + 2.1);
return vec2(max(sandH, rock), near);
}
void main() {
float unit = min(uRes.x, uRes.y);
float pxSize = uGrooves / unit;
vec2 p = (gl_FragCoord.xy - 0.5 * uRes) * pxSize;
vec2 here = ground(p);
float e = 0.05;
vec2 slope = vec2(ground(p + vec2(e, 0.0)).x - here.x, ground(p + vec2(0.0, e)).x - here.x) / e;
float isStone = smoothstep(pxSize, -pxSize, here.y);
// Grains: every pixel of sand leans its own way a little, which is what makes it glitter in low sun.
vec2 fc = floor(gl_FragCoord.xy);
vec2 grit = vec2(hash(fc), hash(fc + 19.19)) - 0.5;
vec2 lumps = vec2(noise(p * 5.0), noise(p * 5.0 + 31.0)) - 0.5;
vec2 rough = mix(grit * 0.36 + lumps * 0.2, (vec2(noise(p * 1.5), noise(p * 1.5 + 9.0)) - 0.5) * 0.16 + grit * 0.07, isStone);
vec3 n = normalize(vec3(-slope + rough, 1.0));
float ce = cos(uSun.y);
vec3 L = vec3(cos(uSun.x) * ce, sin(uSun.x) * ce, sin(uSun.y));
// Walk toward the sun. Anything standing above the ray, a stone or the next ridge of sand, shades this point.
vec3 at = vec3(p, here.x);
float shade = 1.0;
float stepLen = 0.07;
float along = 0.0;
for (int i = 0; i < 18; i++) {
along += stepLen;
stepLen *= 1.33;
vec3 s = at + L * along;
float gap = s.z - ground(s.xy).x;
shade = min(shade, 0.55 + 5.0 * gap / along);
}
shade = smoothstep(0.0, 1.0, clamp(shade, 0.0, 1.0));
// A branch overhangs one corner. Its leaves throw soft, shifting patches.
vec2 uv = gl_FragCoord.xy / uRes;
vec2 lp = p * 0.085 - L.xy * 1.1 + vec2(sin(uTime * 0.31), cos(uTime * 0.23)) * 0.09;
float leaves = 0.58 * noise(lp * 1.5 + 3.0) + 0.3 * noise(lp * 3.7 + vec2(uTime * 0.04, 0.0)) + 0.12 * noise(lp * 8.1);
float corner = smoothstep(0.85, 1.5, uv.x * 0.85 + uv.y * 0.95 + 0.3 * noise(lp * 0.8));
float canopy = smoothstep(0.52, 0.4, leaves) * corner * uCanopy;
float lit = max(dot(n, L), 0.0);
float direct = lit * shade * (1.0 - canopy);
vec3 sun = vec3(1.0, 0.93, 0.8);
// Sand.
float tone = 0.92 + 0.12 * noise(p * 1.7) + 0.05 * (hash(fc + 3.0) - 0.5);
vec3 sand = uSand * tone;
sand *= 1.0 - 0.3 * step(0.992, hash(fc + 71.3));
sand *= 1.0 - 0.3 * exp(-max(here.y, 0.0) * 1.6);
vec3 col = sand * (uSky * 0.78 + sun * direct * 1.5);
col += sun * pow(lit, 24.0) * shade * (1.0 - canopy) * step(0.93, hash(fc + 5.5)) * 0.35;
// Stone: dark, a little speckled, with a dull shine where it faces the sun.
float speck = noise(p * 6.0) * 0.5 + hash(fc) * 0.5;
vec3 rock = uStone * (0.9 + 0.2 * noise(p * 0.9 + 4.0)) * (0.9 + 0.2 * speck);
vec3 H = normalize(L + vec3(0.0, 0.0, 1.0));
vec3 rockCol = rock * (uSky * 0.85 + sun * direct * 2.6) + sun * pow(max(dot(n, H), 0.0), 26.0) * shade * (1.0 - canopy) * 0.22;
col = mix(col, rockCol, isStone);
vec2 c = uv - 0.5;
col *= 1.0 - 0.3 * dot(c, c);
col = mix(col, 1.0 - exp(-col * 1.4), 0.35);
gl_FragColor = vec4(pow(max(col, 0.0), vec3(0.95)), 1.0);
}`;
const mix = (a: number, b: number, t: number) => a + (b - a) * t;
/** One fragment shader fills the block. `onLive` reports when a frame has been painted. */
function SandCanvas({ onLive }: { onLive: (live: boolean) => void }) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
const gl = canvas?.getContext("webgl", { antialias: false, alpha: false });
if (!canvas || !gl) return; // no WebGL: the CSS garden underneath stays
const compile = (type: number, source: string) => {
const s = gl.createShader(type)!;
gl.shaderSource(s, source);
gl.compileShader(s);
return s;
};
const program = gl.createProgram()!;
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAGMENT));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const position = gl.getAttribLocation(program, "position");
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
const at = (name: string) => gl.getUniformLocation(program, name);
const u = { res: at("uRes"), time: at("uTime"), sun: at("uSun"), stones: at("uStones"), shape: at("uShape") };
gl.uniform1f(at("uGrooves"), garden.grooves);
gl.uniform1f(at("uRings"), garden.rings);
gl.uniform1f(at("uRelief"), garden.relief);
gl.uniform1f(at("uCanopy"), garden.canopy);
gl.uniform3fv(at("uSand"), garden.sand);
gl.uniform3fv(at("uSky"), garden.skylight);
gl.uniform3fv(at("uStone"), garden.stone);
gl.uniform2fv(u.shape, garden.stones.flatMap((s) => [s.turn, s.height]));
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// The sun: its compass direction and its height above the horizon, both in radians.
const sun = { turn: 2.5, lift: 0.42, toTurn: 2.5, toLift: 0.42, touched: -1e9 };
let raf = 0;
let visible = true;
let time = 12;
let before = 0;
const draw = () => {
gl.uniform1f(u.time, time);
gl.uniform2f(u.sun, sun.turn, sun.lift);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const resize = () => {
const w = canvas.clientWidth || 1;
const h = canvas.clientHeight || 1;
const scale = Math.min(Math.min(Math.max(window.devicePixelRatio || 1, 1), 2) * garden.quality, Math.sqrt(garden.maxPixels / (w * h)));
canvas.width = Math.max(1, Math.round(w * scale));
canvas.height = Math.max(1, Math.round(h * scale));
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform2f(u.res, canvas.width, canvas.height);
// Stones are placed as a share of the block, so they stay in frame on a tall phone.
const short = Math.min(w, h);
gl.uniform4fv(u.stones, garden.stones.flatMap((s) => [(s.x * w * garden.grooves) / short, (s.y * h * garden.grooves) / short, s.size, s.stretch]));
draw();
onLive(true);
};
const loop = (now: number) => {
raf = requestAnimationFrame(loop);
const dt = Math.min((now - (before || now)) / 1000, 0.05);
before = now;
time += dt * garden.speed;
// Left alone, the afternoon runs by itself: the sun swings round and sinks, then comes back.
if (now - sun.touched > 2600) {
sun.toTurn = 2.55 + 0.75 * Math.sin(time * 0.21);
sun.toLift = 0.4 + 0.14 * Math.sin(time * 0.33 + 1.0);
}
const k = 1 - Math.exp(-dt * 2.6);
sun.turn = mix(sun.turn, sun.toTurn, k);
sun.lift = mix(sun.lift, sun.toLift, k);
draw();
};
const start = () => {
cancelAnimationFrame(raf);
before = 0;
if (still) draw();
else if (visible && !document.hidden) raf = requestAnimationFrame(loop);
};
const onPointer = (e: PointerEvent) => {
const box = canvas.getBoundingClientRect();
const x = (e.clientX - box.left) / box.width - 0.5;
const y = 0.5 - (e.clientY - box.top) / box.height;
if (Math.abs(x) > 0.5 || Math.abs(y) > 0.5) return;
// The sun stands where the pointer is: its direction from the middle of the garden, and lower the further out it goes.
sun.toTurn = Math.atan2(y * box.height, x * box.width);
// Take the short way round when the pointer crosses behind the centre.
while (sun.toTurn - sun.turn > Math.PI) sun.toTurn -= Math.PI * 2;
while (sun.toTurn - sun.turn < -Math.PI) sun.toTurn += Math.PI * 2;
sun.toLift = mix(0.85, 0.24, Math.min(1, Math.hypot(x, y) * 2));
sun.touched = performance.now();
};
const onLost = (e: Event) => {
e.preventDefault();
cancelAnimationFrame(raf);
onLive(false);
};
const sizeWatcher = new ResizeObserver(resize);
sizeWatcher.observe(canvas);
const viewWatcher = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
start();
});
viewWatcher.observe(canvas);
document.addEventListener("visibilitychange", start);
if (!still) window.addEventListener("pointermove", onPointer, { passive: true });
canvas.addEventListener("webglcontextlost", onLost);
resize();
start();
return () => {
cancelAnimationFrame(raf);
sizeWatcher.disconnect();
viewWatcher.disconnect();
document.removeEventListener("visibilitychange", start);
window.removeEventListener("pointermove", onPointer);
canvas.removeEventListener("webglcontextlost", onLost);
gl.getExtension("WEBGL_lose_context")?.loseContext();
};
}, [onLive]);
return <canvas ref={ref} aria-hidden="true" className="absolute inset-0 h-full w-full" />;
}
/** The garden without a shader: CSS rake lines, two stones and their rings. Shown first, and kept when WebGL is missing. */
function StillSand() {
const lines = "repeating-linear-gradient(176deg, #e9dcc3 0px, #d9c8a8 9px, #b9a481 15px, #efe3cc 19px, #e9dcc3 22px)";
const rings = "repeating-radial-gradient(ellipse at center, #e9dcc3 0px, #d9c8a8 9px, #b9a481 15px, #efe3cc 19px, #e9dcc3 22px)";
return (
<div aria-hidden="true" className="absolute inset-0 overflow-hidden bg-[#dccdb0]" style={{ backgroundImage: lines }}>
{[
{ left: "70%", top: "42%", w: 190, h: 132, turn: -28 },
{ left: "23%", top: "70%", w: 110, h: 92, turn: 40 },
].map((s) => (
<span key={s.left} className="absolute -translate-x-1/2 -translate-y-1/2 rounded-[50%]" style={{ left: s.left, top: s.top, width: s.w + 220, height: s.h + 220, backgroundImage: rings, transform: `translate(-50%, -50%) rotate(${s.turn}deg)` }}>
<span className="absolute left-1/2 top-1/2 rounded-[46%_54%_52%_48%/55%_48%_52%_45%] bg-[radial-gradient(circle_at_34%_30%,#5b5a5c_0%,#2c2b2e_55%,#1a191c_100%)] shadow-[26px_30px_26px_-6px_rgba(60,52,70,0.55)]" style={{ width: s.w, height: s.h, transform: "translate(-50%, -50%)" }} />
</span>
))}
<div className="raked-zen-sand-garden-webgl-background-with-basalt-stones-and-a-slow-turning-sun-shadow-shade absolute -inset-[10%] bg-[radial-gradient(34%_30%_at_88%_14%,rgba(70,66,84,0.5)_0%,rgba(70,66,84,0)_100%),radial-gradient(22%_26%_at_66%_6%,rgba(70,66,84,0.4)_0%,rgba(70,66,84,0)_100%),radial-gradient(20%_30%_at_98%_44%,rgba(70,66,84,0.38)_0%,rgba(70,66,84,0)_100%)]" />
</div>
);
}
export default function RakedZenSandGardenWebglBackgroundWithBasaltStonesAndASlowTurningSunShadow() {
const [live, setLive] = useState(false);
return (
<section className="relative isolate h-[620px] overflow-hidden bg-[#dccdb0] sm:h-[760px]">
<style>{`
@keyframes raked-zen-sand-garden-webgl-background-with-basalt-stones-and-a-slow-turning-sun-shadow-shade { 0%, 100% { transform: translate3d(0, 0, 0); } 50% { transform: translate3d(-2.5%, 2%, 0); } }
.raked-zen-sand-garden-webgl-background-with-basalt-stones-and-a-slow-turning-sun-shadow-shade { animation: raked-zen-sand-garden-webgl-background-with-basalt-stones-and-a-slow-turning-sun-shadow-shade 7s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) { .raked-zen-sand-garden-webgl-background-with-basalt-stones-and-a-slow-turning-sun-shadow-shade { animation: none; } }
`}</style>
<StillSand />
<div className={`pointer-events-none absolute inset-0 transition-opacity duration-700 motion-reduce:transition-none ${live ? "opacity-100" : "opacity-0"}`}>
<SandCanvas onLive={setLive} />
</div>
<p className="absolute bottom-5 left-5 rounded-full bg-[#211d18]/85 px-3.5 py-2 font-mono text-[11px] uppercase tracking-[0.2em] text-stone-50 sm:bottom-7 sm:left-7">
Raked sand <span className="text-stone-300">/ three stones, late sun</span>
</p>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/raked-zen-sand-garden-webgl-background-with-basalt-stones-and-a-slow-turning-sun-shadow.json
For every pixel the shader finds the distance to the edge of the nearest stone. Within five lines of a stone that distance is used to place the ridges, which makes rings that follow the stone's own outline. Further out, the ridges follow a nearly straight coordinate with a slight hand-made wobble. The switch between the two is abrupt on purpose, as it is in a real garden.
They are worked out. From each pixel the shader takes eighteen steps toward the sun and checks the height of the garden at every step. If a stone or the next ridge of sand stands above that line, the pixel is in shade, and how narrowly the line clears decides how soft the edge is. That is why the shadows lengthen when the sun drops.
Each pixel tilts its surface by a small random amount before lighting, so neighbouring grains catch the sun differently. A few pixels are darkened as flecks and a few facing the sun are brightened as glitter.
Add entries to `garden.stones`. The shader loops over however many there are. Each stone adds a little work to all nineteen height lookups per pixel, so past six or seven lower `garden.quality` or `garden.maxPixels`.
A CSS garden made from a repeating gradient for the straight lines and repeating radial gradients for the rings, with two shaded stones. It is always underneath, so there is no flash of empty space while the shader starts.
Related: Swimming Pool Floor WebGL Background with Mosaic Tiles Under Drifting Caustic Light, Wet Asphalt at Night WebGL Background with Puddles That Mirror Coloured Shop Signs and Rain Rings, Arched Stained Glass Window WebGL Background That Pours Coloured Light Across a Stone Floor as the Sun Moves.
More animated backgrounds or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.