Skip to content

FreeIn reviewv1.0.0

Coin

A struck coin with a reeded edge, your mark and words on its face; hold the pointer on it and it turns over.
npx shadcn@latest add @vibebox/coin

Controls

<Coin />

About

A cylinder of ninety-six segments laid on its edge. Its two faces share one canvas texture drawn at build: a radial metal gradient, fine concentric turning marks, a raised rim and an inner ring, the words set letter by letter around a circle, and the mark with a soft shadow. The edge uses a stripe texture as a bump map for the reeding. The metal reflects the stage's studio, so highlights run across it as it turns. An idle sine turns it about its vertical axis; each hover adds a full turn that the coin eases into, so it always lands face-on.

Use it when

  • A loyalty programme, rewards or referral page wants an object for the reward.
  • A fintech, savings or crypto-adjacent brand wants a mark in metal.
  • An anniversary, membership or thank-you page wants a keepsake.

Skip it when

  • The brand cannot be read as money or a token.
  • The page already has a 3D centrepiece.

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/coin
  3. 3Use it:
    import { Coin } from "@/components/vibebox/coin";
    
    const colors = { metal: "#d7dce6", relief: "#4c5668" };
    
    <Coin className="mx-auto aspect-square w-full max-w-[420px]" mark="A" ring="Member since 2026" colors={colors} />

Code

3 files
components/vibebox/coin/coin.client.tsx
/**
 * VibeBox Motion · Coin
 * 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 { useCallback } from "react";
import { SculptureCanvas } from "@/lib/vibebox/sculpture-canvas";
import type { CoinOptions } from "./coin.sculpture";

/* Warm, neutral rim lights: the stage's spectrum lights would tint the metal red and green. */
const METAL_LIGHTS = { left: "#fff1d6", right: "#ffe3b0", back: "#ffffff" };

export type CoinProps = CoinOptions & {
  /** Accessible name. */
  label?: string;
  className?: string;
};

/* A flat gold disc with the mark, in CSS, until the 3D coin is ready and wherever WebGL is not. */
function CoinPoster({ mark, metal, relief }: { mark: string; metal: string; relief: string }) {
  return (
    <span aria-hidden="true" className="absolute inset-0 grid place-items-center">
      <span
        className="grid aspect-square w-[52%] place-items-center rounded-full font-serif text-[clamp(2rem,9vw,5rem)] font-bold shadow-[0_24px_50px_-20px_rgb(0_0_0/0.6)]"
        style={{ background: `radial-gradient(circle at 35% 30%, #fff, ${metal} 24%, ${metal})`, color: relief, boxShadow: `inset 0 0 0 10px ${metal}, inset 0 0 0 14px ${relief}` }}
      >
        {[...mark].slice(0, 3).join("")}
      </span>
    </span>
  );
}

export function Coin({ label = "A gold coin turning slowly; hold the pointer over it and it turns over", className, ...options }: CoinProps) {
  const load = useCallback(() => import("./coin.sculpture").then((module) => module.createCoin), []);
  return (
    <SculptureCanvas
      slug="coin"
      load={load}
      options={options}
      label={label}
      poster={<CoinPoster mark={options.mark ?? "V"} metal={options.colors?.metal ?? "#e7b64f"} relief={options.colors?.relief ?? "#9a6a12"} />}
      lights={METAL_LIGHTS}
      className={className}
    />
  );
}
components/vibebox/coin/coin.sculpture.ts
/**
 * VibeBox Motion · Coin
 * 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 * as THREE from "three";
import { clamp01, damp, disposeObject, easeOutCubic } from "@/lib/vibebox/engine/three";
import type { Sculpture, SculptureFrame } from "@/lib/vibebox/engine/three";

/*
 * A struck coin: a short cylinder with a reeded edge and the same die on both faces (a double
 * ring of words and a mark in the middle), in a metal that reflects the stage's studio. It
 * spins slowly on its edge, wobbling a little; a hold turns it over and brings the face to you.
 */

export type CoinOptions = {
  /** One to three characters struck in the middle of the face. */
  mark?: string;
  /** Words around the rim of the face (repeated to fill the ring). */
  ring?: string;
  /** The metal and the colour of the striking, as CSS colours. */
  colors?: { metal?: string; relief?: string };
};

