Zum Inhalt springen
UI Kit
Accent

Mockups

Browserfenster und Geräte in eigener Form, gezeichnet in CSS. Eine Component oder ein Bild kommt hinein, eine Breite skaliert alles.

Eine von 2 Components in Mockups

8 Variants

Variants

Browserfenster

Drei Punkte und eine mittige Adresszeile. Die Seite darin rendert in 1200 px und passt sich ein.

Browser mit Tabs

Tabs über der Werkzeugleiste, der aktive verschmilzt mit ihr. Zurück, Vor und Neu laden stehen vor der Adresse.

Helles und dunkles Fenster

tone stellt ein Fenster fest hell oder dunkel, egal wie die Seite steht. Gut, um beide Modi eines Produkts zu zeigen.

Telefon

Eine eigene Telefonform mit weichen Ecken, Kamerakapsel oder einzelner Linse und Rahmen in Metall oder Tinte.

Tablet

Gleichmäßige Ränder und eine Kamera an der langen Kante. Hier eine App in 1024 px, quer eingepasst.

Laptop

Ein gleichmäßiger Rand ohne Aussparung, ein Scharnierband und ein schlankes Unterteil mit Füßen. Die Breite ist das Unterteil.

Monitor

Ein Display in 16:9 mit schmalem Rand, Statuslicht in der Kante und Standfuß.

Runde Uhr

Ein rundes Gehäuse mit Minutenstrichen und zwei Drückern. Der Ring auf dem Display füllt sich beim Erscheinen.

Details

Was Mockups mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Mockups
Bereich
Visuals
Variants
8
Version
1.0.2 · seit 1.0.0
Datei
components/ui/mockup.tsx
Bringt mit
utilskitdevice-frames
npm-Pakete
Keine
Sprachen
Texte kommen über Props
Studio
Im Code anpassen

Props

Die Schnittstelle BrowserMockupProps aus components/ui/mockup.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von MockupBase an.

  • urloptional
    string

    Address in the address bar. Without it and without tabs the bar is minimal.

  • tabsoptional
    string[]

    Tab titles. The active one joins the toolbar.

  • activeTaboptional
    number
  • tabIconsoptional
    React.ReactNode[]

    Small marks before the tab titles, e.g. an <AppMark />

  • toneoptional
    'auto' | 'light' | 'dark'

    auto follows the page, light and dark fix the window and its content

  • aspectoptional
    string

    Height of the content area as a ratio, e.g. "16 / 10". Without it the content sets the height.

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle label.
  • 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, 542 Zeilen
components/ui/mockup.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { TabletDevice, type TabletDeviceProps } from '@/components/ui/device-frames';

/*
 * Mockups: windows and devices drawn in CSS, with any content inside.
 * Every size inside a device is a share of its width (container query units),
 * so one width scales the whole device, also when max-width squeezes it.
 * Content comes as children (a live component) or as an image.
 * With `viewport` the content renders at that CSS width and is scaled to fit,
 * like a screenshot of a real screen.
 *
 * The devices are our own shapes: no real product is traced.
 */

const cq = (n: number) => `${round(n, 3)}cqw`;

const SHELL = cn(
  '[--mk-ink:var(--bezel)]',
  '[--mk-metal:color-mix(in_oklab,var(--foreground)_38%,var(--background))] [--mk-metal-soft:color-mix(in_oklab,var(--foreground)_16%,var(--background))]',
  'dark:[--mk-metal:color-mix(in_oklab,var(--foreground)_40%,var(--background))] dark:[--mk-metal-soft:color-mix(in_oklab,var(--foreground)_19%,var(--background))]',
);
const METAL = (deg: number) => `linear-gradient(${deg}deg, var(--mk-metal), var(--mk-metal-soft) 8%, var(--mk-metal) 50%, var(--mk-metal-soft) 92%, var(--mk-metal))`;

