3D phone
A rounded 3D phone with an editable React screen, camera pose, glass, and metal edges.
Customize
import {Scene, Video} from "odori";
import {Phone3d} from "../components/phone-3d/phone-3d";
<Video>
<Scene id="phone-3d" duration="6s">
<Phone3d
depth={24}
light={0.7}
/>
</Scene>
</Video>Installation
pnpm odori add component phone-3dnpx odori add component phone-3dbunx odori add component phone-3dEdit the source in videos/components/phone-3d/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/phone-3d/phone-3d.tsx
import { useMemo, type CSSProperties, type ReactNode } from "react";
import { useDesignScale } from "odori";
import { rimSegments, rotate, type PhonePose } from "./geometry";
export type { PhonePose } from "./geometry";
export type Phone3DProps = {
children?: ReactNode;
/** Screen coordinates are stable while the camera moves. */
screenWidth?: number;
screenHeight?: number;
bezel?: number;
radius?: number;
depth?: number;
pose?: Partial<PhonePose>;
/** Short edge of the composition used to author camera offsets and scale. */
designHeight?: number;
/** World-space rim illumination, from 0 to 1. */
light?: number;
};
const plane: CSSProperties = {
position: "absolute",
backfaceVisibility: "hidden",
transformStyle: "preserve-3d",
};
const defaultPose: PhonePose = {
x: 0,
y: 0,
scale: 0.68,
rotateX: -12,
rotateY: -22,
rotateZ: -8,
perspective: 1800,
};
/** A CSS 3D extrusion with a live React screen. No raster screen or camera-specific paths. */
export function Phone3D({
children,
screenWidth = 390,
screenHeight = 800,
bezel = 8,
radius = 46,
depth = 24,
pose: inputPose,
designHeight = 640,
light = 0.45,
}: Phone3DProps) {
const designScale = useDesignScale(designHeight);
const pose = { ...defaultPose, ...inputPose };
const width = screenWidth + bezel * 2,
height = screenHeight + bezel * 2;
const segments = useMemo(
() => rimSegments(width, height, radius),
[width, height, radius],
);
const front = depth / 2;
const normal = rotate([0, 0, 1], pose);
const sheen = Math.max(
0,
normal[0] * -0.35 + normal[1] * -0.5 + normal[2] * 0.79,
);
const surface: CSSProperties = { ...plane, inset: 0, borderRadius: radius };
return (
<div
data-odori-bleed
style={{
position: "absolute",
inset: 0,
perspective: pose.perspective * designScale,
perspectiveOrigin: "50% 50%",
}}
>
<div
data-phone-body
style={{
...plane,
left: "50%",
top: "50%",
width,
height,
marginLeft: -width / 2,
marginTop: -height / 2,
transform: `translate3d(${pose.x * designScale}px,${pose.y * designScale}px,0) scale3d(${pose.scale * designScale},${pose.scale * designScale},${pose.scale * designScale}) rotateZ(${pose.rotateZ}deg) rotateY(${pose.rotateY}deg) rotateX(${pose.rotateX}deg)`,
}}
>
<div
style={{
...surface,
background: "#101114",
border: "1px solid #32343a",
transform: `translateZ(${-front}px) rotateY(180deg)`,
}}
/>
{segments.map((segment, i) => {
const n = rotate(segment.normal, pose);
const diffuse = Math.max(0, n[0] * -0.35 + n[1] * -0.5 + n[2] * 0.79);
const specular = Math.pow(
Math.max(0, n[0] * -0.2 + n[1] * -0.3 + n[2] * 0.93),
16,
);
const value = Math.round(8 + light * (diffuse * 40 + specular * 100));
return (
<div
key={i}
style={{
...plane,
left: segment.x,
top: segment.y,
width: segment.length + 1,
height: depth,
marginLeft: -(segment.length + 1) / 2,
marginTop: -depth / 2,
transform: `rotateZ(${segment.angle}deg) rotateX(90deg)`,
background: `linear-gradient(0deg,rgb(${value * 0.5} ${value * 0.52} ${value * 0.56}),rgb(${value} ${value * 1.02} ${value * 1.06}) 45%,rgb(${value * 0.65} ${value * 0.68} ${value * 0.72}))`,
}}
/>
);
})}
{/* Ports live on the bottom side plane, so every camera sees the same hardware. */}
<div
style={{
...plane,
left: radius,
top: height,
width: width - radius * 2,
height: depth,
marginTop: -depth / 2,
transform: "rotateX(-90deg)",
}}
>
<div
style={{
position: "absolute",
left: "50%",
top: depth * 0.25,
width: 46,
height: depth * 0.5,
marginLeft: -23,
borderRadius: 8,
background: "#010102",
boxShadow: "inset 0 1px 2px #000,0 0 0 .5px #25262a",
}}
/>
{[-1, 1].flatMap((side) =>
Array.from({ length: 5 }, (_, i) => (
<span
key={`${side}-${i}`}
style={{
position: "absolute",
left: `calc(50% + ${side * (43 + i * 12) - 2}px)`,
top: depth / 2 - 2,
width: 4,
height: 4,
borderRadius: "50%",
background: "#010102",
boxShadow: "0 0 0 .35px #34363c",
}}
/>
)),
)}
</div>
<div
style={{
...surface,
transform: `translateZ(${front}px)`,
background: "#050506",
boxShadow: `inset 0 0 0 1px rgba(155,164,175,${0.08 + sheen * light * 0.2}),inset 0 0 0 3px #08090b`,
}}
/>
<div
data-phone-screen
style={{
...plane,
left: bezel,
top: bezel,
width: screenWidth,
height: screenHeight,
borderRadius: radius - bezel,
transform: `translateZ(${front + 0.2}px)`,
overflow: "hidden",
background: "#09090a",
color: "white",
}}
>
{children}
</div>
<div
style={{
...surface,
pointerEvents: "none",
transform: `translateZ(${front + 0.5}px)`,
background: `linear-gradient(${120 - pose.rotateY}deg,rgba(140,160,185,${sheen * light * 0.025}),transparent 40%,rgba(0,0,0,.08))`,
boxShadow: `inset 0 0 0 .5px rgba(166,177,190,${light * 0.35})`,
}}
/>
</div>
</div>
);
}
videos/components/phone-3d/geometry.ts
export type Vector = readonly [number, number, number];
export type PhonePose = {
x: number;
y: number;
scale: number;
rotateX: number;
rotateY: number;
rotateZ: number;
perspective: number;
};
export const radians = (degrees: number) => (degrees * Math.PI) / 180;
/** Same rotation order as the CSS transform, also used for world-space lighting. */
export function rotate([x, y, z]: Vector, pose: PhonePose): Vector {
const a = radians(pose.rotateX),
b = radians(pose.rotateY),
c = radians(pose.rotateZ);
const y1 = y * Math.cos(a) - z * Math.sin(a),
z1 = y * Math.sin(a) + z * Math.cos(a);
const x2 = x * Math.cos(b) + z1 * Math.sin(b),
z2 = -x * Math.sin(b) + z1 * Math.cos(b);
return [
x2 * Math.cos(c) - y1 * Math.sin(c),
x2 * Math.sin(c) + y1 * Math.cos(c),
z2,
];
}
/** Rounded-rectangle extrusion, generated once from dimensions, never shot coordinates. */
export function rimSegments(width: number, height: number, radius: number) {
const points: [number, number][] = [];
for (const [cx, cy, from] of [
[width - radius, radius, -90],
[width - radius, height - radius, 0],
[radius, height - radius, 90],
[radius, radius, 180],
]) {
for (let i = 0; i <= 16; i++) {
const angle = radians(from + (i * 90) / 16);
points.push([
cx + radius * Math.cos(angle),
cy + radius * Math.sin(angle),
]);
}
}
return points.map(([x, y], i) => {
const [nextX, nextY] = points[(i + 1) % points.length];
const dx = nextX - x,
dy = nextY - y,
length = Math.hypot(dx, dy);
return {
x: (x + nextX) / 2,
y: (y + nextY) / 2,
length,
angle: (Math.atan2(dy, dx) * 180) / Math.PI,
normal: [dy / length, -dx / length, 0] as Vector,
};
});
}
videos/components/phone-3d/phone-3d.preview.tsx
import { defineComponentPreview } from "odori/preview";
import { useFrame } from "odori";
import { Phone3D } from "./phone-3d";
function Screen() {
return (
<div
style={{
padding: "66px 24px 24px",
fontFamily: "system-ui,sans-serif",
color: "#eef0f4",
background: "linear-gradient(155deg,#1d2636,#0b0d12 65%)",
height: "100%",
}}
>
<div style={{ fontSize: 13, color: "#9ea7b8", marginBottom: 36 }}>
9:41
</div>
<h1 style={{ fontSize: 34, letterSpacing: "-.05em", margin: "0 0 9px" }}>
Your workspace.
</h1>
<p style={{ fontSize: 16, color: "#a6b0c1", margin: "0 0 38px" }}>
Everything is up to date.
</p>
{["Design review", "Mobile release", "Team notes"].map((name, i) => (
<div
key={name}
style={{
display: "flex",
alignItems: "center",
gap: 14,
padding: "22px 16px",
marginBottom: 12,
borderRadius: 18,
background: "#ffffff07",
border: "1px solid #ffffff0c",
}}
>
<span
style={{
width: 32,
height: 32,
borderRadius: 10,
background: ["#8798ce", "#86b1a0", "#b19dc4"][i],
}}
/>
<span style={{ fontSize: 16 }}>{name}</span>
</div>
))}
<div
style={{
position: "absolute",
bottom: 30,
left: 24,
right: 24,
display: "flex",
justifyContent: "space-around",
padding: 20,
borderRadius: 30,
background: "#ffffff0c",
fontSize: 13,
}}
>
<span>Home</span>
<span>Search</span>
<span>Inbox</span>
</div>
</div>
);
}
function Preview({
angle = -22,
depth = 24,
light = 0.7,
animate = true,
}: {
angle?: number;
depth?: number;
light?: number;
animate?: boolean;
}) {
const frame = useFrame();
return (
<Phone3D
depth={depth}
light={light}
pose={{
rotateY:
angle + (!animate ? 0 : Math.sin((frame / 180) * Math.PI * 2) * 8),
rotateX: -12,
rotateZ: -8,
scale: 0.64,
}}
>
<Screen />
</Phone3D>
);
}
export default defineComponentPreview({
title: "3D phone",
category: "Interface/Chrome",
description:
"A rounded 3D phone with an editable React screen, camera pose, glass, and metal edges.",
component: Preview,
canvas: { width: 1920, height: 1080, duration: "6s" },
controls: {
animate: { type: "boolean", defaultValue: true },
angle: { type: "number", defaultValue: -22, min: -70, max: 70 },
depth: { type: "number", defaultValue: 24, min: 8, max: 40 },
light: { type: "number", defaultValue: 0.7, min: 0, max: 1 },
},
examples: [
{ name: "Front", props: { angle: 0 } },
{ name: "Left", props: { angle: -40 } },
{ name: "Right", props: { angle: 40 } },
],
});
videos/components/phone-3d/README.md
# Put React content on a 3D phone
Use `Phone3D` to show an editable interface inside a rounded phone. The component builds the glass, extruded metal rim, charging port, and speaker holes with CSS 3D geometry. Your screen remains React content.
## Add a screen
The default screen uses a 390 × 800 coordinate space. Position content inside that space; the component scales the whole device with the composition’s short edge.
```tsx
import {useFrame} from "odori";
import {Phone3D} from "./components/phone-3d/phone-3d";
export function ProductPhone() {
const frame = useFrame();
return (
<Phone3D pose={{rotateY: -30 + frame / 15}}>
<div style={{padding: 32, color: "white"}}>
Your editable app interface
</div>
</Phone3D>
);
}
```
The component fills its positioned parent. Keep that parent free of opacity and filter effects: these can flatten CSS 3D descendants. Apply blur to individual screen elements instead.
## Set the camera
`pose` accepts `x`, `y`, `scale`, `rotateX`, `rotateY`, `rotateZ`, and `perspective`. Offsets use design pixels; rotations use degrees. The camera points toward the composition center. Smaller positive perspective values increase foreshortening.
The transform applies X, Y, then Z rotation before scale and translation. The component does not animate on its own. Derive pose values from `useFrame()` for repeatable seeking and exports.
## Set the body
| Prop | Default | Purpose |
| --- | --- | --- |
| `screenWidth` | 390 | Screen width before camera transforms |
| `screenHeight` | 800 | Screen height before camera transforms |
| `bezel` | 8 | Gap between the screen and outer edge |
| `radius` | 46 | Outer corner radius |
| `depth` | 24 | Physical body thickness |
| `designHeight` | 640 | Composition short edge used for design coordinates |
| `light` | 0.45 | Rim illumination, from 0 to 1 |
Use positive dimensions and perspective. Keep the corner radius larger than the bezel and smaller than half the body’s shortest side.
## Rendering limits
This is a CSS 3D extrusion, not a ray-traced hardware model. Surface shading follows the camera rotation, but reflections and lens blur remain approximations. The front, sides, and back share one geometry; the back has no camera module. Supply your own screen fonts and load them before export.
Add it to a scene
import {Scene, Video} from "odori";
import {Phone3d} from "../components/phone-3d/phone-3d";
<Video>
<Scene id="phone-3d" duration="6s">
<Phone3d
depth={24}
light={0.7}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Interface
- Recommended duration
- 180 frames · 6s
- Minimum duration
- 1 frames · 0.03s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- The phone is static unless the caller changes its pose; set animate to false in the preview.
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
children | ReactNode | — | — |
screenWidthScreen coordinates are stable while the camera moves. | number | — | — |
screenHeight | number | — | — |
bezel | number | — | — |
radius | number | — | — |
depth | number | — | — |
pose | Partial<PhonePose> | — | — |
designHeightShort edge of the composition used to author camera offsets and scale. | number | — | — |
lightWorld-space rim illumination, from 0 to 1. | number | — | — |
Related components
All componentsAccordionSections opening one at a time, the outgoing one closing as the next grows.Browser demoBrowser chrome for deterministic product interface frames.Card deckStacked cards that cycle from front to back.ComboboxA search field that filters a dropdown list.Command menuKeyboard-first action search and selection flow.Context menuThe menu that appears where you clicked, anchored to its own corner.