Skip to content

FreeIn reviewv1.0.0

Morph Sphere

A liquid sphere that melts into the things you sell: fifteen shapes, four skins, tap for the next.
npx shadcn@latest add @vibebox/morph-sphere

Controls

Skin
3.2
<MorphSphere />

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

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/morph-sphere
  3. 3Use it:
    import { MorphSphere } from "@/components/vibebox/morph-sphere";
    
    <MorphSphere pack="retail" preset="spectrum" className="aspect-square w-full max-w-[420px]" />

Code

3 files
components/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

PropTypeDefaultDescription
pack"studio" | "fitness" | "gifting" | "retail" | "wellness" | "all""studio"A set of shapes for a kind of business.
shapesMorphShape[]—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.
holdnumber3.2Seconds each shape holds before it melts into the next.
cyclebooleantrueMoves through the shapes by itself (paused while the pointer is over it).
interactivebooleantrueTap, 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.
speednumber1Multiplies the speed of the flow inside.
intensitynumber1Multiplies the brightness.
labelstring—Accessible name.
stillbooleanfalseHolds one composed frame (a gallery card, reduced motion).
childrenReactNode—What sits over the sphere.
classNamestring—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 as data-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 still on 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.
  • A glowing sphere of points in blue and magenta, turning into a ring

    A 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

  • A planet with blue oceans, green continents and white clouds, lit from one side

    Planet

    Free

    A world: continents, ice, clouds that turn, city lights on the night side and a glowing atmosphere.

    3D · WebGL2

  • A glowing wireframe sphere with cyan and magenta lines and a halo of points

    A sphere drawn as light: glowing wire, bright nodes, a halo of points and a pulse you drive.

    3D · WebGL2

↑ ↓ to move↵ to openesc to close