/** Light tokens for a light window on a dark page (tone="light") */
const LIGHT: React.CSSProperties = {
  colorScheme: 'light',
  ['--background' as string]: 'oklch(1 0 none)',
  ['--foreground' as string]: 'oklch(0.17 0.005 286)',
  ['--surface' as string]: 'oklch(0.985 0.0015 286)',
  ['--surface-2' as string]: 'oklch(0.966 0.003 286)',
  ['--card' as string]: 'oklch(1 0 none)',
  ['--card-foreground' as string]: 'oklch(0.17 0.005 286)',
  ['--muted' as string]: 'oklch(0.966 0.003 286)',
  ['--muted-foreground' as string]: 'oklch(0.5 0.012 286)',
  ['--subtle-foreground' as string]: 'oklch(0.62 0.01 286)',
  ['--border' as string]: 'oklch(0.915 0.004 286)',
  ['--border-strong' as string]: 'oklch(0.86 0.006 286)',
  ['--accent' as string]: 'oklch(0.955 0.004 286)',
  ['--highlight' as string]: 'inset 0 1px 0 rgb(255 255 255 / 0.7)',
  ['--shadow-sm' as string]: '0 1px 2px hsl(240 6% 10% / 0.05)',
  ['--shadow-md' as string]: '0 1px 2px hsl(240 6% 10% / 0.04), 0 6px 20px -6px hsl(240 6% 10% / 0.1)',
  ['--shadow-lg' as string]: '0 2px 4px hsl(240 6% 10% / 0.04), 0 24px 48px -16px hsl(240 6% 10% / 0.18)',
  color: 'oklch(0.17 0.005 286)',
};

/* ------------------------------------------------------------------ */
/* Screen: children, image or a scaled virtual viewport                */
/* ------------------------------------------------------------------ */

interface ScreenProps {
  viewport?: number;
  guess: number;
  image?: string;
  imageAlt?: string;
  style?: React.CSSProperties;
  children?: React.ReactNode;
}

export function MockupScreen({ viewport, guess, image, imageAlt = '', style, children }: ScreenProps) {
  const ref = useRef<HTMLDivElement>(null);
  // Until the browser measures, the screen scales itself in CSS: the ratio of its own width
  // (100cqw) to the viewport, so a server render or a page without JavaScript fits exactly
  const [scale, setScale] = useState<number | null>(null);
  void guess;
  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={cn('absolute inset-0 overflow-hidden', viewport && !image && '[container-type:inline-size]')} style={style}>
      {image ? (
        <img src={image} alt={imageAlt} className="size-full object-cover object-top" loading="lazy" />
      ) : viewport ? (
        <div
          className="absolute left-0 top-0 origin-top-left [container-type:inline-size]"
          style={
            scale
              ? { width: viewport, height: `${round(100 / scale, 3)}%`, transform: `scale(${scale})` }
              : ({ '--k': `tan(atan2(100cqw, ${viewport}px))`, width: viewport, height: 'calc(100% / var(--k))', transform: 'scale(var(--k))' } as React.CSSProperties)
          }
        >
          {children}
        </div>
      ) : (
        children
      )}
    </div>
  );
}

interface MockupBase {
  /** Width in px or any CSS width. Never grows past its container. */
  width?: number | string;
  /** Render the content at this CSS width and scale it to fit */
  viewport?: number;
  /** An image instead of children, shown from the top */
  image?: string;
  imageAlt?: string;
  /** Accessible name. Without it the mockup is a plain wrapper. */
  label?: string;
  className?: string;
  children?: React.ReactNode;
}

const rootProps = (label: string | undefined, width: number | string, className?: string, style?: React.CSSProperties) => ({
  role: label ? 'group' : undefined,
  'aria-label': label,
  className: cn('relative max-w-full shrink-0 [container-type:inline-size]', SHELL, className),
  style: { width, ...style },
});
const guessOf = (w: number | string, fallback: number) => (typeof w === 'number' ? w : fallback);

