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
Variants
Details
What Mockups brings along, read from the registry and the source.
- Category
- Mockups
- Area
- Visuals
- 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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The BrowserMockupProps interface from components/ui/mockup.tsx, with the comments from the code. Also takes every prop of MockupBase.
urloptionalstringAddress in the address bar. Without it and without tabs the bar is minimal.
tabsoptionalstring[]Tab titles. The active one joins the toolbar.
activeTaboptionalnumbertabIconsoptionalReact.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
aspectoptionalstringHeight 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
'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.