Device frames
Phone, tablet, laptop and watch drawn in CSS. One width scales the whole device, the content can render at a real screen size.
One of 5 components in Media
Details
What Device frames brings along, read from the registry and the source.
- Category
- Media
- Area
- Elements
- Variants
- 4
- Version
- 1.0.1 · since 1.0.0
- File
- components/ui/device-frames.tsx
- Brings along
- utilskit
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PhoneDeviceProps interface from components/ui/device-frames.tsx, with the comments from the code. Also takes every prop of DeviceBase.
orientationoptional'portrait' | 'landscape'timeoptionalstringTime in the status bar
statusBaroptionalbooleanStatus bar with time, signal and battery (portrait only). Default true.
Accessibility
Read from the source of the component.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 378 lines
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, round } from '@/lib/kit';
/*
* Devices drawn with CSS only. Every size inside is a share of the device
* width (container query units), so one width prop scales the whole device,
* also when max-width: 100% squeezes it on a phone.
* Bezels stay dark in both themes, the metal edge is a mix of foreground and background.
*/
const SHELL = cn(
'[--dev-ink:color-mix(in_oklch,var(--foreground)_93%,var(--background))] dark:[--dev-ink:color-mix(in_oklch,var(--background)_93%,var(--foreground))]',
'[--dev-metal:color-mix(in_oklch,var(--foreground)_40%,var(--background))] [--dev-metal-soft:color-mix(in_oklch,var(--foreground)_18%,var(--background))]',
'dark:[--dev-metal:color-mix(in_oklch,var(--foreground)_42%,var(--background))] dark:[--dev-metal-soft:color-mix(in_oklch,var(--foreground)_20%,var(--background))]',
);
const METAL_H = 'linear-gradient(90deg, var(--dev-metal), var(--dev-metal-soft) 7%, var(--dev-metal) 50%, var(--dev-metal-soft) 93%, var(--dev-metal))';
const METAL_V = 'linear-gradient(180deg, var(--dev-metal), var(--dev-metal-soft) 7%, var(--dev-metal) 50%, var(--dev-metal-soft) 93%, var(--dev-metal))';
/** Container query length, rounded for hydration */
const cq = (n: number) => `${round(n, 3)}cqw`;
/* ------------------------------------------------------------------ */
/* Screen content, natural or scaled from a virtual viewport */
/* ------------------------------------------------------------------ */
function ScreenContent({ viewport, guess, style, children }: { viewport?: number; guess: number; style?: React.CSSProperties; children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(viewport ? round(guess / viewport, 4) : 1);
useEffect(() => {
const el = ref.current;
if (!el || !viewport || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(([e]) => {
const w = e.contentRect.width;
if (w) setScale(round(w / viewport, 4));
});
ro.observe(el);
return () => ro.disconnect();
}, [viewport]);
return (
<div ref={ref} data-clip-ok className="absolute inset-0 overflow-hidden" style={style}>
{viewport ? (
<div className="absolute left-0 top-0 origin-top-left" style={{ width: viewport, height: `${round(100 / scale, 3)}%`, transform: `scale(${scale})` }}>
{children}
</div>
) : (
children
)}
</div>
);
}
/** Status bar glyphs: signal, wifi, battery. Drawn, no icon set needed. */
function StatusIcons({ size }: { size: string }) {
return (
<span className="flex items-center" style={{ gap: `calc(${size} * 0.32)` }} aria-hidden>
<svg viewBox="0 0 18 12" style={{ height: `calc(${size} * 0.72)` }} fill="currentColor">
<rect x="0" y="8" width="3" height="4" rx="0.8" />
<rect x="5" y="5.5" width="3" height="6.5" rx="0.8" />
<rect x="10" y="3" width="3" height="9" rx="0.8" />
<rect x="15" y="0" width="3" height="12" rx="0.8" />
</svg>
<svg viewBox="0 0 16 12" style={{ height: `calc(${size} * 0.72)` }} fill="currentColor">
<path d="M8 2.2c2.4 0 4.6.9 6.3 2.5l1.2-1.3A10.8 10.8 0 0 0 8 .4C5.2.4 2.5 1.5.5 3.4l1.2 1.3A9 9 0 0 1 8 2.2Zm0 3.7c1.4 0 2.7.5 3.7 1.4l1.2-1.3A7.3 7.3 0 0 0 8 4.1c-1.8 0-3.6.7-4.9 1.9l1.2 1.3c1-.9 2.3-1.4 3.7-1.4Zm0 3.6c.5 0 1 .2 1.4.5L8 11.6 6.6 10c.4-.3.9-.5 1.4-.5Z" />
</svg>
<svg viewBox="0 0 27 13" style={{ height: `calc(${size} * 0.78)` }}>
<rect x="0.6" y="0.6" width="22.8" height="11.8" rx="3.4" fill="none" stroke="currentColor" strokeOpacity="0.4" strokeWidth="1.2" />
<rect x="2.4" y="2.4" width="16" height="8.2" rx="2" fill="currentColor" />
<path d="M25 4.3v4.4c.9-.3 1.5-1.2 1.5-2.2s-.6-1.9-1.5-2.2Z" fill="currentColor" fillOpacity="0.45" />
</svg>
</span>
);
}
interface DeviceBase {
/** Width in px (or any CSS width). The device never grows past its container. */
width?: number | string;
/** Render the children at this CSS width and scale them to fit, like a screenshot. Without it they fill the screen at their own size. */
viewport?: number;
/** Accessible name, e.g. "Northwind app on a phone". Without it the frame is a plain wrapper. */
label?: string;
className?: string;
/** The screen content */
children?: React.ReactNode;
}
const rootProps = (label: string | undefined, width: number | string, className: string | undefined, base: string) => ({
role: label ? 'group' : undefined,
'aria-label': label,
className: cn('relative max-w-full shrink-0 [container-type:inline-size]', SHELL, base, className),
style: { width },
});
const guessFrom = (width: number | string, fallback: number) => (typeof width === 'number' ? width : fallback);
/* ------------------------------------------------------------------ */
/* Phone */
/* ------------------------------------------------------------------ */
export interface PhoneDeviceProps extends DeviceBase {
orientation?: 'portrait' | 'landscape';
/** Time in the status bar */
time?: string;
/** Status bar with time, signal and battery (portrait only). Default true. */
statusBar?: boolean;
}
/**
* A modern phone with metal edge, side buttons, dynamic island, status bar and home indicator.
* <PhoneDevice width={300} viewport={390}><MyScreen /></PhoneDevice>
* <PhoneDevice orientation="landscape" width={620}>…</PhoneDevice>
*/
export function PhoneDevice({ width = 300, orientation = 'portrait', time = '8:30', statusBar = true, viewport, label, className, children }: PhoneDeviceProps) {
const land = orientation === 'landscape';
// u: one percent of the short side, expressed in container width
const u = land ? 0.4884 : 1;
const U = (n: number) => cq(n * u * 0.97);
const showBar = statusBar && !land;
const screenShare = (97 - 2 * 0.97 * u * 4.5) / 100;
return (
<div {...rootProps(label, width, className, '')}>
<div className="relative mx-auto" style={{ width: cq(97), aspectRatio: land ? '1466 / 716' : '716 / 1466' }}>
{/* side buttons */}
{(land
? [
{ side: 'top', at: 19, len: 3.6 },
{ side: 'top', at: 27, len: 7 },
{ side: 'top', at: 36, len: 7 },
{ side: 'bottom', at: 30, len: 11 },
]
: [
{ side: 'left', at: 19, len: 3.6 },
{ side: 'left', at: 27, len: 7 },
{ side: 'left', at: 36, len: 7 },
{ side: 'right', at: 30, len: 11 },
]
).map((b, i) => {
const horizontal = b.side === 'top' || b.side === 'bottom';
return (
<span
key={i}
aria-hidden
className="absolute"
style={{
[b.side]: U(-1.05),
...(horizontal ? { left: `${b.at}%`, width: `${b.len}%`, height: U(1.3) } : { top: `${b.at}%`, height: `${b.len}%`, width: U(1.3) }),
borderRadius: U(0.6),
background: horizontal ? METAL_H : METAL_V,
boxShadow: 'inset 0 0 0 0.5px color-mix(in oklch, var(--foreground) 25%, transparent)',
}}
/>
);
})}
{/* metal frame */}
<div className="absolute inset-0 shadow-[var(--shadow-lg)]" style={{ borderRadius: U(16.2), padding: U(0.9), background: land ? METAL_V : METAL_H }}>
{/* bezel */}
<div className="relative size-full bg-[var(--dev-ink)]" style={{ borderRadius: U(15.3), padding: U(3.6) }}>
{/* screen */}
<div data-clip-ok className="relative size-full overflow-hidden bg-background text-foreground ring-1 ring-inset ring-[color-mix(in_oklch,var(--foreground)_7%,transparent)]" style={{ borderRadius: U(11.9) }}>
<ScreenContent viewport={viewport} guess={guessFrom(width, 300) * screenShare} style={showBar ? { top: U(14) } : undefined}>
{children}
</ScreenContent>
{showBar && (
<div className="absolute inset-x-0 top-0 flex items-center justify-between bg-background font-semibold tracking-[-0.01em]" style={{ height: U(14), paddingInline: U(8.5), fontSize: U(4.5) }}>
<span className="tabular-nums">{time}</span>
<StatusIcons size={U(4.5)} />
</div>
)}
{/* dynamic island */}
<span
aria-hidden
className="absolute flex items-center justify-end bg-[var(--dev-ink)]"
style={
land
? { left: U(2.6), top: '50%', width: U(8.6), height: U(29), transform: 'translateY(-50%)', borderRadius: 999, flexDirection: 'column', paddingBottom: U(2.4) }
: { top: U(2.6), left: '50%', width: U(29), height: U(8.6), transform: 'translateX(-50%)', borderRadius: 999, paddingRight: U(2.4) }
}
>
<span className="rounded-full bg-[color-mix(in_oklch,var(--dev-ink)_70%,var(--primary))] opacity-60" style={{ width: U(3.2), height: U(3.2) }} />
</span>
{/* home indicator */}
<span aria-hidden className="absolute left-1/2 -translate-x-1/2 rounded-full bg-[color-mix(in_oklch,var(--foreground)_78%,transparent)]" style={{ bottom: U(2.2), width: land ? U(64) : U(36), height: U(1.35) }} />
</div>
</div>
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Tablet */
/* ------------------------------------------------------------------ */
export interface TabletDeviceProps extends DeviceBase {
orientation?: 'portrait' | 'landscape';
/** A thin status bar with time and battery. Default false. */
statusBar?: boolean;
time?: string;
}
/**
* A tablet with even bezels, rounded screen, camera and the top and volume buttons.
* <TabletDevice orientation="landscape" width={720} viewport={1024}>…</TabletDevice>
*/
export function TabletDevice({ width = 560, orientation = 'landscape', statusBar = false, time = '8:30', viewport, label, className, children }: TabletDeviceProps) {
const land = orientation === 'landscape';
const ratio = land ? 249.7 / 177.5 : 177.5 / 249.7;
// u: one percent of the short side
const u = land ? 1 / ratio : 1;
const U = (n: number) => cq(n * u * 0.985);
const screenShare = (land ? 100 - (2 * 0.7 + 2 * 4.4) * u : 100 - 2 * 0.7 - 2 * 4.4) * 0.985 / 100;
return (
<div {...rootProps(label, width, className, '')}>
<div className="relative mx-auto" style={{ width: cq(98.5), aspectRatio: land ? '2497 / 1775' : '1775 / 2497' }}>
{/* top button and volume */}
{(land
? [
{ side: 'top', at: 6, len: 8, vertical: false },
{ side: 'right', at: 9, len: 9, vertical: true },
{ side: 'right', at: 20, len: 9, vertical: true },
]
: [
{ side: 'top', at: 80, len: 11, vertical: false },
{ side: 'right', at: 7, len: 7, vertical: true },
{ side: 'right', at: 15, len: 7, vertical: true },
]
).map((b, i) => (
<span
key={i}
aria-hidden
className="absolute"
style={{
[b.side]: U(-0.7),
...(b.vertical ? { top: `${b.at}%`, height: `${b.len}%`, width: U(0.9) } : { left: `${b.at}%`, width: `${b.len}%`, height: U(0.9) }),
borderRadius: U(0.5),
background: b.vertical ? METAL_V : METAL_H,
}}
/>
))}
<div className="absolute inset-0 shadow-[var(--shadow-lg)]" style={{ borderRadius: U(7.4), padding: U(0.7), background: land ? METAL_V : METAL_H }}>
<div className="relative size-full bg-[var(--dev-ink)]" style={{ borderRadius: U(6.8), padding: U(4.4) }}>
{/* camera in the top bezel */}
<span
aria-hidden
className="absolute left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[color-mix(in_oklch,var(--dev-ink)_70%,var(--foreground))] shadow-[inset_0_0_0_1px_color-mix(in_oklch,var(--foreground)_12%,transparent)]"
style={{ top: U(2.2), width: U(1.3), height: U(1.3) }}
/>
<div data-clip-ok className="relative size-full overflow-hidden bg-background text-foreground ring-1 ring-inset ring-[color-mix(in_oklch,var(--foreground)_7%,transparent)]" style={{ borderRadius: U(2.6) }}>
<ScreenContent viewport={viewport} guess={guessFrom(width, 560) * screenShare} style={statusBar ? { top: U(4.2) } : undefined}>
{children}
</ScreenContent>
{statusBar && (
<div className="absolute inset-x-0 top-0 flex items-center justify-between bg-background font-semibold" style={{ height: U(4.2), paddingInline: U(3), fontSize: U(1.9) }}>
<span className="tabular-nums">{time}</span>
<StatusIcons size={U(1.9)} />
</div>
)}
<span aria-hidden className="absolute left-1/2 -translate-x-1/2 rounded-full bg-[color-mix(in_oklch,var(--foreground)_70%,transparent)]" style={{ bottom: U(1.1), width: U(land ? 22 : 18), height: U(0.6) }} />
</div>
</div>
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Laptop */
/* ------------------------------------------------------------------ */
export interface LaptopDeviceProps extends DeviceBase {}
/**
* A laptop: lid with thin bezel and camera, hinge, and a base with the opening notch.
* The width is the width of the base, the lid takes 86 percent of it.
* <LaptopDevice width={880} viewport={1280}>…</LaptopDevice>
*/
export function LaptopDevice({ width = 880, viewport, label, className, children }: LaptopDeviceProps) {
const screenShare = (86 - 2 * 1.5) / 100;
return (
<div {...rootProps(label, width, className, '')}>
{/* lid */}
<div
className="relative mx-auto bg-[var(--dev-ink)]"
style={{
width: cq(86),
padding: `${cq(2.1)} ${cq(1.5)} ${cq(1.9)}`,
borderRadius: `${cq(2.8)} ${cq(2.8)} ${cq(0.5)} ${cq(0.5)}`,
boxShadow: `0 0 0 ${cq(0.22)} var(--dev-metal), var(--shadow-lg)`,
}}
>
<span
aria-hidden
className="absolute left-1/2 -translate-x-1/2 rounded-full bg-[color-mix(in_oklch,var(--dev-ink)_65%,var(--foreground))] shadow-[inset_0_0_0_1px_color-mix(in_oklch,var(--foreground)_14%,transparent)]"
style={{ top: cq(0.75), width: cq(0.6), height: cq(0.6) }}
/>
<div data-clip-ok className="relative aspect-[16/10] overflow-hidden bg-background text-foreground ring-1 ring-inset ring-[color-mix(in_oklch,var(--foreground)_7%,transparent)]" style={{ borderRadius: cq(0.45) }}>
<ScreenContent viewport={viewport} guess={guessFrom(width, 880) * screenShare}>
{children}
</ScreenContent>
</div>
</div>
{/* hinge */}
<div aria-hidden className="mx-auto" style={{ width: cq(84), height: cq(0.7), background: 'linear-gradient(180deg, var(--dev-ink), color-mix(in oklch, var(--dev-ink) 70%, var(--dev-metal)))' }} />
{/* base */}
<div
aria-hidden
className="relative"
style={{
height: cq(2.2),
borderRadius: `${cq(0.3)} ${cq(0.3)} ${cq(2.4)} ${cq(2.4)} / ${cq(0.3)} ${cq(0.3)} ${cq(1.8)} ${cq(1.8)}`,
background: 'linear-gradient(180deg, color-mix(in oklch, var(--dev-metal) 55%, var(--background)) 0%, var(--dev-metal) 30%, var(--dev-metal-soft) 100%)',
boxShadow: `inset 0 ${cq(0.12)} 0 color-mix(in oklch, var(--background) 45%, transparent), 0 ${cq(1.2)} ${cq(2.4)} ${cq(-0.8)} color-mix(in oklch, var(--foreground) 28%, transparent)`,
}}
>
<span
className="absolute left-1/2 top-0 -translate-x-1/2"
style={{
width: cq(13),
height: cq(0.85),
borderRadius: `0 0 ${cq(0.9)} ${cq(0.9)}`,
background: 'linear-gradient(180deg, var(--dev-metal-soft), color-mix(in oklch, var(--dev-metal) 70%, var(--dev-metal-soft)))',
boxShadow: `inset 0 ${cq(0.15)} ${cq(0.3)} color-mix(in oklch, var(--foreground) 22%, transparent)`,
}}
/>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Watch */
/* ------------------------------------------------------------------ */
export interface WatchDeviceProps extends DeviceBase {}
/**
* A smartwatch with rounded case, digital crown, side button and band stubs.
* <WatchDevice width={180} viewport={184}>…</WatchDevice>
*/
export function WatchDevice({ width = 180, viewport, label, className, children }: WatchDeviceProps) {
const screenShare = (90 - 2 * (2 + 6)) / 100;
const band = (top: boolean) => (
<div
aria-hidden
style={{
marginLeft: cq(9),
width: cq(72),
height: cq(34),
background: 'linear-gradient(90deg, color-mix(in oklch, var(--dev-ink) 88%, var(--dev-metal)), color-mix(in oklch, var(--dev-ink) 72%, var(--dev-metal)) 50%, color-mix(in oklch, var(--dev-ink) 88%, var(--dev-metal)))',
borderRadius: top ? `${cq(6)} ${cq(6)} 0 0` : `0 0 ${cq(6)} ${cq(6)}`,
maskImage: `linear-gradient(${top ? '0deg' : '180deg'}, #000 30%, transparent 95%)`,
WebkitMaskImage: `linear-gradient(${top ? '0deg' : '180deg'}, #000 30%, transparent 95%)`,
}}
/>
);
return (
<div {...rootProps(label, width, className, '')}>
{band(true)}
<div className="relative" style={{ width: cq(90), aspectRatio: '41 / 49' }}>
{/* crown and button */}
<span aria-hidden className="absolute" style={{ right: cq(-5.2), top: '24%', width: cq(6.4), height: '17%', borderRadius: cq(2), background: METAL_V, boxShadow: `inset ${cq(-1)} 0 0 color-mix(in oklch, var(--foreground) 18%, transparent)` }} />
<span aria-hidden className="absolute" style={{ right: cq(-2), top: '50%', width: cq(3), height: '21%', borderRadius: cq(1.2), background: METAL_V }} />
<div className="absolute inset-0 shadow-[var(--shadow-lg)]" style={{ borderRadius: cq(25), padding: cq(2), background: METAL_H }}>
<div className="relative size-full bg-[var(--dev-ink)]" style={{ borderRadius: cq(23), padding: cq(6) }}>
<div data-clip-ok className="relative size-full overflow-hidden bg-background text-foreground ring-1 ring-inset ring-[color-mix(in_oklch,var(--foreground)_7%,transparent)]" style={{ borderRadius: cq(17) }}>
<ScreenContent viewport={viewport} guess={guessFrom(width, 180) * screenShare}>
{children}
</ScreenContent>
</div>
</div>
</div>
</div>
{band(false)}
</div>
);
}Frequent questions
How do I install Device frames?
With aiw add @ai-website/device-frames. The command puts components/ui/device-frames.tsx into your project, the code is yours from then on. It needs no further npm packages.
Which variants does Device frames have?
4 variants, Phone, Tablet, Laptop and Device set.
Can an AI assistant build with Device frames?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Device frames with aiw_components and install it with aiw_add @ai-website/device-frames.