Geräterahmen
Telefon, Tablet, Laptop und Uhr, gezeichnet in CSS. Eine Breite skaliert das ganze Gerät, der Inhalt kann in echter Bildschirmgröße rendern.
Eine von 5 Components in Media
Variants
Details
Was Geräterahmen mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Media
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.1 · seit 1.0.0
- Datei
- components/ui/device-frames.tsx
- Bringt mit
- utilskit
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle PhoneDeviceProps aus components/ui/device-frames.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von DeviceBase an.
orientationoptional'portrait' | 'landscape'timeoptionalstringTime in the status bar
statusBaroptionalbooleanStatus bar with time, signal and battery (portrait only). Default true.
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 378 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Geräterahmen?
Mit aiw add @ai-website/device-frames. Der Befehl legt die Datei components/ui/device-frames.tsx in dein Projekt, der Code gehört danach dir. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Geräterahmen?
4 Varianten, Telefon, Tablet, Laptop und Geräteset.
Kann ein KI-Assistent Geräterahmen einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Geräterahmen mit aiw_components und installieren sie mit aiw_add @ai-website/device-frames.