/** Signal, wifi and battery, drawn */
function StatusGlyphs({ size }: { size: string }) {
  return (
    <span className="flex items-center" style={{ gap: `calc(${size} * 0.34)` }} aria-hidden>
      <svg viewBox="0 0 17 11" style={{ height: `calc(${size} * 0.7)` }} fill="currentColor">
        <rect x="0" y="7" width="3" height="4" rx="1" />
        <rect x="4.6" y="4.8" width="3" height="6.2" rx="1" />
        <rect x="9.2" y="2.4" width="3" height="8.6" rx="1" />
        <rect x="13.8" y="0" width="3" height="11" rx="1" fillOpacity="0.35" />
      </svg>
      <svg viewBox="0 0 15 11" style={{ height: `calc(${size} * 0.7)` }} fill="currentColor">
        <path d="M7.5 10.6 5.6 8.4a2.8 2.8 0 0 1 3.8 0Zm-3.1-3.5L3.1 5.6a6.6 6.6 0 0 1 8.8 0l-1.3 1.5a4.6 4.6 0 0 0-6.2 0ZM1.8 4.2.5 2.7a10.4 10.4 0 0 1 14 0l-1.3 1.5a8.4 8.4 0 0 0-11.4 0Z" />
      </svg>
      <svg viewBox="0 0 25 12" style={{ height: `calc(${size} * 0.76)` }}>
        <rect x="0.6" y="0.6" width="21" height="10.8" rx="3.2" fill="none" stroke="currentColor" strokeOpacity="0.4" strokeWidth="1.2" />
        <rect x="2.3" y="2.3" width="14" height="7.4" rx="1.8" fill="currentColor" />
        <rect x="22.8" y="4" width="1.8" height="4" rx="0.9" fill="currentColor" fillOpacity="0.45" />
      </svg>
    </span>
  );
}

/* ------------------------------------------------------------------ */
/* Browser window                                                      */
/* ------------------------------------------------------------------ */

const BROWSER_TEXTS = {
  en: { back: 'Back', forward: 'Forward', reload: 'Reload', newTab: 'New tab' },
  de: { back: 'Zurück', forward: 'Vor', reload: 'Neu laden', newTab: 'Neuer Tab' },
};

export interface BrowserMockupProps extends MockupBase {
  /** Address in the address bar. Without it and without tabs the bar is minimal. */
  url?: string;
  /** Tab titles. The active one joins the toolbar. */
  tabs?: string[];
  activeTab?: number;
  /** Small marks before the tab titles, e.g. an <AppMark /> */
  tabIcons?: React.ReactNode[];
  /** auto follows the page, light and dark fix the window and its content */
  tone?: 'auto' | 'light' | 'dark';
  /** Height of the content area as a ratio, e.g. "16 / 10". Without it the content sets the height. */
  aspect?: string;
  labels?: Partial<typeof BROWSER_TEXTS.en>;
}

/**
 * A browser window with dots, optional tabs and an address bar.
 *   <BrowserMockup url="northwind.app" aspect="16 / 10"><App /></BrowserMockup>
 *   <BrowserMockup tabs={['Overview', 'Reports']} url="…" tone="dark" viewport={1280}>…</BrowserMockup>
 */
