About
One ray-marched signed-distance field. The resting shape is a sphere with four smaller ones orbiting inside it, merged with a smooth minimum, so it reads as a liquid drop; fourteen other shapes are signed-distance functions of their own (a beating heart from the exact 2D heart puffed into a pillow, a dumbbell with a knurled grip, a gift with a ribbon and a bow, a pin with a hole, a cut gem from folded planes...). Any two are blended by weight, so a shape melts into the next instead of cutting to it. The skin is four lighting models over one four-colour palette that swirls inside the volume: liquid (caustics and a fresnel rim), pearl (milky with a drift of hue), chrome (a small studio reflected in the metal) and glass (a clear, dim inside and a bright coloured rim). The shape sequence, the easing and the drag spin live in a small per-sphere piece beside the shared shader program.
Use it when
- A hero wants a signature object that is not a stock 3D asset: a fitness app (dumbbell, flame), a shop (gift, coin, pin), a studio (chat, spark, cube).
- An AI or assistant product wants a living mark that can show what it is doing, by shape.
- A landing page wants one memorable, touchable thing above the fold.
Skip it when
- The page already has a 3D centrepiece.
- A flat, quiet page where a moving object would compete with the copy.
Install
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the component with the shadcn CLI:
npx shadcn@latest add @vibebox/morph-sphere - 3Use it:
import { MorphSphere } from "@/components/vibebox/morph-sphere"; <MorphSphere pack="retail" preset="spectrum" className="aspect-square w-full max-w-[420px]" />
Code
3 filescomponents/vibebox/morph-sphere/index.tsx
/**
* VibeBox Motion · Morph Sphere
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
export { MorphSphere, SHAPE_NAMES, type MorphFinish, type MorphSphereProps } from "./morph-sphere.client";
export { MORPH_PACKS, MORPH_SHAPES, PRESET_NAMES as MORPH_SPHERE_PRESETS, type MorphPack, type MorphShape, type MorphSpherePreset } from "./morph-sphere.shader";components/vibebox/morph-sphere/morph-sphere.client.tsx
/**
* VibeBox Motion · Morph Sphere
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
"use client";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { BackdropCanvas, type PosterSpec } from "@/lib/vibebox/backdrop-canvas";
import { prefersReducedMotion } from "@/lib/vibebox/engine/core";
import {
MORPH_PACKS,
MORPH_SHAPES,
createMorphPiece,
presets,
type MorphPack,
type MorphParams,
type MorphShape,
type MorphSpherePreset,
} from "./morph-sphere.shader";
export type MorphFinish = "liquid" | "pearl" | "chrome" | "glass";
const FINISH_ID: Record<MorphFinish, number> = { liquid: 0, pearl: 1, chrome: 2, glass: 3 };
export type MorphSphereProps = {
/** What sits on or beside the sphere. */
children?: ReactNode;
/** A set of shapes for a kind of business: studio, fitness, gifting, retail, wellness or all. */
pack?: MorphPack;
/** Your own shapes, in order; takes the place of `pack`. */
shapes?: readonly MorphShape[];
/** The skin. Each palette has its own default. */
finish?: MorphFinish;
/** A named palette: spectrum, pearl, ember, mint or mono. */
preset?: MorphSpherePreset;
/** Overrides single palette colours with any CSS colour. */
colors?: Partial<Record<"a" | "b" | "c" | "d", string>>;
/** Seconds each shape holds before it melts into the next. */
hold?: number;
/** Move through the shapes by itself (paused while the pointer is over it). */
cycle?: boolean;
/** Tap, click or press Enter for the next shape; drag to turn it. */
interactive?: boolean;
/** Called when the sphere starts heading to a shape. */
onShape?: (shape: MorphShape) => void;
/** Multiplies the speed of the flow inside (1 keeps it). */
speed?: number;
/** Multiplies the brightness. */
intensity?: number;
/** Accessible name. */
label?: string;
/** Hold one composed frame (a gallery card, reduced motion). */
still?: boolean;
className?: string;
};
const POSTER: PosterSpec = { type: "orb", core: "c2", edge: "c1", halo: "c0", size: 34 };
const SHAPE_NAMES = Object.keys(MORPH_SHAPES) as MorphShape[];
export function MorphSphere({
children,
pack = "studio",
shapes,
finish,
preset = "spectrum",
colors,
hold = 3.2,
cycle = true,
interactive = true,
onShape,
speed,
intensity,
label = "A liquid sphere that changes shape",
still = false,
className,
}: MorphSphereProps) {
const piece = useMemo(() => createMorphPiece(), []);
const list = useMemo(() => (shapes?.length ? shapes : MORPH_PACKS[pack]).filter((name) => name in MORPH_SHAPES), [shapes, pack]);
const order = useMemo(() => list.map((name) => MORPH_SHAPES[name]), [list]);
const [target, setTarget] = useState(0);
const [taps, setTaps] = useState(0);
const [hovering, setHovering] = useState(false);
const reduced = typeof window !== "undefined" && prefersReducedMotion();
const settled = still || reduced;
// The sphere moves on by itself, and holds while it is being touched.
useEffect(() => {
if (!cycle || settled || hovering || list.length < 2) return;
const timer = window.setInterval(() => setTarget((value) => value + 1), Math.max(1, hold) * 1000);
return () => window.clearInterval(timer);
}, [cycle, settled, hovering, hold, list.length]);
const current = list[((target % list.length) + list.length) % list.length];
useEffect(() => {
if (current) onShape?.(current);
// onShape is the caller's; it should not restart the effect.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [current]);
// A drag turns the sphere; a short press with no movement is a tap.
const drag = useRef<{ x: number; y: number; moved: number; at: number } | null>(null);
const onPointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
drag.current = { x: event.clientX, y: event.clientY, moved: 0, at: performance.now() };
event.currentTarget.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: React.PointerEvent<HTMLButtonElement>) => {
const state = drag.current;
if (!state) return;
const dx = event.clientX - state.x;
const dy = event.clientY - state.y;
state.x = event.clientX;
state.y = event.clientY;
state.moved += Math.abs(dx) + Math.abs(dy);
if (state.moved > 4) piece.spin(dx * 0.012, dy * 0.012);
};
const next = () => {
setTarget((value) => value + 1);
setTaps((value) => value + 1);
};
const onPointerUp = (event: React.PointerEvent<HTMLButtonElement>) => {
const state = drag.current;
drag.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
if (state && state.moved <= 6 && performance.now() - state.at < 450) next();
};
const extra = useMemo(
() => ({ order, target, tapped: taps > 0 ? taps : null, settled }) as Omit<MorphParams, "colors" | "values">,
[order, target, taps, settled],
);
const overrides = useMemo(() => {
const out: Record<string, string> = {};
if (colors?.a) out.c0 = colors.a;
if (colors?.b) out.c1 = colors.b;
if (colors?.c) out.c2 = colors.c;
if (colors?.d) out.c3 = colors.d;
return out;
}, [colors]);
const values = useMemo(() => (finish ? { finish: FINISH_ID[finish] } : {}), [finish]);
return (
<div className={className} data-vb="morph-sphere" data-shape={current} style={{ position: "relative" } as CSSProperties}>
<BackdropCanvas<MorphSpherePreset, MorphParams>
slug="morph-sphere-canvas"
piece={piece}
presets={presets}
fallback="spectrum"
preset={preset}
colors={overrides}
values={values}
speed={speed}
intensity={intensity}
extra={extra}
poster={POSTER}
clearPoster
scale={0.8}
stillTime={1.7}
still={settled}
interactive={false}
className="size-full"
>
{children}
</BackdropCanvas>
{interactive && !settled ? (
<button
type="button"
aria-label={`${label}. Press for the next shape.`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={() => (drag.current = null)}
onPointerEnter={() => setHovering(true)}
onPointerLeave={() => setHovering(false)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
next();
}
}}
className="absolute inset-0 cursor-grab touch-pan-y rounded-[inherit] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring active:cursor-grabbing"
/>
) : (
<span role="img" aria-label={label} className="absolute inset-0" />
)}
</div>
);
}
export { SHAPE_NAMES };components/vibebox/morph-sphere/morph-sphere.shader.ts
/**
* VibeBox Motion · Morph Sphere
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import { compose, createScenePiece, type SceneParams, type ScenePreset } from "@/lib/vibebox/engine/backdrops";
import type { ShaderPiece } from "@/lib/vibebox/engine/gl";
/*
* A drop of liquid that becomes things. One ray-marched signed-distance field: the drop is a
* handful of merging spheres, and fourteen shapes (a heart, a dumbbell, a gift, a pin...) are
* blended into it two at a time, so any shape melts into any other. The surface is lit four
* ways (liquid, pearl, chrome, glass) from a four-colour palette that swirls inside it.
* Shapes follow the idea of the studio's own orbs (Vyra, Kai, Giftlyph, Kaha).
*/
export const MORPH_SHAPES = {
blob: 0,
dumbbell: 1,
heart: 2,
spark: 3,
flame: 4,
kettlebell: 5,
ring: 6,
moon: 7,
gift: 8,
chat: 9,
cube: 10,
gem: 11,
coin: 12,
pin: 13,
drop: 14,
} as const;
export type MorphShape = keyof typeof MORPH_SHAPES;
/** Shape sets for a kind of business. */
export const MORPH_PACKS = {
studio: ["blob", "chat", "spark", "cube", "ring", "gem", "heart"],
fitness: ["blob", "dumbbell", "heart", "flame", "kettlebell", "drop", "ring", "moon"],
gifting: ["blob", "gift", "heart", "spark", "chat", "ring"],
retail: ["blob", "coin", "gift", "pin", "cube", "gem", "heart"],
wellness: ["blob", "drop", "moon", "heart", "ring", "spark"],
all: ["blob", "dumbbell", "heart", "spark", "flame", "kettlebell", "ring", "moon", "gift", "chat", "cube", "gem", "coin", "pin", "drop"],
} as const satisfies Record<string, readonly MorphShape[]>;
export type MorphPack = keyof typeof MORPH_PACKS;
const SCENE = /* glsl */ `
uniform vec3 uC0;
uniform vec3 uC1;
uniform vec3 uC2;
uniform vec3 uC3;
uniform vec3 uTint;
uniform float uTintW;
uniform float uShapeA;
uniform float uShapeB;
uniform float uMixAB;
uniform float uPoke;
uniform float uYaw;
uniform float uPitch;
uniform float uFinish;
uniform float uScaleS;
uniform float uEnergy;
uniform float uFlow;
uniform float uSpeed;
uniform float uIntensity;
const float TAN_HALF = 0.42;
const float CAMERA_Z = 3.0;
float T;
mat3 R;
vec3 palette(float x) {
float t = fract(x) * 4.0;
vec3 c = mix(lin(uC0), lin(uC1), smoothstep(0.0, 1.0, t));
c = mix(c, lin(uC2), smoothstep(1.0, 2.0, t));
c = mix(c, lin(uC3), smoothstep(2.0, 3.0, t));
return mix(c, lin(uC0), smoothstep(3.0, 4.0, t));
}
float wave(float x) { return 0.5 - 0.5 * cos(TAU * x); }
float smin(float a, float b, float k) { float h = max(k - abs(a - b), 0.0) / k; return min(a, b) - h * h * k * 0.25; }
float sdSphere(vec3 p, float r) { return length(p) - r; }
float sdRoundBox(vec3 p, vec3 b, float r) {
vec3 q = abs(p) - b + r;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0) - r;
}
float sdCapsule(vec3 p, vec3 a, vec3 b, float r) {
vec3 pa = p - a, ba = b - a;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
return length(pa - ba * h) - r;
}
float sdTorus(vec3 p, float radius, float tube) { return length(vec2(length(p.xz) - radius, p.y)) - tube; }
float sdCappedCyl(vec3 p, float r, float h) {
vec2 d = abs(vec2(length(p.xz), p.y)) - vec2(r, h);
return min(max(d.x, d.y), 0.0) + length(max(d, 0.0));
}
float sdRoundCone(vec3 p, vec3 a, vec3 b, float r1, float r2) {
vec3 ba = b - a;
float l2 = dot(ba, ba);
float rr = r1 - r2;
float a2 = l2 - rr * rr;
float il2 = 1.0 / l2;
vec3 pa = p - a;
float y = dot(pa, ba);
float z = y - l2;
vec3 xv = pa * l2 - ba * y;
float x2 = dot(xv, xv);
float y2 = y * y * l2;
float z2 = z * z * l2;
float k = sign(rr) * rr * rr * x2;
if (sign(z) * a2 * z2 > k) return sqrt(x2 + z2) * il2 - r2;
if (sign(y) * a2 * y2 < k) return sqrt(x2 + y2) * il2 - r1;
return (sqrt(x2 * a2 * il2) + y * rr) * il2 - r1;
}
float ndot(vec2 a, vec2 b) { return a.x * b.x - a.y * b.y; }
float sdRhombus(vec2 p, vec2 b) {
p = abs(p);
float h = clamp(ndot(b - 2.0 * p, b) / dot(b, b), -1.0, 1.0);
float d = length(p - 0.5 * b * vec2(1.0 - h, 1.0 + h));
return d * sign(p.x * b.y + p.y * b.x - b.x * b.y);
}
float sdHexPrism(vec3 p, vec2 h) {
const vec3 k = vec3(-0.8660254, 0.5, 0.57735);
p = abs(p);
p.xy -= 2.0 * min(dot(k.xy, p.xy), 0.0) * k.xy;
vec2 d = vec2(length(p.xy - vec2(clamp(p.x, -k.z * h.x, k.z * h.x), h.x)) * sign(p.y - h.x), p.z - h.y);
return min(max(d.x, d.y), 0.0) + length(max(d, 0.0));
}
float dot2(vec2 v) { return dot(v, v); }
// 0 blob: a sphere with four smaller ones orbiting inside it, merged
float sdBlob(vec3 p) {
float t = uFlow * T;
float d = sdSphere(p, 0.64 + 0.02 * sin(t * 1.3));
for (int i = 0; i < 4; i++) {
float fi = float(i);
float a = t * (0.62 + 0.11 * fi) + fi * 1.71;
vec3 c = vec3(cos(a) * 0.2, sin(a * 1.27 + fi) * 0.17, sin(a * 0.93 + fi * 2.1) * 0.16);
d = smin(d, sdSphere(p - c, 0.42 + 0.03 * sin(t * 1.6 + fi * 1.9)), 0.3);
}
return d;
}
// 1 dumbbell: hexagonal heads on a knurled grip, tipped and slowly turning
float sdDumbbell(vec3 p) {
p *= 0.74;
p.xy = rot(0.28) * p.xy;
p.xz = rot(T * 0.5) * p.xz;
float grip = sdCappedCyl(p.yxz, 0.085 + 0.008 * cos(p.x * 4.0), 0.5) - 0.01;
grip += 0.0012 * sin(p.x * 110.0) * step(abs(p.x), 0.26);
float head = sdHexPrism(vec3(abs(p.x) - 0.53, p.y, p.z).zyx, vec2(0.26, 0.13)) - 0.035;
float collar = sdCappedCyl(vec3(abs(p.x) - 0.37, p.y, p.z).yxz, 0.12, 0.03) - 0.01;
return smin(min(head, collar), grip, 0.04) / 0.74;
}
// 2 heart: iq's exact 2D heart, puffed into a pillow, beating lub-dub
float sdHeart2(vec2 p) {
p.x = abs(p.x);
if (p.y + p.x > 1.0) return sqrt(dot2(p - vec2(0.25, 0.75))) - 0.35355339;
return sqrt(min(dot2(p - vec2(0.0, 1.0)), dot2(p - 0.5 * max(p.x + p.y, 0.0)))) * sign(p.x - p.y);
}
float sdHeart(vec3 p) {
float ph = fract(T * 0.85);
float beat = 1.0 + 0.06 * exp(-pow((ph - 0.1) / 0.05, 2.0)) + 0.04 * exp(-pow((ph - 0.3) / 0.05, 2.0));
p /= beat;
float d = sdHeart2((p.xy - vec2(0.0, -0.62)) / 1.15) * 1.15;
vec2 w = vec2(d + 0.08, abs(p.z) - 0.08);
return (min(max(w.x, w.y), 0.0) + length(max(w, 0.0)) - 0.17) * beat;
}
// 3 spark: a four-point star with a small companion
float spark2D(vec2 p) { return smin(sdRhombus(p, vec2(0.2, 0.95)), sdRhombus(p, vec2(0.95, 0.2)), 0.2); }
float sdSpark(vec3 p) {
p.xy = rot(0.25 * sin(T * 0.7)) * p.xy;
float d = spark2D(p.xy - vec2(-0.08, -0.06));
d = min(d, spark2D((p.xy - vec2(0.62, 0.6)) * 2.8) / 2.8);
vec2 w = vec2(d, abs(p.z) - 0.09);
return min(max(w.x, w.y), 0.0) + length(max(w, 0.0)) - 0.07;
}
// 4 flame: a flickering teardrop with a tongue
float sdFlame(vec3 p) {
float flick = 0.07 * sin(T * 7.3 + p.y * 5.0) + 0.04 * sin(T * 11.0 - p.y * 9.0);
vec3 q = p;
q.x += flick * (p.y + 0.45);
q.z *= 1.3;
float d = sdRoundCone(q, vec3(0.0, -0.32, 0.0), vec3(0.0, 0.7, 0.0), 0.46, 0.02) / 1.3;
float tongue = sdRoundCone(q - vec3(0.24, -0.1, 0.0), vec3(0.0), vec3(0.08, 0.42, 0.0), 0.2, 0.02) / 1.3;
return smin(d, tongue, 0.12);
}
// 5 kettlebell: a squat bell with a flat base and an arched handle
float sdKettlebell(vec3 p) {
vec3 q = p;
q.xz = rot(0.45 + 0.35 * sin(T * 0.5)) * q.xz;
q.y += 0.1;
vec3 b = q - vec3(0.0, -0.2, 0.0);
float body = (length(b * vec3(1.0, 1.08, 1.0)) - 0.5) / 1.08;
body = max(body, -(q.y + 0.66));
vec3 h = q - vec3(0.0, 0.36, 0.0);
float arch = max(sdTorus(h.xzy, 0.34, 0.08), -h.y);
float horns = min(sdCapsule(q, vec3(-0.34, 0.36, 0.0), vec3(-0.27, 0.08, 0.0), 0.08), sdCapsule(q, vec3(0.34, 0.36, 0.0), vec3(0.27, 0.08, 0.0), 0.08));
return smin(body, smin(arch, horns, 0.04), 0.09);
}
// 6 ring: a band tipped so it reads in 3D, turning
float sdRing(vec3 p) {
p.yz = rot(0.42) * p.yz;
p.xy = rot(T * 0.3) * p.xy;
return sdTorus(p.xzy, 0.6, 0.19);
}
// 7 moon: a crescent, rocking
float sdMoon(vec3 p) {
p.xy = rot(-0.45 + 0.12 * sin(T * 0.8)) * p.xy;
float d = max(length(p.xy) - 0.66, -(length(p.xy - vec2(0.32, 0.2)) - 0.54));
float bulge = 0.16 * sqrt(max(0.0, 1.0 - dot(p.xy, p.xy) / 0.44));
vec2 w = vec2(d + 0.06, abs(p.z) - bulge);
return min(max(w.x, w.y), 0.0) + length(max(w, 0.0)) - 0.06;
}
// 8 gift: a soft box, a wider lid and a bow
float sdGift(vec3 p) {
p.xz = rot(0.6 + 0.3 * sin(T * 0.6)) * p.xz;
p.yz = rot(-0.32) * p.yz;
p.y += 0.1;
float body = sdRoundBox(p - vec3(0.0, -0.2, 0.0), vec3(0.5, 0.4, 0.5), 0.09);
float lid = sdRoundBox(p - vec3(0.0, 0.26, 0.0), vec3(0.56, 0.1, 0.56), 0.06);
// The ribbon: a band over the lid and down both visible sides.
float bandA = sdRoundBox(p - vec3(0.0, 0.0, 0.0), vec3(0.075, 0.52, 0.575), 0.03);
float bandB = sdRoundBox(p - vec3(0.0, 0.0, 0.0), vec3(0.575, 0.52, 0.075), 0.03);
float box = smin(min(body, lid), min(bandA, bandB), 0.0);
// The bow: two loops lying along the lid and a knot.
vec3 q = p - vec3(0.0, 0.39, 0.0);
float loopL = sdCapsule(q, vec3(-0.04, 0.02, 0.0), vec3(-0.27, 0.07, 0.0), 0.085);
float loopR = sdCapsule(q, vec3(0.04, 0.02, 0.0), vec3(0.27, 0.07, 0.0), 0.085);
float knot = sdSphere(q - vec3(0.0, 0.03, 0.0), 0.085);
float bow = smin(min(loopL, loopR), knot, 0.05);
return smin(box, bow, 0.04);
}
// 9 chat: a speech bubble with a tail
float sdChat(vec3 p) {
float body = sdRoundBox(p - vec3(0.0, 0.12, 0.0), vec3(0.8, 0.55, 0.22), 0.22);
float tail = sdRoundCone(p, vec3(-0.36, -0.2, 0.0), vec3(-0.68, -0.78, 0.0), 0.2, 0.045);
return smin(body, tail, 0.12);
}
// 10 cube: a soft cube turned so three faces read
float sdCube(vec3 p) {
p.xz = rot(0.62 + 0.25 * sin(T * 0.5)) * p.xz;
p.yz = rot(-0.48) * p.yz;
return sdRoundBox(p, vec3(0.5), 0.1);
}
// 11 gem: a cut stone, turning
float sdGem(vec3 p) {
p.yz = rot(-0.34) * p.yz;
p.xz = rot(T * 0.45) * p.xz;
p = p / 1.14;
p.y -= 0.2;
float sector = TAU / 8.0;
float a = mod(atan(p.z, p.x) + sector * 0.5, sector) - sector * 0.5;
vec2 q = vec2(length(p.xz) * cos(a), p.y);
float table = q.y - 0.3;
float crown = dot(q - vec2(0.7, 0.07), normalize(vec2(0.6, 0.8)));
float girdle = q.x - 0.74;
float pavilion = dot(q - vec2(0.74, 0.02), normalize(vec2(0.7, -0.72)));
return max(max(table, crown), max(girdle, pavilion)) * 1.14;
}
// 12 coin: a thick disc with a rim and a groove, spinning on its edge
float sdCoin(vec3 p) {
p.xz = rot(T * 1.1) * p.xz;
vec2 d = vec2(length(p.xy) - 0.74, abs(p.z) - 0.11);
float disc = min(max(d.x, d.y), 0.0) + length(max(d, 0.0)) - 0.05;
float groove = abs(length(p.xy) - 0.58) - 0.025;
return max(disc, -max(groove, 0.13 - abs(p.z)));
}
// 13 pin: a map marker with a hole, swaying
float sdPin(vec3 p) {
p.xz = rot(0.3 * sin(T * 0.7)) * p.xz;
p.y += 0.12;
float head = sdSphere(p - vec3(0.0, 0.3, 0.0), 0.46);
float tip = sdRoundCone(p, vec3(0.0, 0.3, 0.0), vec3(0.0, -0.66, 0.0), 0.46, 0.04);
float d = smin(head, tip, 0.14);
return max(d, -(sdSphere(p - vec3(0.0, 0.32, 0.18), 0.17)));
}
// 14 drop: a teardrop that turns
float sdDrop(vec3 p) {
p.xz = rot(T * 0.4) * p.xz;
return sdRoundCone(p, vec3(0.0, -0.28, 0.0), vec3(0.0, 0.72, 0.0), 0.5, 0.015);
}
float shapeSDF(int id, vec3 p) {
if (id == 1) return sdDumbbell(p);
if (id == 2) return sdHeart(p);
if (id == 3) return sdSpark(p);
if (id == 4) return sdFlame(p);
if (id == 5) return sdKettlebell(p);
if (id == 6) return sdRing(p);
if (id == 7) return sdMoon(p);
if (id == 8) return sdGift(p);
if (id == 9) return sdChat(p);
if (id == 10) return sdCube(p);
if (id == 11) return sdGem(p);
if (id == 12) return sdCoin(p);
if (id == 13) return sdPin(p);
if (id == 14) return sdDrop(p);
return sdBlob(p);
}
float sceneObject(vec3 p) {
int a = int(uShapeA + 0.5);
int b = int(uShapeB + 0.5);
float d = shapeSDF(a, p);
if (uMixAB > 0.001 && b != a) d = mix(d, shapeSDF(b, p), uMixAB);
// A tap: a ripple runs over the surface and fades.
if (uPoke >= 0.0) d += sin(length(p) * 22.0 - uPoke * 24.0) * 0.022 * exp(-uPoke * 3.5);
return d;
}
float map(vec3 pw) { return sceneObject(R * pw / uScaleS) * uScaleS; }
vec3 normalAt(vec3 p) {
const vec2 k = vec2(1.0, -1.0);
const float e = 0.0015;
return normalize(k.xyy * map(p + k.xyy * e) + k.yyx * map(p + k.yyx * e) + k.yxy * map(p + k.yxy * e) + k.xxx * map(p + k.xxx * e));
}
/* A small studio: a dark gradient, a big white softbox and two coloured strips. */
vec3 environment(vec3 r) {
vec3 col = mix(vec3(0.02, 0.025, 0.04), vec3(0.14, 0.15, 0.2), clamp(r.y * 0.5 + 0.5, 0.0, 1.0));
col += vec3(0.9, 0.95, 1.0) * smoothstep(0.8, 0.97, dot(r, normalize(vec3(-0.55, 0.65, 0.5)))) * 0.95;
col += palette(0.6) * smoothstep(0.9, 1.0, dot(r, normalize(vec3(0.75, 0.15, 0.55)))) * 0.75;
col += palette(0.1) * smoothstep(0.9, 1.0, dot(r, normalize(vec3(-0.8, -0.35, 0.45)))) * 0.5;
return col;
}
vec3 shade(vec3 pw, vec3 rd, vec3 nw) {
vec3 q = R * pw / uScaleS;
vec3 v = -rd;
float ndv = clamp(dot(nw, v), 0.0, 1.0);
float fres = pow(1.0 - ndv, 3.0);
int finish = int(uFinish + 0.5);
// The inside: a swirl of the palette seen through the skin.
vec3 inner = q + (R * refract(rd, nw, 0.76)) * 0.45;
float t = uFlow * T;
float swirl = atan(inner.z, inner.x) / TAU + inner.y * 0.45 + 0.14 * sin(inner.y * 3.2 + t * 0.9) + 0.11 * sin(inner.x * 2.6 - t * 0.7) + t * 0.04;
vec3 band = palette(0.04 + 0.6 * wave(swirl));
band = mix(band, mix(lin(uTint), vec3(1.0), 0.15 * wave(swirl)), uTintW);
float depth = 0.62 + 0.38 * smoothstep(-0.7, 0.8, sin(inner.x * 2.4 + t * 0.8) * 0.5 + inner.y * 0.5);
vec3 deep = mix(mix(lin(uC0), lin(uTint), uTintW), vec3(0.05), 0.75);
vec3 interior = mix(deep, band, 0.86) * depth * (0.82 + 0.38 * uEnergy);
float c1 = sin(inner.x * 7.0 + sin(inner.y * 5.0 + t * 1.3) * 1.4 + t * 0.9);
float c2 = sin(inner.y * 8.0 + sin(inner.z * 6.0 - t * 1.1) * 1.3 - t * 0.7);
interior += mix(band, vec3(1.0), 0.5) * pow(1.0 - abs(c1 * c2), 14.0) * (0.15 + 0.22 * uEnergy);
vec3 env = environment(reflect(rd, nw));
vec3 col;
vec3 key = normalize(vec3(-0.45, 0.62, 0.85));
float s = max(dot(reflect(-key, nw), v), 0.0);
if (finish == 2) {
// Chrome: the studio itself, lightly tinted.
vec3 rr = reflect(rd, nw);
env += vec3(0.32, 0.34, 0.4) * smoothstep(-0.3, 1.0, rr.y) + vec3(0.1) * smoothstep(0.0, -1.0, rr.y);
col = env * (1.2 + 0.8 * fres) * mix(vec3(1.0), band * 1.8, 0.14 + 0.2 * uTintW);
col += pow(s, 60.0) * 1.2;
} else if (finish == 3) {
// Glass: a dim, clear inside and a bright, coloured rim.
col = mix(interior * 0.28, env * 1.1, 0.22 + 0.72 * fres);
col += palette(fract(0.08 + ndv * 0.75 + t * 0.03)) * fres * 0.7;
col += pow(s, 90.0) * 1.4;
} else {
col = mix(interior, env, 0.16 + 0.6 * fres);
col += mix(palette(fract(0.08 + ndv * 0.75 + t * 0.03)), lin(uTint), uTintW * 0.6) * fres * (0.45 + 0.35 * uEnergy);
col += vec3(1.0) * (pow(s, 90.0) * 1.3 + pow(s, 14.0) * 0.12);
if (finish == 1) {
// Pearl: lighter, with a soft shift of hue across the skin.
float hue = 0.5 + 0.5 * dot(nw, normalize(vec3(0.6, 0.8, 0.3)));
col = mix(col, vec3(0.93, 0.92, 0.96) * (0.55 + 0.45 * ndv) + palette(hue) * 0.28, 0.5);
}
}
return col;
}
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / (0.5 * uResolution.y);
T = uTime * uSpeed;
float cy = cos(uYaw), sy = sin(uYaw), cp = cos(uPitch), sp = sin(uPitch);
R = mat3(cy, 0.0, -sy, 0.0, 1.0, 0.0, sy, 0.0, cy) * mat3(1.0, 0.0, 0.0, 0.0, cp, sp, 0.0, -sp, cp);
vec3 ro = vec3(0.0, 0.0, CAMERA_Z);
vec3 rd = normalize(vec3(uv * TAN_HALF, -1.0));
float pixel = 2.0 * TAN_HALF * CAMERA_Z / uResolution.y;
float bound = 1.25 * uScaleS;
float b = dot(ro, rd);
float h = b * b - (dot(ro, ro) - bound * bound);
vec3 rgb = vec3(0.0);
float alpha = 0.0;
float minD = 1e3;
float hitT = -1.0;
if (h > 0.0) {
float t = max(0.0, -b - sqrt(h));
float tEnd = -b + sqrt(h);
for (int i = 0; i < 64; i++) {
vec3 p = ro + rd * t;
float d = map(p);
if (d < 0.0015) { hitT = t; break; }
minD = min(minD, d);
t += max(d * 0.75, 0.002);
if (t > tEnd) break;
}
} else {
minD = max(0.0, length(cross(rd, -ro)) - 0.72 * uScaleS);
}
vec3 aura = mix(palette(0.45), palette(0.15), 0.5 + 0.5 * sin(T * 0.7));
aura = mix(aura, lin(uTint), uTintW * 0.5);
if (hitT > 0.0) {
vec3 p = ro + rd * hitT;
rgb = shade(p, rd, normalAt(p));
alpha = 1.0;
} else {
float cover = 1.0 - smoothstep(0.0, pixel * 1.4, minD);
float glow = exp(-minD * 8.0 / uScaleS) * (0.2 + 0.35 * uEnergy);
rgb = aura * glow * (1.0 - cover) + mix(aura, vec3(1.0), 0.35) * cover;
alpha = max(glow, cover);
}
rgb *= uIntensity;
// Premultiplied: the sphere floats over whatever is behind the canvas.
fragColor = vec4(finish(rgb, gl_FragCoord.xy) * alpha, alpha);
}
`;
export const fragment = compose(SCENE);
export const PRESET_NAMES = ["spectrum", "pearl", "ember", "mint", "mono"] as const;
export type MorphSpherePreset = (typeof PRESET_NAMES)[number];
export const presets: Record<MorphSpherePreset, ScenePreset> = {
spectrum: {
colors: { c0: "#00c5fc", c1: "#5225ed", c2: "#e434dc", c3: "#fcbe72", tint: "#ffffff" },
values: { tintW: 0, shapeA: 0, shapeB: 0, mixAB: 0, poke: -1, yaw: 0, pitch: 0, finish: 0, scaleS: 1, energy: 0.5, flow: 1, speed: 1, intensity: 1 },
},
pearl: {
colors: { c0: "#6bb8f2", c1: "#9d73ed", c2: "#f278b2", c3: "#fabb7a", tint: "#ffffff" },
values: { tintW: 0, shapeA: 0, shapeB: 0, mixAB: 0, poke: -1, yaw: 0, pitch: 0, finish: 1, scaleS: 1, energy: 0.4, flow: 0.9, speed: 1, intensity: 1.05 },
},
ember: {
colors: { c0: "#ff7a2a", c1: "#ff3d54", c2: "#b3122e", c3: "#ffd36a", tint: "#ffffff" },
values: { tintW: 0, shapeA: 0, shapeB: 0, mixAB: 0, poke: -1, yaw: 0, pitch: 0, finish: 0, scaleS: 1, energy: 0.7, flow: 1.1, speed: 1, intensity: 1 },
},
mint: {
colors: { c0: "#35e0b0", c1: "#1aa6c9", c2: "#2f6df0", c3: "#c9f7e4", tint: "#ffffff" },
values: { tintW: 0, shapeA: 0, shapeB: 0, mixAB: 0, poke: -1, yaw: 0, pitch: 0, finish: 3, scaleS: 1, energy: 0.5, flow: 1, speed: 1, intensity: 1 },
},
mono: {
colors: { c0: "#d7dbe4", c1: "#8a93a8", c2: "#4b5468", c3: "#f5f7fb", tint: "#ffffff" },
values: { tintW: 0, shapeA: 0, shapeB: 0, mixAB: 0, poke: -1, yaw: 0, pitch: 0, finish: 2, scaleS: 1, energy: 0.4, flow: 0.8, speed: 1, intensity: 1 },
},
};
/** What the stage hands the shader: colours and numbers, plus the live shape and spin. */
export type MorphParams = SceneParams & {
/** Shape ids in the order they play. */
order: readonly number[];
/** The index (in `order`, counting up forever) the sphere is heading to. */
target: number;
/** Seconds-clock stamp of the last tap, or null. */
tapped: number | null;
/** One composed frame: no easing, the target is shown at once. */
settled: boolean;
};
export type MorphPiece = ShaderPiece<MorphParams> & {
/** Turns the sphere by a drag (radians); it eases back to its slow turn when let go. */
spin(dx: number, dy: number): void;
};
/**
* A piece per sphere: it eases from shape to shape and remembers when it was last tapped, which
* is why it is created per instance and shares only its compiled program (by key) with the rest.
*/
export function createMorphPiece(): MorphPiece {
const base = createScenePiece("morph-sphere", fragment);
let cur: number | null = null;
let last = 0;
let tapStamp: number | null = null;
let tapClock = -1;
let yaw = 0.35;
let pitch = -0.12;
let yawVelocity = 0;
return {
key: base.key,
fragment,
animated: true,
spin(dx, dy) {
yaw += dx;
pitch = Math.max(-0.9, Math.min(0.9, pitch + dy));
yawVelocity = dx * 20;
},
apply(gl, locate, frame, params) {
const dt = Math.max(0, Math.min(0.1, frame.time - last));
last = frame.time;
// A released drag coasts, and the pitch settles back toward level.
if (!params.settled) {
yaw += yawVelocity * dt;
yawVelocity *= Math.exp(-dt * 2.4);
pitch += (-0.12 - pitch) * (1 - Math.exp(-dt * 0.9));
}
const n = Math.max(1, params.order.length);
if (cur === null || params.settled) cur = params.target;
else cur += (params.target - cur) * (1 - Math.exp(-dt * 3.2));
if (Math.abs((cur ?? 0) - params.target) < 0.001) cur = params.target;
const index = Math.floor(cur);
const raw = cur - index;
// Ease the blend so a shape holds its form and then melts.
const mix = raw * raw * (3 - 2 * raw);
const at = (i: number) => params.order[((i % n) + n) % n] ?? 0;
if (params.tapped !== null && params.tapped !== tapStamp) {
tapStamp = params.tapped;
tapClock = frame.time;
}
const age = tapClock >= 0 ? frame.time - tapClock : -1;
base.apply(gl, locate, frame, {
colors: params.colors,
values: {
...params.values,
shapeA: at(index),
shapeB: at(index + 1),
mixAB: mix,
poke: age >= 0 && age < 1.6 ? age : -1,
yaw,
pitch,
},
});
},
};
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| pack | "studio" | "fitness" | "gifting" | "retail" | "wellness" | "all" | "studio" | A set of shapes for a kind of business. |
| shapes | MorphShape[] | — | Your own shapes, in order; takes the place of pack. Names: blob, dumbbell, heart, spark, flame, kettlebell, ring, moon, gift, chat, cube, gem, coin, pin, drop. |
| preset | "spectrum" | "pearl" | "ember" | "mint" | "mono" | "spectrum" | A palette, with its own default skin. |
| finish | "liquid" | "pearl" | "chrome" | "glass" | — | The skin; leave it out to take the palette's. |
| colors | { a?: string; b?: string; c?: string; d?: string } | — | Overrides single palette colours with any CSS colour. |
| hold | number | 3.2 | Seconds each shape holds before it melts into the next. |
| cycle | boolean | true | Moves through the shapes by itself (paused while the pointer is over it). |
| interactive | boolean | true | Tap, click or press Enter for the next shape; drag to turn it. |
| onShape | (shape: MorphShape) => void | — | Called when the sphere starts heading to a shape. |
| speed | number | 1 | Multiplies the speed of the flow inside. |
| intensity | number | 1 | Multiplies the brightness. |
| label | string | — | Accessible name. |
| still | boolean | false | Holds one composed frame (a gallery card, reduced motion). |
| children | ReactNode | — | What sits over the sphere. |
| className | string | — | Merged with cn(); give it a size and an aspect ratio. |
Accessibility and motion
- Keyboard
- Focusable; Enter or Space for the next shape.
- Screen readers
- A button named by
label("A liquid sphere that changes shape. Press for the next shape."); the current shape is exposed asdata-shape. - Touch
- Tap for the next shape; drag to turn it. Scrolling stays available: the drag only turns the sphere on its own surface.
- Reduced motion
- One composed frame of the first shape; it does not melt, cycle or turn.
Edge cases and limits
- One sphere per page: each is a 64-step ray march. For several, use
stillon all but one. - Chrome on a dark page needs the page to be dark: its studio is dark.
- A very small sphere (under 120 px) loses the detail of the dumbbell and the gift; use
blob,heart,drop.
Related

Particle Sphere
FreeA cloud of points that is a sphere until it is a ring, a heart, a cube, a helix or a galaxy, and shies from the pointer.
3D · three.js

Planet
FreeA world: continents, ice, clouds that turn, city lights on the night side and a glowing atmosphere.
3D · WebGL2

Wire Globe
FreeA sphere drawn as light: glowing wire, bright nodes, a halo of points and a pulse you drive.
3D · WebGL2