Skip to content

FreeIn reviewv1.0.0

Gift Box

A wrapped gift whose lid swings open on hover, with a warm light and a burst of confetti.
npx shadcn@latest add @vibebox/gift-box

Controls

<GiftBox />

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

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

PropTypeDefaultDescription
colors{ box?: string; ribbon?: string }—The wrapping and the ribbon, as CSS colours; the page's spectrum when omitted.
autoOpenbooleantrueOpens by itself for two seconds in every eight when nobody is on it.
labelstring—Accessible name.
classNamestring—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.
  • A cream tea carton with green sides turning in a dark room

    A 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

  • 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 gold coin with a V in the middle and words around its rim, turning on its edge

    Coin

    Free

    A 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

↑ ↓ to move↵ to openesc to close