FreeIn reviewv1.0.0
Wireframe Cube Loader
A CSS-only wireframe cube with a glowing core, for the moment before a 3D scene or heavy view is ready.
npx shadcn@latest add @vibebox/wireframe-cube-loaderControls
120
<WireframeCubeLoader />About
A loader for the second or two before a WebGL scene, a 3D centrepiece or a heavy panel is ready. Two nested wireframe cubes turn in opposite directions over a soft glow. It is drawn with CSS 3D transforms only and renders on the server, so it ships no JavaScript and is on screen with the first paint. It replaces generic spinners on motion-heavy pages, where a spinner would look out of place. Ported from the VibeBox home hero, where it covers the sculpture stage while three.js loads.
Use it when
- A 3D scene, canvas or large client island is loading and the space must not sit empty.
- The loader should match a spectrum or tech-forward look.
Skip it when
- The wait is under about 200ms; it fades in after 180ms, so nothing would show. Leave the space empty.
- Content loads in place (lists, text). Use a skeleton instead.
- The page is calm and editorial (motion level 0 sites can still use it, but a quiet skeleton often fits better).
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/wireframe-cube-loader - 3Use it:
import { WireframeCubeLoader } from "@/components/vibebox/wireframe-cube-loader"; <div className="relative aspect-video"> <WireframeCubeLoader label="Rendering scene" hidden={ready} className="absolute inset-0" /> {/* the scene mounts here and sets ready */} </div>
Code
1 filescomponents/vibebox/wireframe-cube-loader/index.tsx
/**
* VibeBox Motion · Wireframe Cube Loader
* 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 type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
/* A CSS-only loader: a wireframe cube with a glowing core, spinning on transforms alone so it
costs nothing on the main thread. Faces take the spectrum tokens. */
const FACES = ["rotateY(0deg)", "rotateY(90deg)", "rotateY(180deg)", "rotateY(-90deg)", "rotateX(90deg)", "rotateX(-90deg)"] as const;
const TINTS = ["var(--spectrum-1)", "var(--spectrum-2)", "var(--spectrum-3)", "var(--spectrum-4)", "var(--spectrum-5)", "var(--spectrum-7)"] as const;
const KEYFRAMES = `
@keyframes vb-wcl-in { from { opacity: 0; transform: scale(0.94); } }
@keyframes vb-wcl-spin { from { transform: rotateX(-24deg) rotateY(38deg); } to { transform: rotateX(336deg) rotateY(398deg); } }
@keyframes vb-wcl-core { from { transform: rotateX(30deg) rotateY(0deg) rotateZ(45deg); } to { transform: rotateX(30deg) rotateY(-360deg) rotateZ(45deg); } }
@keyframes vb-wcl-breathe { 50% { opacity: 0.55; transform: scale(0.9); } }
@media (prefers-reduced-motion: no-preference) {
[data-vb="wireframe-cube-loader"] { animation: vb-wcl-in 400ms var(--ease-expo-out, cubic-bezier(0.16, 1, 0.3, 1)) 180ms both; }
[data-vb-wcl="cube"] { animation: vb-wcl-spin 4.8s linear infinite; }
[data-vb-wcl="core"] { animation: vb-wcl-core 3.2s linear infinite; }
[data-vb-wcl="glow"] { animation: vb-wcl-breathe 2.4s ease-in-out infinite; }
}
`;
export type WireframeCubeLoaderProps = {
/** Visible caption under the cube, also announced to screen readers. */
label?: string;
/** Cube size in pixels (the core is 40% of it). */
size?: number;
/** Fades the loader out (and hides it from assistive technology) once the content is ready. */
hidden?: boolean;
className?: string;
};
function Cube({ size, part, tints }: { size: number; part: "cube" | "core"; tints: readonly string[] }) {
const core = part === "core";
return (
<div
data-vb-wcl={part}
className="absolute top-1/2 left-1/2 [transform-style:preserve-3d]"
style={{
width: size,
height: size,
margin: `${-size / 2}px 0 0 ${-size / 2}px`,
transform: core ? "rotateX(30deg) rotateY(0deg) rotateZ(45deg)" : "rotateX(-24deg) rotateY(38deg)",
}}
>
{FACES.map((face, index) => {
const tint = tints[index]!;
return (
<span
key={face}
className={cn("absolute inset-0 border [backface-visibility:visible]", core ? "rounded-[3px]" : "rounded-[4px]")}
style={
{
borderColor: `color-mix(in oklab, ${tint} 70%, transparent)`,
background: core
? `color-mix(in oklab, ${tint} 35%, transparent)`
: `linear-gradient(135deg, color-mix(in oklab, ${tint} 12%, transparent) 0%, transparent 60%)`,
boxShadow: `inset 0 0 14px color-mix(in oklab, ${tint} 20%, transparent)`,
transform: `${face} translateZ(${size / 2}px)`,
} as CSSProperties
}
/>
);
})}
</div>
);
}
export function WireframeCubeLoader({ label = "Loading", size = 96, hidden = false, className }: WireframeCubeLoaderProps) {
const core = Math.round(size * 0.42);
return (
<div
data-vb="wireframe-cube-loader"
data-state={hidden ? "hidden" : "visible"}
role="status"
aria-hidden={hidden || undefined}
className={cn(
"pointer-events-none grid place-items-center content-center transition-opacity duration-500 ease-expo-out",
hidden && "opacity-0",
className,
)}
// The cube's perspective projection reaches past its box, so the gap grows with it.
style={{ rowGap: Math.round(size * 0.36) }}
>
<style href="vb-wireframe-cube-loader" precedence="vb">
{KEYFRAMES}
</style>
<div className="relative [perspective:420px]" style={{ width: size, height: size }}>
<div
data-vb-wcl="glow"
aria-hidden="true"
className="absolute top-1/2 left-1/2 rounded-full"
style={{
width: size * 1.6,
height: size * 1.6,
margin: `${-size * 0.8}px 0 0 ${-size * 0.8}px`,
background:
"radial-gradient(circle, color-mix(in oklab, var(--spectrum-3) 38%, transparent) 0%, color-mix(in oklab, var(--spectrum-1) 12%, transparent) 45%, transparent 70%)",
}}
/>
<Cube size={size} part="cube" tints={TINTS} />
<Cube size={core} part="core" tints={[...TINTS].reverse()} />
</div>
<span className="font-mono text-caption tracking-[0.08em] text-muted-foreground uppercase">{label}</span>
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | "Loading" | Caption under the cube, also announced to screen readers. |
| size | number | 96 | Cube size in pixels; the core is 42% of it. |
| hidden | boolean | false | Fades out and leaves the accessibility tree once the content is ready. |
| className | string | — | Merged with cn(). |
Accessibility and motion
- Keyboard
- Not focusable; it has no actions.
- Screen readers
- role="status" with the visible label ("Loading" by default); hidden once
hiddenis set. - Touch
- Nothing to touch.
- Reduced motion
- Both cubes at rest at an angle, the glow steady.
Edge cases and limits
- Very short loads: the 180ms delay means it never flashes.
- Long labels wrap under the cube; keep labels under about 40 characters.
sizebelow 48 makes the core too small to read.
Related

Puzzle Cube
ProA 3x3x3 puzzle cube that scrambles itself, solves with the exact inverse moves, then celebrates.
3D · three.js