export function BrowserMockup({ url, tabs, activeTab = 0, tabIcons, tone = 'auto', aspect, width = '100%', viewport, image, imageAlt, label, labels, className, children }: BrowserMockupProps) {
  const t = useTexts(BROWSER_TEXTS, labels);
  const scaled = Boolean(viewport || image);
  const dots = (
    <span className="flex shrink-0 gap-[5px] sm:gap-[7px]" aria-hidden>
      {[0, 1, 2].map((i) => (
        <span key={i} className="size-2 rounded-full sm:size-[11px] bg-[color-mix(in_oklab,var(--foreground)_16%,var(--surface))] shadow-[inset_0_0_0_0.5px_color-mix(in_oklab,var(--foreground)_14%,transparent)]" />
      ))}
    </span>
  );
  const glyph = (d: string, name: string) => (
    <span title={name} className="grid size-6 place-items-center rounded-md text-subtle-foreground">
      <svg viewBox="0 0 16 16" className="size-[14px]" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
        <path d={d} />
      </svg>
    </span>
  );
  const address = (
    <div className="flex h-6 min-w-0 flex-1 items-center gap-1.5 rounded-md bg-[color-mix(in_oklab,var(--foreground)_5%,var(--background))] px-2.5 text-[10.5px] sm:h-7 sm:gap-2 sm:rounded-lg sm:px-3 sm:text-[11.5px] text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]">
      <svg viewBox="0 0 12 12" className="size-[11px] shrink-0" fill="currentColor" aria-hidden>
        <path d="M3.5 5V3.8a2.5 2.5 0 0 1 5 0V5h.3c.7 0 1.2.5 1.2 1.2v3.6c0 .7-.5 1.2-1.2 1.2H3.2C2.5 11 2 10.5 2 9.8V6.2C2 5.5 2.5 5 3.2 5Zm1.2 0h2.6V3.8a1.3 1.3 0 0 0-2.6 0Z" />
      </svg>
      <span className="truncate">{url}</span>
    </div>
  );
  return (
    <div
      role={label ? 'group' : undefined}
      aria-label={label}
      className={cn('relative max-w-full overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-[var(--highlight),var(--shadow-lg)]', tone === 'dark' && 'dark', className)}
      style={{ width, ...(tone === 'light' ? LIGHT : null) }}
    >
      {tabs?.length ? (
        <div className="bg-surface-2">
          <div className="flex h-10 items-end gap-3 px-3.5 pt-2" aria-hidden>
            <span className="mb-[9px]">{dots}</span>
            <div className="flex min-w-0 flex-1 items-end">
              {tabs.map((tab, i) => {
                const on = i === activeTab;
                return (
                  <span
                    key={tab}
                    className={cn(
                      'relative flex h-8 min-w-0 max-w-[200px] flex-1 items-center gap-2 px-3 text-[11.5px]',
                      on ? 'z-10 rounded-t-[10px] bg-surface font-medium text-foreground shadow-[0_0_0_1px_var(--border)] [clip-path:inset(-2px_-2px_0_-2px)]' : 'text-muted-foreground',
                      !on && i !== activeTab - 1 && i < tabs.length - 1 && 'after:absolute after:right-0 after:top-2 after:h-4 after:w-px after:bg-border',
                      i > 3 && 'max-sm:hidden',
                    )}
                  >
                    {tabIcons?.[i] ?? <span className={cn('size-3 shrink-0 rounded-[4px]', on ? 'bg-primary' : 'bg-border-strong')} />}
                    <span className="truncate">{tab}</span>
                  </span>
                );
              })}
              <span className="mb-1.5 ml-1 grid size-6 shrink-0 place-items-center rounded-md text-subtle-foreground" title={t.newTab}>
                <svg viewBox="0 0 12 12" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
                  <path d="M6 2v8M2 6h8" />
                </svg>
              </span>
            </div>
          </div>
          <div className="relative flex h-11 items-center gap-1.5 border-b border-border bg-surface px-3" aria-hidden>
            {glyph('M10 3 5 8l5 5', t.back)}
            <span className="max-sm:hidden">{glyph('m6 3 5 5-5 5', t.forward)}</span>
            <span className="max-sm:hidden">{glyph('M13 8a5 5 0 1 1-1.6-3.7M13 2.5V5h-2.5', t.reload)}</span>
            {address}
            <span className="w-2" />
          </div>
        </div>
      ) : (
        <div className="relative flex h-8 items-center gap-3 border-b border-border bg-surface px-3 sm:h-10 sm:px-3.5" aria-hidden>
          {dots}
          {url ? <div className="mx-auto flex w-full max-w-[min(60%,26rem)] min-w-0 justify-center">{address}</div> : <span className="flex-1" />}
          <span className="w-[47px] shrink-0 max-sm:hidden" />
        </div>
      )}
      <div className={cn('relative bg-background', scaled && 'overflow-hidden')} style={aspect ? { aspectRatio: aspect } : scaled ? { aspectRatio: '16 / 10' } : undefined}>
        {scaled || aspect ? (
          <MockupScreen viewport={viewport} guess={guessOf(width, 960)} image={image} imageAlt={imageAlt}>
            {children}
          </MockupScreen>
        ) : (
          children
        )}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Phone                                                               */
/* ------------------------------------------------------------------ */

export interface PhoneMockupProps extends MockupBase {
  time?: string;
  /** The camera: a capsule (default) or a single round lens */
  camera?: 'capsule' | 'dot';
  statusBar?: boolean;
  /** Color of the frame: metal follows the theme, ink is dark in both */
  finish?: 'metal' | 'ink';
}

/**
 * A modern phone of our own shape: soft squircle corners, a slim even bezel,
 * a camera capsule with one lens, a long key on the right and a short one on the left.
 *   <PhoneMockup width={300} viewport={390}><Screen /></PhoneMockup>
 */
export function PhoneMockup({ width = 300, viewport, image, imageAlt, time = '8:30', camera = 'capsule', statusBar = true, finish = 'metal', label, className, children }: PhoneMockupProps) {
  const share = (100 - 2 * (1.1 + 3.3)) / 100;
  const bar = 13.5;
  return (
    <div {...rootProps(label, width, className)}>
      <div className="relative mx-auto" style={{ width: cq(97), aspectRatio: '71 / 147' }}>
        {/* keys */}
        <span aria-hidden className="absolute" style={{ right: cq(-1), top: '24%', height: '13%', width: cq(1.4), borderRadius: cq(0.7), background: METAL(180) }} />
        <span aria-hidden className="absolute" style={{ left: cq(-1), top: '20%', height: '6%', width: cq(1.4), borderRadius: cq(0.7), background: METAL(180) }} />
        <span aria-hidden className="absolute" style={{ left: cq(-1), top: '29%', height: '11%', width: cq(1.4), borderRadius: cq(0.7), background: METAL(180) }} />
        {/* frame */}
        <div
          className="absolute inset-0"
          style={{
            borderRadius: cq(15),
            padding: cq(1.1),
            background: finish === 'ink' ? 'var(--mk-ink)' : METAL(90),
            boxShadow: `var(--shadow-lg), inset 0 0 0 ${cq(0.25)} color-mix(in oklab, var(--foreground) 22%, transparent)`,
          }}
        >
          <div className="relative size-full bg-[var(--mk-ink)]" style={{ borderRadius: cq(14), padding: cq(3.3) }}>
            <div data-clip-ok className="relative size-full overflow-hidden bg-background text-foreground" style={{ borderRadius: cq(11) }}>
              <MockupScreen viewport={viewport} guess={guessOf(width, 300) * share} image={image} imageAlt={imageAlt} style={statusBar ? { top: cq(bar) } : undefined}>
                {children}
              </MockupScreen>
              {statusBar && (
                <div className="absolute inset-x-0 top-0 flex items-center justify-between bg-background font-semibold" style={{ height: cq(bar), paddingInline: cq(8), fontSize: cq(4.3) }}>
                  <span className="tabular-nums">{time}</span>
                  <StatusGlyphs size={cq(4.3)} />
                </div>
              )}
              {/* camera */}
              <span
                aria-hidden
                className="absolute left-1/2 flex -translate-x-1/2 items-center justify-end bg-[var(--mk-ink)]"
                style={camera === 'capsule' ? { top: cq(2.8), width: cq(24), height: cq(7.4), borderRadius: 999, paddingRight: cq(2.2) } : { top: cq(3.4), width: cq(6.2), height: cq(6.2), borderRadius: 999, justifyContent: 'center' }}
              >
                <span className="rounded-full bg-[radial-gradient(circle_at_35%_35%,color-mix(in_oklab,var(--primary)_55%,var(--mk-ink)),var(--mk-ink)_70%)]" style={{ width: cq(2.8), height: cq(2.8) }} />
              </span>
              <span aria-hidden className="absolute left-1/2 -translate-x-1/2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_72%,transparent)]" style={{ bottom: cq(2), width: cq(34), height: cq(1.3) }} />
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Laptop                                                              */
/* ------------------------------------------------------------------ */

/**
 * A laptop of our own shape: an even bezel without a notch, a hinge band and
 * a slim base with a soft front lip and two feet. Width is the base width.
 *   <LaptopMockup width={880} viewport={1280}>…</LaptopMockup>
 */
export function LaptopMockup({ width = 880, viewport, image, imageAlt, label, className, children }: MockupBase) {
  const share = (84 - 2 * 1.6) / 100;
  return (
    <div {...rootProps(label, width, className)}>
      <div
        className="relative mx-auto bg-[var(--mk-ink)]"
        style={{
          width: cq(84),
          padding: cq(1.6),
          borderRadius: `${cq(2.2)} ${cq(2.2)} ${cq(0.4)} ${cq(0.4)}`,
          boxShadow: `0 0 0 ${cq(0.2)} var(--mk-metal), var(--shadow-lg)`,
        }}
      >
        <span aria-hidden className="absolute left-1/2 -translate-x-1/2 rounded-full bg-[color-mix(in_oklab,var(--mk-ink)_60%,var(--foreground))]" style={{ top: cq(0.55), width: cq(0.5), height: cq(0.5) }} />
        <div data-clip-ok className="relative aspect-[16/10] overflow-hidden bg-background text-foreground" style={{ borderRadius: cq(0.6) }}>
          <MockupScreen viewport={viewport} guess={guessOf(width, 880) * share} image={image} imageAlt={imageAlt}>
            {children}
          </MockupScreen>
          {/* a faint sheen across the glass */}
          <span aria-hidden className="pointer-events-none absolute inset-0 bg-[linear-gradient(115deg,color-mix(in_oklab,#fff_7%,transparent)_0%,transparent_38%)]" />
        </div>
      </div>
      <div aria-hidden className="mx-auto" style={{ width: cq(80), height: cq(0.8), background: 'linear-gradient(180deg, color-mix(in oklab, var(--mk-ink) 80%, var(--mk-metal)), var(--mk-ink))', borderRadius: `0 0 ${cq(0.4)} ${cq(0.4)}` }} />
      <div
        aria-hidden
        className="relative"
        style={{
          height: cq(1.9),
          borderRadius: `${cq(0.2)} ${cq(0.2)} ${cq(3)} ${cq(3)} / ${cq(0.2)} ${cq(0.2)} ${cq(1.6)} ${cq(1.6)}`,
          background: 'linear-gradient(180deg, color-mix(in oklab, var(--mk-metal) 60%, var(--background)), var(--mk-metal) 35%, var(--mk-metal-soft))',
          boxShadow: `inset 0 ${cq(0.1)} 0 color-mix(in oklab, var(--background) 50%, transparent), 0 ${cq(1.4)} ${cq(2.6)} ${cq(-1)} color-mix(in oklab, var(--foreground) 30%, transparent)`,
        }}
      >
        <span className="absolute inset-x-[3%] bottom-[18%] h-px bg-[color-mix(in_oklab,var(--foreground)_14%,transparent)]" />
      </div>
      <div aria-hidden className="mx-auto flex justify-between" style={{ width: cq(88) }}>
        {[0, 1].map((i) => (
          <span key={i} className="bg-[color-mix(in_oklab,var(--mk-ink)_80%,var(--mk-metal))]" style={{ width: cq(6), height: cq(0.4), borderRadius: `0 0 ${cq(0.4)} ${cq(0.4)}` }} />
        ))}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Monitor                                                             */
/* ------------------------------------------------------------------ */

export interface MonitorMockupProps extends MockupBase {
  /** foot: a neck and a flat foot, none: the display alone (wall or arm) */
  stand?: 'foot' | 'none';
}

/**
 * A desktop display: thin even bezel, a slightly deeper chin with a status
 * light, a slim neck and a flat foot. Width is the display width.
 *   <MonitorMockup width={900} viewport={1440}>…</MonitorMockup>
 */
export function MonitorMockup({ width = 900, viewport, image, imageAlt, stand = 'foot', label, className, children }: MonitorMockupProps) {
  const share = (100 - 2 * 1.3) / 100;
  return (
    <div {...rootProps(label, width, className)}>
      <div
        className="relative bg-[var(--mk-ink)]"
        style={{ padding: `${cq(1.3)} ${cq(1.3)} ${cq(2.6)}`, borderRadius: cq(1.6), boxShadow: `0 0 0 ${cq(0.18)} var(--mk-metal), var(--shadow-lg)` }}
      >
        <div data-clip-ok className="relative aspect-[16/9] overflow-hidden bg-background text-foreground" style={{ borderRadius: cq(0.5) }}>
          <MockupScreen viewport={viewport} guess={guessOf(width, 900) * share} image={image} imageAlt={imageAlt}>
            {children}
          </MockupScreen>
          <span aria-hidden className="pointer-events-none absolute inset-0 bg-[linear-gradient(120deg,color-mix(in_oklab,#fff_6%,transparent)_0%,transparent_34%)]" />
        </div>
        <span aria-hidden className="absolute left-1/2 -translate-x-1/2 rounded-full bg-[color-mix(in_oklab,var(--primary)_70%,var(--mk-ink))]" style={{ bottom: cq(1.05), width: cq(0.5), height: cq(0.5) }} />
      </div>
      {stand === 'foot' && (
        <div aria-hidden className="flex flex-col items-center">
          <span style={{ width: cq(9), height: cq(9), background: `linear-gradient(90deg, var(--mk-metal-soft), var(--mk-metal) 50%, var(--mk-metal-soft))`, clipPath: 'polygon(14% 0, 86% 0, 100% 100%, 0 100%)' }} />
          <span
            style={{
              width: cq(26),
              height: cq(1.3),
              borderRadius: `${cq(0.4)} ${cq(0.4)} ${cq(1)} ${cq(1)}`,
              background: METAL(90),
              boxShadow: `0 ${cq(1)} ${cq(2)} ${cq(-0.6)} color-mix(in oklab, var(--foreground) 28%, transparent)`,
            }}
          />
        </div>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Watch                                                               */
/* ------------------------------------------------------------------ */

/**
 * A round smartwatch of our own shape: a ring bezel with minute marks, two
 * pushers and soft band stubs. Content sits in the round screen.
 *   <WatchMockup width={200}><Face /></WatchMockup>
 */
export function WatchMockup({ width = 200, viewport, image, imageAlt, label, className, children }: MockupBase) {
  const share = 0.7;
  const band = (top: boolean) => (
    <div
      aria-hidden
      className="mx-auto"
      style={{
        width: cq(50),
        height: cq(30),
        borderRadius: top ? `${cq(8)} ${cq(8)} ${cq(2)} ${cq(2)}` : `${cq(2)} ${cq(2)} ${cq(8)} ${cq(8)}`,
        background: 'linear-gradient(90deg, color-mix(in oklab, var(--mk-ink) 86%, var(--mk-metal)), color-mix(in oklab, var(--mk-ink) 70%, var(--mk-metal)) 50%, color-mix(in oklab, var(--mk-ink) 86%, var(--mk-metal)))',
        maskImage: `linear-gradient(${top ? '0deg' : '180deg'}, #000 35%, transparent 98%)`,
        WebkitMaskImage: `linear-gradient(${top ? '0deg' : '180deg'}, #000 35%, transparent 98%)`,
      }}
    />
  );
  return (
    <div {...rootProps(label, width, className)}>
      {band(true)}
      <div className="relative mx-auto" style={{ width: cq(86), aspectRatio: '1' }}>
        <span aria-hidden className="absolute" style={{ right: cq(-3.6), top: '28%', width: cq(5), height: '11%', borderRadius: cq(1.4), background: METAL(180) }} />
        <span aria-hidden className="absolute" style={{ right: cq(-3.6), top: '61%', width: cq(5), height: '11%', borderRadius: cq(1.4), background: METAL(180) }} />
        <div className="absolute inset-0 rounded-full shadow-[var(--shadow-lg)]" style={{ padding: cq(1.6), background: METAL(135) }}>
          <div className="relative size-full rounded-full bg-[var(--mk-ink)]" style={{ padding: cq(7) }}>
            <svg viewBox="0 0 100 100" className="absolute inset-0 size-full" aria-hidden>
              {Array.from({ length: 60 }, (_, i) => {
                const a = (i / 60) * Math.PI * 2;
                const long = i % 5 === 0;
                const r1 = long ? 43.5 : 45;
                return (
                  <line
                    key={i}
                    x1={round(50 + Math.sin(a) * r1)}
                    y1={round(50 - Math.cos(a) * r1)}
                    x2={round(50 + Math.sin(a) * 47.2)}
                    y2={round(50 - Math.cos(a) * 47.2)}
                    stroke="color-mix(in oklab, var(--foreground) 55%, transparent)"
                    strokeOpacity={long ? 0.75 : 0.32}
                    strokeWidth={long ? 0.9 : 0.5}
                  />
                );
              })}
            </svg>
            <div data-clip-ok className="relative size-full overflow-hidden rounded-full bg-background text-foreground">
              <MockupScreen viewport={viewport} guess={guessOf(width, 200) * share} image={image} imageAlt={imageAlt}>
                {children}
              </MockupScreen>
            </div>
          </div>
        </div>
      </div>
      {band(false)}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* One entry point                                                     */
/* ------------------------------------------------------------------ */

export type MockupDevice = 'browser' | 'phone' | 'tablet' | 'laptop' | 'monitor' | 'watch';

export type MockupProps =
  | ({ device: 'browser' } & BrowserMockupProps)
  | ({ device: 'phone' } & PhoneMockupProps)
  | ({ device: 'tablet' } & TabletDeviceProps & { image?: string; imageAlt?: string })
  | ({ device: 'laptop' } & MockupBase)
  | ({ device: 'monitor' } & MonitorMockupProps)
  | ({ device: 'watch' } & MockupBase);

/**
 * Any mockup by name, the same props as the single components.
 *   <Mockup device="laptop" viewport={1280}>…</Mockup>
 */
export function Mockup(props: MockupProps) {
  switch (props.device) {
    case 'browser': {
      const { device: _d, ...rest } = props;
      return <BrowserMockup {...rest} />;
    }
    case 'phone': {
      const { device: _d, ...rest } = props;
      return <PhoneMockup {...rest} />;
    }
    case 'tablet': {
      const { device: _d, image, imageAlt, children, ...rest } = props;
      return <TabletDevice {...rest}>{image ? <img src={image} alt={imageAlt ?? ''} className="size-full object-cover object-top" /> : children}</TabletDevice>;
    }
    case 'laptop': {
      const { device: _d, ...rest } = props;
      return <LaptopMockup {...rest} />;
    }
    case 'monitor': {
      const { device: _d, ...rest } = props;
      return <MonitorMockup {...rest} />;
    }
    case 'watch': {
      const { device: _d, ...rest } = props;
      return <WatchMockup {...rest} />;
    }
  }
}

/** The tablet of the kit, under the mockup name */
export { TabletDevice as TabletMockup };

Häufige Fragen

Wie installiere ich Mockups?

Mit aiw add @ai-website/mockup. Der Befehl legt die Datei components/ui/mockup.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind device-frames. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Mockups?

8 Varianten, Browserfenster, Browser mit Tabs, Helles und dunkles Fenster, Telefon, Tablet, Laptop, Monitor und Runde Uhr.

Kann ein KI-Assistent Mockups einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Mockups mit aiw_components und installieren sie mit aiw_add @ai-website/mockup.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert