About
Boxes for the base and lid, two ribbon bands that cross on each, a squashed torus per bow loop and a sphere for the knot, in a matt wrap and a glossy ribbon. The lid sits on a hinge group at the back top edge so a single rotation opens it, lifting a little as it goes. An additive glow sprite and a point light come up with the opening; ninety-six confetti pieces are one instanced mesh on a ballistic path with spin and a short life, burst once when the lid passes half open and trickled while it stays up. The pointer, or a timer in the idle state, drives a single eased open value.
Use it when
- A gifting, voucher, florist, bakery or boutique page wants a delight on the hero.
- A birthday, wedding or holiday campaign needs an object that says present.
- A loyalty or referral page wants a reward to open.
Skip it when
- The brand is austere or technical.
- The page already has a 3D centrepiece.
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/gift-box - 3Use it:
import { GiftBox } from "@/components/vibebox/gift-box"; const colors = { box: "#ff6fae", ribbon: "#ffe08a" }; <div className="rounded-xl bg-[#07070d]"> <GiftBox className="mx-auto aspect-square w-full max-w-[460px]" colors={colors} /> </div>
Code
3 filescomponents/vibebox/gift-box/gift-box.client.tsx
/**
* VibeBox Motion · Gift Box
* 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 { GiftBoxOptions } from "./gift-box.sculpture";
export type GiftBoxProps = GiftBoxOptions & {
/** Accessible name. */
label?: string;
className?: string;
};
/* A wrapped box with a ribbon, in CSS, until the 3D one is ready and wherever WebGL is not. */
function BoxPoster({ box, ribbon }: { box: string; ribbon: string }) {
return (
<span aria-hidden="true" className="absolute inset-0 grid place-items-center">
<span className="relative aspect-square w-[40%]">
<span className="absolute inset-x-0 top-[26%] bottom-0 rounded-[6px]" style={{ background: box, boxShadow: "0 24px 40px -18px rgb(0 0 0 / 0.55)" }} />
<span className="absolute inset-x-[-5%] top-[8%] h-[24%] rounded-[6px]" style={{ background: `color-mix(in srgb, ${box}, #fff 12%)` }} />
<span className="absolute inset-y-[8%] left-[43%] w-[14%]" style={{ background: ribbon }} />
<span className="absolute top-[-4%] left-[28%] h-[16%] w-[44%] rounded-full" style={{ border: `5px solid ${ribbon}` }} />
</span>
</span>
);
}
export function GiftBox({ label = "A wrapped gift that opens its lid when you hold the pointer over it, with confetti and a warm light", className, ...options }: GiftBoxProps) {
const load = useCallback(() => import("./gift-box.sculpture").then((module) => module.createGiftBox), []);
return (
<SculptureCanvas
slug="gift-box"
load={load}
options={options}
label={label}
poster={<BoxPoster box={options.colors?.box ?? "var(--spectrum-5)"} ribbon={options.colors?.ribbon ?? "var(--spectrum-7)"} />}
className={className}
/>
);
}components/vibebox/gift-box/gift-box.sculpture.ts
/**
* VibeBox Motion · Gift Box
* 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, radialGlowTexture, seededRandom, spectrumAt } from "@/lib/vibebox/engine/three";
import type { Sculpture, SculptureFrame } from "@/lib/vibebox/engine/three";
/*
* A wrapped gift on a plinth of air. The lid is hinged at its back edge; when the pointer is on
* the box (or on its own every few seconds) it swings open, a warm light comes up from inside and
* confetti bursts out and falls. Everything is built from boxes, a squashed torus and a sphere.
*/
export type GiftBoxOptions = {
/** The wrapping and the ribbon, as CSS colours (default: pink on a gold ribbon from your spectrum). */
colors?: { box?: string; ribbon?: string };
/** Opens by itself every few seconds when nobody is on it (default true). */
autoOpen?: boolean;
};
const CONFETTI = 96;
export function createGiftBox(options: GiftBoxOptions = {}): Sculpture {
const boxColor = new THREE.Color(options.colors?.box ?? spectrumAt(0.74, new THREE.Color()).getStyle());
const ribbonColor = new THREE.Color(options.colors?.ribbon ?? spectrumAt(0.97, new THREE.Color()).getStyle());
const autoOpen = options.autoOpen !== false;
const random = seededRandom(918273);
const object = new THREE.Group();
const body = new THREE.Group();
object.add(body);
const wrap = new THREE.MeshStandardMaterial({ color: boxColor, roughness: 0.42, metalness: 0.06, envMapIntensity: 0.9 });
const ribbon = new THREE.MeshStandardMaterial({ color: ribbonColor, roughness: 0.28, metalness: 0.5, envMapIntensity: 1.2 });
const dark = new THREE.MeshBasicMaterial({ color: new THREE.Color(boxColor).multiplyScalar(0.22) });
const disposables: THREE.BufferGeometry[] = [];
const mesh = (geometry: THREE.BufferGeometry, material: THREE.Material, parent: THREE.Object3D) => {
disposables.push(geometry);
const m = new THREE.Mesh(geometry, material);
parent.add(m);
return m;
};
// The base, its inside (dark, seen when the lid is up) and the two ribbon bands.
const W = 1.5;
const H = 1.05;
mesh(new THREE.BoxGeometry(W, H, W), wrap, body);
const inside = mesh(new THREE.PlaneGeometry(W - 0.12, W - 0.12), dark, body);
inside.rotation.x = -Math.PI / 2;
inside.position.y = H / 2 + 0.002;
mesh(new THREE.BoxGeometry(0.26, H + 0.01, W + 0.02), ribbon, body);
mesh(new THREE.BoxGeometry(W + 0.02, H + 0.01, 0.26), ribbon, body);
// The lid, hinged on its back edge, with its own bands and the bow.
const hinge = new THREE.Group();
hinge.position.set(0, H / 2, -W / 2 - 0.07);
body.add(hinge);
const lid = new THREE.Group();
lid.position.set(0, 0.16, W / 2 + 0.07);
hinge.add(lid);
mesh(new THREE.BoxGeometry(W + 0.14, 0.32, W + 0.14), wrap, lid);
mesh(new THREE.BoxGeometry(0.26, 0.335, W + 0.16), ribbon, lid);
mesh(new THREE.BoxGeometry(W + 0.16, 0.335, 0.26), ribbon, lid);
const bow = new THREE.Group();
bow.position.set(0, 0.32, 0);
lid.add(bow);
const loopGeometry = new THREE.TorusGeometry(0.3, 0.075, 16, 40);
for (const side of [-1, 1]) {
const loop = mesh(loopGeometry, ribbon, bow);
loop.scale.set(1, 1.2, 0.62);
loop.position.set(side * 0.3, 0.2, 0);
loop.rotation.set(0, 0, side * 0.55);
const tail = mesh(new THREE.BoxGeometry(0.17, 0.5, 0.04), ribbon, bow);
tail.position.set(side * 0.2, 0.0, 0.12);
tail.rotation.z = side * -0.5;
}
mesh(new THREE.SphereGeometry(0.14, 20, 14), ribbon, bow).position.set(0, 0.16, 0);
// The light that comes up from inside.
const glowTexture = radialGlowTexture([
[0, "rgba(255,244,214,1)"],
[0.35, "rgba(255,200,120,0.55)"],
[1, "rgba(255,160,80,0)"],
]);
const glowMaterial = new THREE.SpriteMaterial({ map: glowTexture, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, opacity: 0 });
const glow = new THREE.Sprite(glowMaterial);
glow.scale.set(3.2, 3.2, 1);
glow.position.set(0, H / 2 + 0.5, 0);
body.add(glow);
const light = new THREE.PointLight(new THREE.Color("#ffd8a0"), 0, 6, 1.6);
light.position.set(0, H / 2 + 0.25, 0);
body.add(light);
// The confetti: small flat pieces on a ballistic path, recycled.
const pieceGeometry = new THREE.PlaneGeometry(0.1, 0.16);
const pieceMaterial = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide, vertexColors: false });
const confetti = new THREE.InstancedMesh(pieceGeometry, pieceMaterial, CONFETTI);
confetti.frustumCulled = false;
const tint = new THREE.Color();
for (let i = 0; i < CONFETTI; i++) confetti.setColorAt(i, spectrumAt(random(), tint));
body.add(confetti);
type Piece = { life: number; x: number; y: number; z: number; vx: number; vy: number; vz: number; spin: number; rx: number; rz: number };
const pieces: Piece[] = Array.from({ length: CONFETTI }, () => ({ life: 0, x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, spin: 0, rx: 0, rz: 0 }));
const dummy = new THREE.Object3D();
let cursor = 0;
const burst = (count: number) => {
for (let k = 0; k < count; k++) {
const p = pieces[cursor]!;
cursor = (cursor + 1) % CONFETTI;
const angle = random() * Math.PI * 2;
const lateral = 0.25 + random() * 0.7;
p.life = 1.6 + random() * 1.1;
p.x = (random() - 0.5) * 0.9;
p.y = H / 2 + 0.35;
p.z = (random() - 0.5) * 0.9;
p.vx = Math.cos(angle) * lateral;
p.vz = Math.sin(angle) * lateral * 0.6;
p.vy = 2.1 + random() * 1.6;
p.spin = (random() - 0.5) * 14;
p.rx = random() * 6;
p.rz = random() * 6;
}
};
let open = 0;
let clock = 0;
let wasOpen = false;
let built = 0;
let trickle = 0;
const renderConfetti = (delta: number) => {
for (let i = 0; i < CONFETTI; i++) {
const p = pieces[i]!;
if (p.life > 0) {
p.life -= delta;
p.vy -= 3.4 * delta;
p.vx *= 1 - 0.6 * delta;
p.vz *= 1 - 0.6 * delta;
p.x += p.vx * delta;
p.y += p.vy * delta;
p.z += p.vz * delta;
p.rx += p.spin * delta;
p.rz += p.spin * 0.6 * delta;
}
const alive = p.life > 0 ? Math.min(1, p.life * 2.2) : 0;
dummy.position.set(p.x, p.y, p.z);
dummy.rotation.set(p.rx, p.rz, p.spin * 0.1);
dummy.scale.setScalar(alive);
dummy.updateMatrix();
confetti.setMatrixAt(i, dummy.matrix);
}
confetti.instanceMatrix.needsUpdate = true;
};
renderConfetti(0);
return {
object,
assembleSeconds: 1.4,
holdSeconds: 40,
assemble(progress) {
built = easeOutCubic(clamp01(progress));
object.scale.setScalar((0.4 + 0.6 * built) * 1.1);
object.position.y = (1 - built) * 1.4 - 0.3;
body.rotation.y = (1 - built) * 1.4;
},
update(frame: SculptureFrame) {
if (frame.still) {
open = 0;
hinge.rotation.x = 0;
glowMaterial.opacity = 0;
light.intensity = 0;
body.rotation.set(0.14, -0.5, 0);
renderConfetti(0);
return;
}
clock += frame.delta;
// Open for a couple of seconds out of every eight when nobody is on it; always while hovered.
const demo = autoOpen && clock % 8 > 5.2;
const target = frame.hover > 0.35 || demo ? 1 : 0;
open += (target - open) * damp(target ? 6 : 4, frame.delta);
const isOpen = open > 0.55;
if (isOpen && !wasOpen) burst(CONFETTI);
wasOpen = isOpen;
if (open > 0.85) {
trickle += frame.delta;
if (trickle > 0.09) {
trickle = 0;
burst(2);
}
}
// The lid swings back on its hinge and lifts a little as it goes.
hinge.rotation.x = -open * 1.32;
lid.position.y = 0.16 + open * 0.05;
glowMaterial.opacity = open * 0.7;
light.intensity = open * 4.5;
glow.scale.setScalar(3.2 + open * 0.6 + Math.sin(frame.time * 3) * 0.05);
body.rotation.y = -0.5 + Math.sin(frame.time * 0.5) * 0.18 + frame.pointer.x * 0.2 * frame.hover;
body.rotation.x = 0.14 + Math.sin(frame.time * 0.7) * 0.03;
object.position.y = Math.sin(frame.time * 0.9) * 0.06 - 0.3;
renderConfetti(frame.delta);
void built;
},
dispose() {
disposeObject(object);
disposables.forEach((geometry) => geometry.dispose());
[wrap, ribbon, dark, glowMaterial, pieceMaterial].forEach((material) => material.dispose());
pieceGeometry.dispose();
glowTexture.dispose();
light.dispose?.();
},
};
}components/vibebox/gift-box/index.tsx
/**
* VibeBox Motion · Gift Box
* 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 { GiftBox, type GiftBoxProps } from "./gift-box.client";
export type { GiftBoxOptions } from "./gift-box.sculpture";Props
| Prop | Type | Default | Description |
|---|---|---|---|
| colors | { box?: string; ribbon?: string } | — | The wrapping and the ribbon, as CSS colours; the page's spectrum when omitted. |
| autoOpen | boolean | true | Opens by itself for two seconds in every eight when nobody is on it. |
| label | string | — | Accessible name. |
| className | string | — | Merged with cn(); give it a size, about square, on a dark background. |
Accessibility and motion
- Keyboard
- Not focusable; the box is an illustration.
- Screen readers
- An image named by
label. - Touch
- A finger on the box opens it; scrolling is never blocked.
- Reduced motion
- One closed box, seen a little from above; the lid does not open and nothing falls.
Edge cases and limits
- It opens on hover, so on touch it opens under a finger and closes when the finger lifts.
- The confetti is additive-free but bright: keep it on a dark card.
autoOpen={false}leaves it closed until someone is on it.
Related

Product Box
FreeA packaging box in CSS 3D with your art on every face: drag it, or turn it a side at a time with the arrow keys.
3D · CSS

Brochure
FreeA 3D brochure whose paper bends: click a page, drag a corner or press the arrow keys to turn it.
3D · three.js

Coin
FreeA struck coin with a reeded edge, your mark and words on its face; hold the pointer on it and it turns over.
3D · three.js