function dieTexture(mark: string, ring: string, metal: string, relief: string): THREE.CanvasTexture {
  const size = 1024;
  const canvas = document.createElement("canvas");
  canvas.width = size;
  canvas.height = size;
  const c = canvas.getContext("2d")!;
  const mid = size / 2;
  // A soft hot spot toward the upper left, kept off the mark.
  const face = c.createRadialGradient(mid * 0.5, mid * 0.45, 20, mid, mid, mid * 1.15);
  face.addColorStop(0, "rgba(255,255,255,0.9)");
  face.addColorStop(0.28, metal);
  face.addColorStop(1, metal);
  c.fillStyle = face;
  c.fillRect(0, 0, size, size);
  // Fine concentric turning marks, as a die leaves.
  c.lineWidth = 1;
  for (let r = 30; r < mid; r += 6) {
    c.strokeStyle = `rgba(0,0,0,${0.025 + 0.02 * ((r / 6) % 3)})`;
    c.beginPath();
    c.arc(mid, mid, r, 0, Math.PI * 2);
    c.stroke();
  }
  // The raised rim and the inner ring.
  c.strokeStyle = relief;
  c.lineWidth = 22;
  c.beginPath();
  c.arc(mid, mid, mid - 26, 0, Math.PI * 2);
  c.stroke();
  c.lineWidth = 6;
  c.beginPath();
  c.arc(mid, mid, mid - 84, 0, Math.PI * 2);
  c.stroke();
  c.beginPath();
  c.arc(mid, mid, mid * 0.56, 0, Math.PI * 2);
  c.stroke();
  // The words, set around the ring.
  const text = (ring.trim() || "VALUE") .toUpperCase();
  const ringText = (text + " · ").repeat(Math.max(2, Math.ceil(26 / (text.length + 3)))).slice(0, 36);
  c.fillStyle = relief;
  c.font = "600 64px ui-sans-serif, system-ui, sans-serif";
  c.textAlign = "center";
  c.textBaseline = "middle";
  const letters = [...ringText];
  const radius = mid - 150;
  letters.forEach((letter, index) => {
    const angle = (index / letters.length) * Math.PI * 2 - Math.PI / 2;
    c.save();
    c.translate(mid + Math.cos(angle) * radius, mid + Math.sin(angle) * radius);
    c.rotate(angle + Math.PI / 2);
    c.fillText(letter, 0, 0);
    c.restore();
  });
  // The mark.
  const chars = [...mark].slice(0, 3).join("");
  c.font = `700 ${chars.length > 1 ? 260 : 360}px ui-serif, Georgia, serif`;
  c.shadowColor = "rgba(0,0,0,0.35)";
  c.shadowBlur = 14;
  c.shadowOffsetY = 6;
  c.fillStyle = relief;
  c.fillText(chars, mid, mid + 12);
  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;
  texture.anisotropy = 8;
  return texture;
}

function reedTexture(): THREE.CanvasTexture {
  const canvas = document.createElement("canvas");
  canvas.width = 128;
  canvas.height = 8;
  const c = canvas.getContext("2d")!;
  c.fillStyle = "#808080";
  c.fillRect(0, 0, 128, 8);
  for (let x = 0; x < 128; x += 4) {
    c.fillStyle = "#e8e8e8";
    c.fillRect(x, 0, 2, 8);
    c.fillStyle = "#222";
    c.fillRect(x + 2, 0, 2, 8);
  }
  const texture = new THREE.CanvasTexture(canvas);
  texture.wrapS = THREE.RepeatWrapping;
  texture.repeat.set(10, 1);
  return texture;
}

export function createCoin(options: CoinOptions = {}): Sculpture {
  const metal = options.colors?.metal ?? "#e7b64f";
  const relief = options.colors?.relief ?? "#9a6a12";
  const object = new THREE.Group();
  const pivot = new THREE.Group();
  object.add(pivot);

  const face = dieTexture(options.mark ?? "V", options.ring ?? "Thank you", metal, relief);
  const reed = reedTexture();
  // The metal also glows a little with its own colour, so it never goes black between reflections.
  const glow = new THREE.Color(metal).multiplyScalar(0.2);
  const faceMaterial = new THREE.MeshStandardMaterial({ map: face, emissiveMap: face, emissive: glow, metalness: 0.8, roughness: 0.34, envMapIntensity: 1.5 });
  const edgeMaterial = new THREE.MeshStandardMaterial({ color: new THREE.Color(metal), emissive: glow, bumpMap: reed, bumpScale: 1.4, metalness: 0.9, roughness: 0.34, envMapIntensity: 1.5 });
  // A cylinder's axis is Y and its caps' images lie turned a quarter: turn the geometry about
  // its axis so the die reads upright, then stand it on its edge with the face toward the viewer.
  const geometry = new THREE.CylinderGeometry(1, 1, 0.16, 96, 1);
  geometry.rotateY(Math.PI / 2);
  const coin = new THREE.Mesh(geometry, [edgeMaterial, faceMaterial, faceMaterial]);
  coin.rotation.x = Math.PI / 2;
  pivot.add(coin);

  let spin = 0.5;
  let flip = 0;
  let flipTarget = 0;
  let built = 0;
  let wasHover = false;

  return {
    object,
    assembleSeconds: 1.5,
    holdSeconds: 30,
    assemble(progress) {
      built = easeOutCubic(clamp01(progress));
      object.position.y = (1 - built) * 2.4;
      object.scale.setScalar((0.5 + 0.5 * built) * 1.55);
      pivot.rotation.z = (1 - built) * 1.2;
    },
    update(frame: SculptureFrame) {
      if (frame.still) {
        pivot.rotation.set(0.12, -0.5, 0);
        return;
      }
      const hovering = frame.hover > 0.35;
      // Each time the pointer arrives the coin turns over once.
      if (hovering && !wasHover) flipTarget += Math.PI * 2;
      wasHover = hovering;
      flip += (flipTarget - flip) * damp(4.5, frame.delta);
      spin += frame.delta * (hovering ? 0.2 : 0.55);
      pivot.rotation.y = Math.sin(spin) * 0.85 + flip;
      pivot.rotation.x = 0.1 + Math.sin(frame.time * 0.6) * 0.1;
      object.position.y = Math.sin(frame.time * 0.9) * 0.05;
    },
    dispose() {
      disposeObject(object);
      face.dispose();
      reed.dispose();
      faceMaterial.dispose();
      edgeMaterial.dispose();
    },
  };
}
components/vibebox/coin/index.tsx
/**
 * VibeBox Motion · Coin
 * 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 { Coin, type CoinProps } from "./coin.client";
export type { CoinOptions } from "./coin.sculpture";

Props

PropTypeDefaultDescription
markstring"V"One to three characters struck in the middle.
ringstring"Thank you"Words around the rim, repeated to fill the ring.
colors{ metal?: string; relief?: string }{ metal: "#e7b64f", relief: "#9a6a12" }The metal and the colour of the striking.
labelstring—Accessible name.
classNamestring—Merged with cn(); give it a size, about square.

Accessibility and motion

Keyboard
Not focusable; the coin is an illustration.
Screen readers
An image named by label.
Touch
A finger on the coin turns it over; scrolling is never blocked.
Reduced motion
One composed pose of the coin, turned a little; it does not spin or flip.

Edge cases and limits

  • Keep mark to one to three characters: more is unreadable on the face.
  • Emoji depend on the visitor's fonts; a plain letter is safer.
  • The words repeat to fill the ring: a very long phrase is cut off.
  • An open 3D brochure on a desk with a page turning and its corner lifted

    A 3D brochure whose paper bends: click a page, drag a corner or press the arrow keys to turn it.

    3D · three.js

  • A globe made of blue dots with pulsing gold markers and pink arcs between cities

    A globe of dots with pulsing markers and arcs; drag to turn it, choose a place to spin to it.

    3D · three.js

  • A pink gift box with a gold ribbon and bow, its lid lifting with confetti

    A wrapped gift whose lid swings open on hover, with a warm light and a burst of confetti.

    3D · three.js

↑ ↓ to move↵ to openesc to close