Cube
A real 3D cube in pure CSS, three ways: solid bright faces, a lined side, or beveled faces with popped-out edges.
betaCube.tsx
Examples
Variants
Solid faces, a hatched side on paper, or raised beveled keys.
cube/variants.tsx
import { Cube } from "@streamuilib/react";
export default function CubeVariants() {
return (
<div className="flex flex-wrap items-end justify-center gap-6">
<figure className="flex flex-col items-center gap-2">
<Cube variant="solid" label="Solid cube" />
<figcaption className="font-mono text-xs uppercase">solid</figcaption>
</figure>
<figure className="flex flex-col items-center gap-2">
<Cube variant="lined" tone="pink" label="Lined cube" />
<figcaption className="font-mono text-xs uppercase">lined</figcaption>
</figure>
<figure className="flex flex-col items-center gap-2">
<Cube variant="beveled" tone="blue" label="Beveled cube" />
<figcaption className="font-mono text-xs uppercase">beveled</figcaption>
</figure>
</div>
);
}Sizes and tones
cube/sizes.tsx
import { Cube } from "@streamuilib/react";
export default function CubeSizes() {
return (
<div className="flex flex-wrap items-end justify-center gap-4">
<Cube size="sm" tone="orange" />
<Cube size="md" tone="violet" variant="beveled" />
<Cube size="lg" tone="lime" variant="lined" />
</div>
);
}Spin
On a loop, or on hover. Both stop for reduced motion.
cube/spin.tsx
import { Cube } from "@streamuilib/react";
export default function CubeSpin() {
return (
<div className="flex flex-wrap items-center justify-center gap-8">
<Cube spin="loop" tone="pink" label="Spinning cube" />
{/* spin="hover" turns while the pointer is over it, or while its parent has focus. */}
<button type="button" className="rounded-none focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-blue">
<Cube spin="hover" variant="beveled" tone="orange" label="Hover to spin" />
</button>
</div>
);
}Installation
bun add @streamuilib/reactusage.tsx
import { Cube } from "@streamuilib/react";
<Cube variant="beveled" tone="blue" spin="loop" />API
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "solid" | "lined" | "beveled" | "solid" | Solid bright faces, paper with one hatched side, or raised faces with popped-out edges. |
| tone | "lime" | "pink" | "blue" | "violet" | "orange" | "lime" | The main color; solid and beveled cycle the other brights round the faces. |
| size | "sm" | "md" | "lg" | "md" | 56, 96, or 144px along an edge. |
| spin | "none" | "loop" | "hover" | "none" | Turn continuously, while hovered (or its parent is focused), or not at all. |
| label | string | — | Accessible name; gives it role="img". Without one it is hidden as decoration. |
| ...props | ComponentProps<"span"> | — | Native span attributes. |
Accessibility
- Decorative by default (aria-hidden); pass label to make it a named role="img".
- Six real faces in a CSS preserve-3d scene, so no canvas or JavaScript; it renders on the server.
- Spinning stops when the user prefers reduced motion.
Source
packages/react/src/Cube.tsx
import type { ComponentProps, CSSProperties } from "react";
import { cn } from "./cn";
import type { Tone } from "./tones";
export type CubeVariant = "solid" | "lined" | "beveled";
export type CubeSize = "sm" | "md" | "lg";
export type CubeSpin = "none" | "loop" | "hover";
type FaceTone = Tone | "paper";
export type CubeProps = Omit<ComponentProps<"span">, "children"> & {
/**
* - `solid`: every face a full bright color
* - `lined`: paper faces with a bright top, and one side hatched with ink lines
* - `beveled`: raised faces with popped-out edges, like a 3D key
*/
variant?: CubeVariant;
size?: CubeSize;
/** The main color. `solid` and `beveled` start from it and cycle the other brights round the faces. */
tone?: Tone;
/** Turn the cube: on a loop, on hover (or focus of its parent), or not at all. */
spin?: CubeSpin;
/** Accessible name. Without one the cube is decorative and hidden. */
label?: string;
};
const PX: Record<CubeSize, number> = { sm: 56, md: 96, lg: 144 };
const BRIGHTS: Tone[] = ["lime", "pink", "blue", "violet", "orange"];
/** Faces in order: front, right, back, left, top, bottom, as transforms from the cube's center. */
const FACES = [
"translateZ(calc(var(--sui-cube) / 2))",
"rotateY(90deg) translateZ(calc(var(--sui-cube) / 2))",
"rotateY(180deg) translateZ(calc(var(--sui-cube) / 2))",
"rotateY(-90deg) translateZ(calc(var(--sui-cube) / 2))",
"rotateX(90deg) translateZ(calc(var(--sui-cube) / 2))",
"rotateX(-90deg) translateZ(calc(var(--sui-cube) / 2))",
];
const fill = (t: FaceTone) => (t === "paper" ? "var(--paper-raised)" : `var(--${t})`);
/** What each face shows, per variant. */
function faceStyle(variant: CubeVariant, i: number, tone: Tone): CSSProperties {
const order = [tone, ...BRIGHTS.filter((b) => b !== tone)];
if (variant === "lined") {
// Paper faces, the top in the tone, and the right side (and its opposite) hatched.
if (i === 4) return { background: fill(tone) };
if (i === 1 || i === 3) {
return {
backgroundColor: "var(--paper-raised)",
backgroundImage: "repeating-linear-gradient(-45deg, var(--ink) 0 2.5px, transparent 2.5px 9px)",
};
}
return { background: "var(--paper-raised)" };
}
const color = fill(order[i % order.length]);
if (variant === "beveled") {
// Light catches the top-left edges and the bottom-right ones fall into shadow.
return {
background: color,
boxShadow:
"inset 0.4em 0.4em 0 color-mix(in oklab, white 50%, transparent), inset -0.4em -0.4em 0 color-mix(in oklab, var(--ink) 28%, transparent)",
};
}
return { background: color };
}
/**
* A 3D cube in CSS: six real faces in a preserve-3d scene, turned to show
* three of them. Pure CSS, no JavaScript. Decorative unless you give it a
* `label`; spinning stops for reduced motion.
*/
export function Cube({
variant = "solid",
size = "md",
tone = "lime",
spin = "none",
label,
className,
style,
...props
}: CubeProps) {
const px = PX[size];
return (
<span
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
data-spin={spin === "none" ? undefined : spin}
className={cn("sui-cube inline-grid shrink-0 place-items-center", className)}
style={{ "--sui-cube": `${px}px`, width: px * 1.7, height: px * 1.7, fontSize: px / 6, ...style } as CSSProperties}
{...props}
>
<span className="sui-cube-scene relative block" style={{ width: px, height: px }}>
{FACES.map((transform, i) => (
<span
key={i}
className="absolute inset-0 block border-[3px] border-ink"
style={{ transform, backfaceVisibility: "hidden", ...faceStyle(variant, i, tone) }}
>
{/* Beveled faces carry a raised key in the middle, edged the same way. */}
{variant === "beveled" && (
<span
className="absolute inset-[22%] block border-[3px] border-ink"
style={{
boxShadow:
"inset 0.3em 0.3em 0 color-mix(in oklab, white 55%, transparent), inset -0.3em -0.3em 0 color-mix(in oklab, var(--ink) 30%, transparent), 0.25em 0.25em 0 color-mix(in oklab, var(--ink) 40%, transparent)",
}}
/>
)}
</span>
))}
</span>
</span>
);
}