Gerät in 3D als Mockup
Ein Telefon, Tablet, Laptop oder Browserfenster in echter CSS-Perspektive, mit Kante und Rückseite. Es ruht in einem Kamerawinkel, neigt sich zum Zeiger und schwebt sanft, solange es zu sehen ist, dein Inhalt bleibt darin bedienbar.
Dein Produkt in einem echten Gerät
Ein Geräte-Mockup zeigt das Produkt dort, wo Menschen es nutzen. Dieses ist in CSS-Perspektive gebaut, mit Kante und Rückseite, der Inhalt darin bleibt echtes HTML, klickbar und lesbar. Das Gerät ruht in einem Kamerawinkel, neigt sich zum Zeiger und schwebt sanft, solange es zu sehen ist.
„Telefon neben dem Text“ dreht ein Telefon mit Metallkante zum Text. „Laptop, der sich öffnet“ klappt den Deckel beim Hereinscrollen auf, mit einem bedienbaren Dashboard darin. „Tablet auf dem Tisch“ liegt schräg mit breitem Schatten, ruhig genug für eine ganze Section. „Browserfenster im Raum“ zeigt eine Seite in Desktop-Breite als gedrehte Glasfläche. „Kamerawinkel“ ergänzt Knöpfe, die das Telefon in fünf Winkel drehen, Rückseite inklusive, als Radiogruppe mit Pfeiltasten.
Setz deine echte Oberfläche ein statt eines Screenshots, sie bleibt auf jedem Bildschirm scharf und lässt sich im Code pflegen. Nimm Laptop oder Browserfenster für Software, das Telefon für Apps und mobile Seiten. Ein Gerät pro Section hält den Blick beim Produkt.
Variants
Details
Was Gerät in 3D mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- 3D-Flächen
- Bereich
- Visuals
- Variants
- 5
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/device-3d.tsx
- Bringt mit
- utilskitmockupdevice-framesdepth
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 8 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- 3D und Bewegung
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle Device3DProps aus components/ui/device-3d.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
Eingebaute Texte
Diese Texte schreibt Gerät in 3D selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 8 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| angles | Englisch Camera angle | Deutsch Kamerawinkel |
| front | Englisch Front | Deutsch Vorn |
| left | Englisch Left | Deutsch Links |
| right | Englisch Right | Deutsch Rechts |
| hero | Englisch Turned | Deutsch Gedreht |
| top | Englisch Above | Deutsch Von oben |
| desk | Englisch On the desk | Deutsch Auf dem Tisch |
| back | Englisch Back | Deutsch Rückseite |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Gerät in 3D reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
- Setzt aria-checked und aria-label, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen label, radio und radiogroup.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 325 Zeilen
'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>;
}Häufige Fragen
Wie installiere ich Gerät in 3D?
Mit aiw add @ai-website/device-3d. Der Befehl legt die Datei components/ui/device-3d.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind mockup, device-frames und depth. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Gerät in 3D?
5 Varianten, Telefon neben dem Text, Laptop, der sich öffnet, Tablet auf dem Tisch, Browserfenster im Raum und Kamerawinkel.
Kann ich die Texte von Gerät in 3D übersetzen?
Ja. Gerät in 3D bringt 8 eigene Texte auf Deutsch und Englisch mit, etwa „Kamerawinkel“, „Vorn“ und „Links“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Gerät in 3D einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Gerät in 3D mit aiw_components und installieren sie mit aiw_add @ai-website/device-3d.