3D device mockup
A phone, tablet, laptop or browser window in real CSS perspective, with an edge and a back. It rests at a camera angle, leans toward the pointer and floats gently while on screen, your content stays live inside.
Your product inside a real device
A device mockup shows the product where people will use it. This one is built in CSS perspective with an edge and a back, so the content inside stays live HTML, clickable and readable. The device rests at a camera angle, leans toward the pointer and floats gently while it is on screen.
Phone beside the text turns a phone with a metal edge toward the copy. Laptop that opens lifts the lid on its hinge as it scrolls into view, with a live dashboard inside. Tablet on the desk lies at an angle with a broad shadow, calm enough to carry a whole section. Browser window in space renders a page at desktop width as a turned glass panel. Camera angles adds buttons that turn the phone to five angles, the back included, as a radio group with arrow keys.
Put your real interface inside instead of a screenshot, it stays sharp on every screen and can be updated in code. Use the laptop or the browser window for software, the phone for apps and mobile sites. One device per section keeps the attention on the product.
Variants
Details
What Device in 3D brings along, read from the registry and the source.
- Category
- 3D surfaces
- Area
- Visuals
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/device-3d.tsx
- Brings along
- utilskitmockupdevice-framesdepth
- npm packages
- None
- Languages
- English and German, 8 texts of its own
- Studio
- Adjust in code
- In collections
- 3D and motion
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The Device3DProps interface from components/ui/device-3d.tsx, with the comments from the code.
deviceoptionalDevice3DKindphonetabletlaptopbrowser
angleoptionalDevice3DAnglefrontleftrightherotopdeskback
The camera angle the device rests at
widthoptionalnumber | stringWidth of the device body in px (or any CSS width). It never grows past its container.
viewportoptionalnumberRender the content at this CSS width and scale it to fit, like a screenshot
imageoptionalstringAn image instead of children, shown from the top
imageAltoptionalstringurloptionalstringbrowser: address in the bar
interactiveoptionalbooleanLean toward a mouse or pen. Default true.
floatoptionalbooleanFloat up and down gently while on screen. Default true.
anglesoptionalDevice3DAngle[]Camera buttons under the device, one per angle. Arrow keys move between them.
lidoptional'view' | 'open'laptop: the lid opens when the laptop scrolls into view (default) or stands open
shadowoptionalbooleanSoft shadow on the floor. Default true.
labeloptionalstringAccessible name of the device, e.g. "Northwind app on a phone"
captionoptionalstringA short visible caption under the device
childrenoptionalReact.ReactNode
Built-in texts
Device in 3D writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 8 texts
| Key | English | German |
|---|---|---|
| angles | English Camera angle | German Kamerawinkel |
| front | English Front | German Vorn |
| left | English Left | German Links |
| right | English Right | German Rechts |
| hero | English Turned | German Gedreht |
| top | English Above | German Von oben |
| desk | English On the desk | German Auf dem Tisch |
| back | English Back | German Rückseite |
Accessibility
Read from the source of the component.
- Device in 3D handles the keys Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
- Sets aria-checked and aria-label, so screen readers announce purpose and state.
- Uses the roles label, radio and radiogroup.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 325 lines
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useTexts } from '@/lib/kit';
import { BrowserMockup, MockupScreen, PhoneMockup } from '@/components/ui/mockup';
import { TabletDevice } from '@/components/ui/device-frames';
import { Glare, Slab, ease3d, useActive, usePointerTilt } from '@/components/ui/depth';
/*
* A device in real CSS perspective: a phone, a tablet, a laptop or a browser window with thickness,
* turned to a camera angle, leaning toward a mouse and floating gently while it is on screen. The
* content comes as children (or an image) and keeps working inside, links and buttons included.
*
* Every device is a body with an edge (Slab): copies of its outline stacked behind the face, so a
* turn shows a side instead of a paper sheet, and the back shows a back. The laptop is built from
* two bodies, a base lying flat and a lid on a hinge that opens when the laptop scrolls into view.
*
* Without JavaScript, on touch screens and with reduced motion the device rests at its angle: a
* finished still picture. Pointer lean and floating run only while the device is on screen.
*
* <Device3D device="phone" angle="right"><App /></Device3D>
* <Device3D device="laptop" angle="hero" viewport={1280}>…</Device3D>
* <Device3D device="phone" angles={['front', 'left', 'right', 'back']}>…</Device3D>
*/
export type Device3DKind = 'phone' | 'tablet' | 'laptop' | 'browser';
export type Device3DAngle = 'front' | 'left' | 'right' | 'hero' | 'top' | 'desk' | 'back';
export interface Device3DProps {
device?: Device3DKind;
/** The camera angle the device rests at */
angle?: Device3DAngle;
/** Width of the device body in px (or any CSS width). It never grows past its container. */
width?: number | string;
/** Render the content at this CSS width and scale it to fit, like a screenshot */
viewport?: number;
/** An image instead of children, shown from the top */
image?: string;
imageAlt?: string;
/** browser: address in the bar */
url?: string;
/** Lean toward a mouse or pen. Default true. */
interactive?: boolean;
/** Float up and down gently while on screen. Default true. */
float?: boolean;
/** Camera buttons under the device, one per angle. Arrow keys move between them. */
angles?: Device3DAngle[];
/** laptop: the lid opens when the laptop scrolls into view (default) or stands open */
lid?: 'view' | 'open';
/** Soft shadow on the floor. Default true. */
shadow?: boolean;
/** Accessible name of the device, e.g. "Northwind app on a phone" */
label?: string;
/** A short visible caption under the device */
caption?: string;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
children?: React.ReactNode;
}
const TEXTS = {
en: { angles: 'Camera angle', front: 'Front', left: 'Left', right: 'Right', hero: 'Turned', top: 'Above', desk: 'On the desk', back: 'Back' },
de: { angles: 'Kamerawinkel', front: 'Vorn', left: 'Links', right: 'Rechts', hero: 'Gedreht', top: 'Von oben', desk: 'Auf dem Tisch', back: 'Rückseite' },
};
type Turn = { x: number; y: number; z: number };
/** Rotations per device. A standing device tips back to show its face from above; the laptop tips toward the camera to show its deck. */
const ANGLES: Record<'flat' | 'laptop', Record<Device3DAngle, Turn>> = {
flat: {
front: { x: 0, y: 0, z: 0 },
left: { x: 4, y: 26, z: 0 },
right: { x: 4, y: -26, z: 0 },
hero: { x: 8, y: -22, z: 3 },
top: { x: 34, y: 0, z: 0 },
desk: { x: 48, y: 0, z: -30 },
back: { x: 4, y: 180, z: 0 },
},
laptop: {
front: { x: -10, y: 0, z: 0 },
left: { x: -12, y: 30, z: 0 },
right: { x: -12, y: -30, z: 0 },
hero: { x: -14, y: -22, z: 0 },
top: { x: -36, y: 0, z: 0 },
desk: { x: -30, y: -38, z: 0 },
back: { x: -12, y: 180, z: 0 },
},
};
const WIDTH: Record<Device3DKind, number> = { phone: 280, tablet: 560, laptop: 720, browser: 820 };
const cq = (n: number) => `${round(n, 3)}cqw`;
const METAL = 'color-mix(in oklab, var(--foreground) 34%, var(--background))';
const INK = 'var(--bezel)';
/* ------------------------------------------------------------------ */
/* Bodies */
/* ------------------------------------------------------------------ */
function PhoneBody({ viewport, image, imageAlt, children }: Pick<Device3DProps, 'viewport' | 'image' | 'imageAlt' | 'children'>) {
// PhoneMockup draws the face; its body is 97 % of the width, aspect 71 / 147, corner 15 %
const back = (
<span className="absolute inset-0" style={{ background: `linear-gradient(160deg, color-mix(in oklab, ${METAL} 82%, white), ${METAL} 55%, color-mix(in oklab, ${METAL} 88%, black))` }}>
<span className="absolute grid grid-cols-2 gap-[14%] p-[14%] shadow-[0_2px_10px_rgb(0_0_0/0.25)]" style={{ left: cq(6), top: cq(5), width: cq(34), height: cq(34), borderRadius: cq(9), background: `color-mix(in oklab, ${METAL} 70%, black)` }}>
{[0, 1, 2].map((i) => (
<span key={i} className="rounded-full" style={{ background: 'radial-gradient(circle at 38% 36%, color-mix(in oklab, var(--primary) 50%, #1a1d24), #0d0f14 62%)', boxShadow: '0 0 0 2px color-mix(in oklab, #fff 18%, transparent)' }} />
))}
</span>
</span>
);
return (
<Slab depth={cq(7)} layers={7} radius={cq(15)} inset={`0 ${cq(1.5)}`} edge={METAL} back={back}>
<PhoneMockup width="100%" viewport={viewport} image={image} imageAlt={imageAlt}>
{children}
</PhoneMockup>
<Glare className="inset-x-[1.5%] rounded-[15cqw]" />
</Slab>
);
}
function TabletBody({ viewport, image, imageAlt, children }: Pick<Device3DProps, 'viewport' | 'image' | 'imageAlt' | 'children'>) {
const radius = cq(7.4 * (1775 / 2497) * 0.985);
const back = (
<span className="absolute inset-0" style={{ background: `linear-gradient(160deg, color-mix(in oklab, ${METAL} 82%, white), ${METAL} 60%)` }}>
<span className="absolute rounded-full" style={{ left: cq(4), top: cq(4), width: cq(5), height: cq(5), background: `color-mix(in oklab, ${METAL} 60%, black)` }} />
</span>
);
return (
<Slab depth={cq(2.4)} layers={5} radius={radius} inset={`0 ${cq(0.75)}`} edge={METAL} back={back}>
<TabletDevice width="100%" orientation="landscape" viewport={viewport}>
{image ? <img src={image} alt={imageAlt ?? ''} className="size-full object-cover object-top" /> : children}
</TabletDevice>
<Glare className="inset-x-[0.75%] rounded-[5.2cqw]" />
</Slab>
);
}
function BrowserBody({ url, viewport, image, imageAlt, children }: Pick<Device3DProps, 'url' | 'viewport' | 'image' | 'imageAlt' | 'children'>) {
return (
<Slab depth={cq(1.6)} layers={4} radius="12px" edge="color-mix(in oklab, var(--foreground) 16%, var(--background))">
<BrowserMockup url={url} aspect="16 / 10" viewport={viewport} image={image} imageAlt={imageAlt}>
{children}
</BrowserMockup>
<Glare strength={0.2} className="rounded-xl" />
</Slab>
);
}
/** Lid on a hinge over a base lying flat. The lid angle comes from --lid (degrees, 0 is upright). */
function LaptopBody({ viewport, image, imageAlt, open, children }: Pick<Device3DProps, 'viewport' | 'image' | 'imageAlt' | 'children'> & { open: boolean }) {
const lidH = 64;
const deck = 60;
return (
<div className="relative [transform-style:preserve-3d]" style={{ width: '100%', height: cq(lidH) }}>
{/* base, hinged at the bottom of the lid, lying toward the camera */}
<div className="absolute inset-x-0 [transform-style:preserve-3d]" style={{ top: cq(lidH), height: cq(deck), transformOrigin: 'top', transform: 'rotateX(90deg)' }}>
<Slab depth={cq(1.8)} layers={5} radius={`${cq(1.2)} ${cq(1.2)} ${cq(3)} ${cq(3)}`} edge={METAL} className="size-full">
<div aria-hidden className="relative" style={{ height: cq(deck), borderRadius: `${cq(1.2)} ${cq(1.2)} ${cq(3)} ${cq(3)}`, background: `linear-gradient(180deg, color-mix(in oklab, ${METAL} 70%, white), ${METAL})` }}>
{/* keyboard well with keys drawn as a pattern, no hundred elements */}
<span
className="absolute isolate"
style={{
left: cq(9),
right: cq(9),
top: cq(5),
height: cq(26),
borderRadius: cq(1),
background: INK,
backgroundImage: `repeating-linear-gradient(90deg, transparent 0 ${cq(0.45)}, color-mix(in oklab, ${INK} 86%, white) ${cq(0.45)} ${cq(5.4)}), repeating-linear-gradient(180deg, transparent 0 ${cq(0.45)}, transparent ${cq(0.45)} ${cq(5.3)})`,
maskImage: `repeating-linear-gradient(180deg, black 0 ${cq(4.6)}, transparent ${cq(4.6)} ${cq(5.3)})`,
}}
/>
{/* trackpad */}
<span className="absolute isolate left-1/2 -translate-x-1/2" style={{ top: cq(35), width: cq(28), height: cq(19), borderRadius: cq(1.6), background: `color-mix(in oklab, ${METAL} 88%, white)`, boxShadow: `inset 0 0 0 ${cq(0.15)} color-mix(in oklab, ${METAL} 70%, black)` }} />
</div>
</Slab>
</div>
{/* lid */}
<div
className="absolute inset-0 [transform-style:preserve-3d] motion-safe:transition-transform"
style={{ transformOrigin: 'bottom', transform: `rotateX(var(--lid-angle, ${open ? 8 : -88}deg))`, transitionTimingFunction: ease3d, transitionDuration: 'var(--lid-duration, 1400ms)' }}
>
<Slab depth={cq(1)} layers={4} radius={`${cq(2.2)} ${cq(2.2)} ${cq(0.6)} ${cq(0.6)}`} edge={METAL} back={<span className="absolute inset-0" style={{ background: `linear-gradient(160deg, color-mix(in oklab, ${METAL} 80%, white), ${METAL})` }} />} className="size-full">
<div className="relative size-full" style={{ height: cq(lidH), padding: `${cq(2.4)} ${cq(2.2)} ${cq(3.2)}`, borderRadius: `${cq(2.2)} ${cq(2.2)} ${cq(0.6)} ${cq(0.6)}`, background: INK, boxShadow: `inset 0 0 0 ${cq(0.2)} ${METAL}` }}>
<span aria-hidden className="absolute left-1/2 -translate-x-1/2 rounded-full bg-[color-mix(in_oklab,var(--bezel)_60%,var(--foreground))]" style={{ top: cq(0.9), width: cq(0.6), height: cq(0.6) }} />
<div data-clip-ok className="relative size-full overflow-hidden bg-background text-foreground" style={{ borderRadius: cq(0.5) }}>
<MockupScreen viewport={viewport} guess={680} image={image} imageAlt={imageAlt}>
{children}
</MockupScreen>
</div>
<Glare strength={0.22} />
</div>
</Slab>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Camera buttons, a radio group */
/* ------------------------------------------------------------------ */
function AngleSwitch({ angles, value, onChange, t }: { angles: Device3DAngle[]; value: Device3DAngle; onChange: (a: Device3DAngle) => void; t: (typeof TEXTS)['en'] }) {
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const key = (e: React.KeyboardEvent, i: number) => {
const n = angles.length;
const to = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? (i + 1) % n : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? (i - 1 + n) % n : e.key === 'Home' ? 0 : e.key === 'End' ? n - 1 : -1;
if (to < 0) return;
e.preventDefault();
onChange(angles[to]);
refs.current[to]?.focus();
};
return (
<div role="radiogroup" aria-label={t.angles} className="relative z-10 mx-auto mt-10 kit-no-scrollbar flex w-fit max-w-full gap-1 sm:justify-center overflow-x-auto rounded-xl max-sm:w-full border border-border bg-card p-1 shadow-[var(--highlight),var(--shadow-sm)]">
{angles.map((a, i) => {
const on = a === value;
return (
<button
key={a}
ref={(n) => {
refs.current[i] = n;
}}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => onChange(a)}
onKeyDown={(e) => key(e, i)}
className={cn(
'min-h-11 cursor-pointer whitespace-nowrap rounded-lg px-3.5 text-[13.5px] max-sm:flex-auto max-sm:px-1.5 max-sm:text-[13px] font-medium transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary sm:min-h-9 sm:px-4',
on ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
{t[a]}
</button>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/** A device in real perspective with thickness, leaning toward the pointer. */
export function Device3D({ device = 'phone', angle = 'hero', width, viewport, image, imageAlt, url, interactive = true, float = true, angles, lid = 'view', shadow = true, label, caption, labels, className, children }: Device3DProps) {
const t = useTexts(TEXTS, labels);
const [current, setCurrent] = useState<Device3DAngle>(angle);
useEffect(() => setCurrent(angle), [angle]);
const tilt = usePointerTilt<HTMLDivElement>();
const [watch, active] = useActive<HTMLDivElement>();
// The lid stands open on the server and without JavaScript; it closes only once the script knows it can open it again in view
const [open, setOpen] = useState(true);
useEffect(() => {
if (device !== 'laptop' || lid === 'open' || prefersReducedMotion()) return;
const el = watch.current;
if (!el || el.getBoundingClientRect().top < window.innerHeight * 0.55) return;
setOpen(false);
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) {
setOpen(true);
io.disconnect();
}
}, { rootMargin: '0px 0px -22% 0px' });
io.observe(el);
return () => io.disconnect();
}, [device, lid, watch]);
const set = ANGLES[device === 'laptop' ? 'laptop' : 'flat'][current];
const w = width ?? WIDTH[device];
const lean = interactive ? { x: device === 'laptop' ? 8 : 12, y: device === 'laptop' ? 5 : 8 } : { x: 0, y: 0 };
// Room for what the turn projects outside the layout box: the laptop deck, a device lying on a desk
const room = device === 'laptop' ? (current === 'top' ? 34 : current === 'desk' ? 30 : 24) : 0;
return (
<div className={cn('relative w-full', device === 'laptop' && 'px-[7%]', className)}>
{/* The laptop deck comes toward the camera and projects wider than the lid: the side room keeps it inside narrow containers */}
<div ref={watch} className="relative mx-auto w-full [container-type:inline-size]" style={{ maxWidth: w }} role={label ? 'group' : undefined} aria-label={label}>
<div
ref={tilt.ref}
{...(interactive ? tilt.handlers : {})}
className="relative [perspective:340cqw]"
style={{ paddingBottom: cq(room), perspectiveOrigin: '50% 40%' }}
>
{shadow && (
<span
aria-hidden
className="pointer-events-none absolute left-1/2 -translate-x-1/2 rounded-[50%] bg-[color-mix(in_oklab,var(--foreground)_20%,transparent)] blur-2xl transition-[width,opacity] duration-700 dark:bg-[color-mix(in_oklab,black_70%,transparent)]"
style={{ bottom: device === 'laptop' ? cq(-2) : cq(-6), width: current === 'desk' || current === 'top' ? '96%' : '78%', height: cq(device === 'phone' ? 14 : 8) }}
/>
)}
<div className={cn('[transform-style:preserve-3d]', float && 'motion-safe:animate-kit-float', float && !active && '[animation-play-state:paused]')}>
<div className="[transform-style:preserve-3d]" style={{ transform: `rotateX(calc(var(--tilt-y, 0) * ${-lean.y}deg)) rotateY(calc(var(--tilt-x, 0) * ${lean.x}deg))` }}>
<div
className="[transform-style:preserve-3d] motion-safe:transition-transform motion-safe:duration-[1100ms]"
style={{ transform: `rotateX(${set.x}deg) rotateY(${set.y}deg) rotateZ(${set.z}deg)`, transitionTimingFunction: ease3d }}
>
{device === 'phone' && <PhoneBody viewport={viewport} image={image} imageAlt={imageAlt}>{children}</PhoneBody>}
{device === 'tablet' && <TabletBody viewport={viewport} image={image} imageAlt={imageAlt}>{children}</TabletBody>}
{device === 'browser' && <BrowserBody url={url} viewport={viewport} image={image} imageAlt={imageAlt}>{children}</BrowserBody>}
{device === 'laptop' && <LaptopBody viewport={viewport} image={image} imageAlt={imageAlt} open={open}>{children}</LaptopBody>}
</div>
</div>
</div>
</div>
</div>
{angles && angles.length > 1 && <AngleSwitch angles={angles} value={current} onChange={setCurrent} t={t} />}
{caption && <p className="relative mt-8 text-center text-[13.5px] text-muted-foreground">{caption}</p>}
</div>
);
}
/* ------------------------------------------------------------------ */
/* The body alone, for scenes that turn it themselves */
/* ------------------------------------------------------------------ */
/**
* Only the device body with its edge and back, without camera, lean or float. Put it inside your
* own 3D stage (transform-style: preserve-3d) and turn it there, e.g. in rotate-showcase.
* It sizes itself to its container width, which must be a size container ([container-type:inline-size]).
*/
export function DeviceBody({ device = 'phone', viewport, image, imageAlt, url, open = true, children }: Pick<Device3DProps, 'device' | 'viewport' | 'image' | 'imageAlt' | 'url' | 'children'> & { open?: boolean }) {
if (device === 'tablet') return <TabletBody viewport={viewport} image={image} imageAlt={imageAlt}>{children}</TabletBody>;
if (device === 'browser') return <BrowserBody url={url} viewport={viewport} image={image} imageAlt={imageAlt}>{children}</BrowserBody>;
if (device === 'laptop') return <LaptopBody viewport={viewport} image={image} imageAlt={imageAlt} open={open}>{children}</LaptopBody>;
return <PhoneBody viewport={viewport} image={image} imageAlt={imageAlt}>{children}</PhoneBody>;
}Frequent questions
How do I install Device in 3D?
With aiw add @ai-website/device-3d. The command puts components/ui/device-3d.tsx into your project, the code is yours from then on. It brings along mockup, device-frames and depth. It needs no further npm packages.
Which variants does Device in 3D have?
5 variants, Phone beside the text, Laptop that opens, Tablet on the desk, Browser window in space and Camera angles.
Can I translate the texts of Device in 3D?
Yes. Device in 3D carries 8 texts of its own in English and German, such as “Camera angle”, “Front” and “Left”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Device in 3D?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Device in 3D with aiw_components and install it with aiw_add @ai-website/device-3d.