Skip to content
UI Kit
Accent color

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.

5 variantsOne of 6 components in 3D surfaces

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

Phone beside the textNew in 1.2

A phone turned toward the text, with a visible metal edge. It follows the mouse a few degrees and floats while it is on screen.

Laptop that opensNew in 1.2

A laptop with a deck and a keyboard seen from above. The lid opens on its hinge when the laptop scrolls into view, the dashboard inside is live.

Tablet on the deskNew in 1.2

A tablet lying on a desk at an angle, with its edge in view and a broad shadow below. Calm enough to carry a whole section beside it.

Browser window in spaceNew in 1.2

A browser window with a thin edge, turned to the left like a glass panel. The page inside renders at desktop width and stays clickable.

Camera anglesNew in 1.2

Buttons under the phone turn it to five angles, the back included. A radio group, arrow keys move between the angles.

Details

What Device in 3D brings along, read from the registry and the source.

Category
3D surfaces
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

Props

The Device3DProps interface from components/ui/device-3d.tsx, with the comments from the code.

  • deviceoptional
    Device3DKind

    phonetabletlaptopbrowser

  • angleoptional
    Device3DAngle

    frontleftrightherotopdeskback

    The camera angle the device rests at

  • widthoptional
    number | string

    Width of the device body in px (or any CSS width). It never grows past its container.

  • viewportoptional
    number

    Render the content at this CSS width and scale it to fit, like a screenshot

  • imageoptional
    string

    An image instead of children, shown from the top

  • imageAltoptional
    string
  • urloptional
    string

    browser: address in the bar

  • interactiveoptional
    boolean

    Lean toward a mouse or pen. Default true.

  • floatoptional
    boolean

    Float up and down gently while on screen. Default true.

  • anglesoptional
    Device3DAngle[]

    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

  • shadowoptional
    boolean

    Soft shadow on the floor. Default true.

  • labeloptional
    string

    Accessible name of the device, e.g. "Northwind app on a phone"

  • captionoptional
    string

    A short visible caption under the device

  • childrenoptional
    React.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
anglesEnglish Camera angleGerman Kamerawinkel
frontEnglish FrontGerman Vorn
leftEnglish LeftGerman Links
rightEnglish RightGerman Rechts
heroEnglish TurnedGerman Gedreht
topEnglish AboveGerman Von oben
deskEnglish On the deskGerman Auf dem Tisch
backEnglish BackGerman 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
components/ui/device-3d.tsx
'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.

Often searched

Jump to

Copied to the clipboard