Zum Inhalt springen
UI Kit
Accent

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

4 Variants

Variants

Telefon

Seitentasten, Dynamic Island, Statusleiste und Home-Balken, aufrecht oder quer für Karten und Video.

Tablet

Gleichmäßige Ränder, Kamera an der langen Kante, hier quer mit einer App, die in 980 px rendert und sich einpasst.

Laptop

Schmaler Rand mit Kamera, Scharnier und ein Unterteil mit Griffmulde. Ideal für Website-Vorschauen.

Geräteset

Laptop, Telefon und Uhr in einer Szene. Breiten in Prozent, das Set schrumpft als ein Bild.

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

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'
  • timeoptional
    string

    Time in the status bar

  • statusBaroptional
    boolean

    Status 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
components/ui/device-frames.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert