Device frame
A phone, tablet, or desktop body around a product surface.
Customize
import {Scene, Video} from "odori";
import {DeviceFrame} from "../components/device-frame/device-frame";
<Video>
<Scene id="device-frame" duration="4s">
<DeviceFrame
device="desktop"
url="app.example.com/deployments"
entranceFrames={20}
/>
</Scene>
</Video>Installation
pnpm odori add component device-framenpx odori add component device-framebunx odori add component device-frameEdit the source in videos/components/device-frame/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/device-frame/device-frame.tsx
import type {ReactNode} from "react";
import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type DeviceFrameProps = {
/** Which body to draw around the content. */
device?: "phone" | "tablet" | "desktop";
children?: ReactNode;
/** Shown in the desktop chrome's address bar. Ignored by the other two. */
url?: string;
/** Frames the device takes to settle into place. */
entranceFrames?: number;
};
const BODIES = {
phone: {width: 480, height: 980, radius: 62, bezel: 14},
tablet: {width: 900, height: 1180, radius: 40, bezel: 18},
desktop: {width: 1560, height: 940, radius: 22, bezel: 0},
} as const;
/**
* A product stage with a body around it: the same content reads as an app on a
* phone and as a site on a desktop, and the frame is what tells the viewer
* which claim is being made.
*
* Proportions are the real ones rather than a stylised outline, because a
* device that is the wrong shape quietly undermines the screenshot inside it.
* Nothing here is a photograph, so it takes the brand's colours and stays
* honest at any format.
*/
export const DeviceFrame = ({device = "desktop", children, url, entranceFrames = 20}: DeviceFrameProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const body = BODIES[device];
const appear = interpolate(frame, [0, entranceFrames], [0, 1], {easing: Easing.standard});
const bezel = body.bezel * scale;
return (
<Fill style={{alignItems: "center", justifyContent: "center", padding: 60 * scale}}>
<div
style={{
background: brand.colors.surface,
border: `${Math.max(1, 2 * scale)}px solid ${brand.colors.border}`,
borderRadius: body.radius * scale,
boxShadow: `0 ${44 * scale}px ${90 * scale}px ${brand.colors.shadow}`,
// A column, so the screen below the chrome can take the remaining
// height. Without this the body's `flex: 1` means nothing and the
// content collapses to zero — the frame renders, and it is empty.
display: "flex",
flexDirection: "column",
height: body.height * scale,
maxHeight: "100%",
maxWidth: "100%",
opacity: appear,
overflow: "hidden",
padding: bezel,
position: "relative",
transform: `translateY(${(1 - appear) * 26 * scale}px) scale(${0.985 + appear * 0.015})`,
width: body.width * scale,
}}
>
{device === "desktop" ? (
<div
style={{
alignItems: "center",
background: "rgba(255,255,255,0.02)",
borderBottom: `1px solid ${brand.colors.border}`,
display: "flex",
gap: 9 * scale,
padding: `${14 * scale}px ${20 * scale}px`,
}}
>
{[0, 1, 2].map((index) => (
<span
key={index}
style={{background: brand.colors.border, borderRadius: 999, height: 11 * scale, width: 11 * scale}}
/>
))}
{url ? (
<span
style={{
background: brand.colors.background,
borderRadius: 999,
color: brand.colors.muted,
fontFamily: brand.typography.mono,
// 17px is what a browser really shows and what `odori test`
// rejects: at 1080p it is a grey smudge. A video is read
// from further away than a screen is.
fontSize: 24 * scale,
marginLeft: 14 * scale,
padding: `${8 * scale}px ${20 * scale}px`,
}}
>
{url}
</span>
) : null}
</div>
) : null}
<div
style={{
background: brand.colors.background,
borderRadius: Math.max(0, (body.radius - body.bezel) * scale),
flex: 1,
overflow: "hidden",
position: "relative",
}}
>
{children}
</div>
{/* The one physical detail worth drawing: a phone's status notch and a
tablet's home line are what the eye uses to tell them apart. */}
{device === "phone" ? (
<span
style={{
background: brand.colors.background,
borderRadius: 999,
height: 26 * scale,
left: "50%",
position: "absolute",
top: bezel + 10 * scale,
transform: "translateX(-50%)",
width: 150 * scale,
}}
/>
) : null}
</div>
</Fill>
);
};
videos/components/device-frame/device-frame.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {DeviceFrame} from "./device-frame";
const Screen = () => (
<div
style={{
alignItems: "center",
color: "#f5f5f5",
display: "flex",
flexDirection: "column",
fontFamily: "ui-sans-serif, system-ui",
gap: 18,
inset: 0,
justifyContent: "center",
position: "absolute",
textAlign: "center",
}}
>
<strong style={{fontSize: 54, letterSpacing: "-0.03em"}}>Deployments</strong>
<span style={{color: "#8f8f8f", fontSize: 28}}>All systems normal</span>
</div>
);
export default defineComponentPreview({
title: "Device frame",
category: "Interface/Chrome",
description: "A phone, tablet, or desktop body around a product surface.",
component: DeviceFrame,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
device: {type: "select", defaultValue: "desktop", options: ["desktop", "tablet", "phone"]},
url: {type: "text", defaultValue: "app.example.com/deployments"},
entranceFrames: {type: "number", defaultValue: 20, min: 0, max: 60},
},
examples: [
{name: "Desktop", props: {children: <Screen />}},
{name: "Phone", props: {device: "phone", children: <Screen />}},
{name: "Tablet", props: {device: "tablet", children: <Screen />}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {DeviceFrame} from "../components/device-frame/device-frame";
<Video>
<Scene id="device-frame" duration="4s">
<DeviceFrame
device="desktop"
url="app.example.com/deployments"
entranceFrames={20}
/>
</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
- 120 frames · 4s
- Minimum duration
- 30 frames · 1s
- Entrance
- 20 frames
- Exit
- 0 frames
- Reduced motion
- the body appears in place without rising
- Requires
- sans font
- Content limits
- url ≤ 64
Props
| Prop | Type | Default | Required |
|---|---|---|---|
deviceWhich body to draw around the content. | "phone" | "tablet" | "desktop" | "desktop" | — |
children | ReactNode | — | — |
urlShown in the desktop chrome's address bar. Ignored by the other two. | string | — | — |
entranceFramesFrames the device takes to settle into place. | number | 20 | — |
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.