Skip to content
UI Kit
Accent color

Mockups

Browser windows and devices of our own shape, drawn in CSS. Put a live component or an image inside, one width scales everything.

One of 2 components in Mockups

8 variants

Variants

Browser window

Three dots and a centered address bar. The page inside renders at 1200 px and scales to fit.

Browser with tabs

Tabs above the toolbar, the active one joins it. Back, forward and reload sit before the address.

Light and dark window

tone fixes a window light or dark, whatever the page is. Good for showing both modes of a product.

Phone

Our own phone shape with soft corners, a camera capsule or a single lens, and a metal or ink frame.

Tablet

Even bezels and a camera on the long edge. Here an app at 1024 px, scaled into landscape.

Laptop

An even bezel without a notch, a hinge band and a slim base with feet. The width is the base.

Desktop display

A 16:9 display with a thin bezel, a status light in the chin and a stand with a flat foot.

Round watch

A round case with minute marks on the bezel and two pushers. The ring on the screen fills as it appears.

Details

What Mockups brings along, read from the registry and the source.

Category
Mockups
Variants
8
Version
1.0.2 · since 1.0.0
File
components/ui/mockup.tsx
Brings along
utilskitdevice-frames
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The BrowserMockupProps interface from components/ui/mockup.tsx, with the comments from the code. Also takes every prop of MockupBase.

  • 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.

Accessibility

Read from the source of the component.

  • Sets aria-label, so screen readers announce purpose and state.
  • Uses the role label.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 542 lines
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 };

Frequent questions

How do I install Mockups?

With aiw add @ai-website/mockup. The command puts components/ui/mockup.tsx into your project, the code is yours from then on. It brings along device-frames. It needs no further npm packages.

Which variants does Mockups have?

8 variants, Browser window, Browser with tabs, Light and dark window, Phone, Tablet, Laptop, Desktop display and Round watch.

Can an AI assistant build with Mockups?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Mockups with aiw_components and install it with aiw_add @ai-website/mockup.

Often searched

Jump to

Copied to the clipboard