Mockup scenes
Devices that overlap, windows in quiet perspective, pages that scroll inside their device and floating card collages.
One of 2 components in Mockups
Variants
Details
What Mockup scenes brings along, read from the registry and the source.
- Category
- Mockups
- Area
- Visuals
- Variants
- 8
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/mockup-scene.tsx
- Brings along
- utilskitmockupdevice-framesmotionmotion-scene
- 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 DeviceEnsembleProps interface from components/ui/mockup-scene.tsx, with the comments from the code.
desktoprequiredReact.ReactNodeContent of the large screen
phoneoptionalReact.ReactNodeContent of the phone
tabletoptionalReact.ReactNodeContent of the tablet, only in layout="trio"
desktopDeviceoptional'browser' | 'laptop' | 'monitor'The large device
layoutoptional'right' | 'left' | 'trio'right: phone in front on the right, left: on the left, trio: tablet, desktop and phone
desktopViewportoptionalnumberRender widths of the screens, like real devices
phoneViewportoptionalnumbertabletViewportoptionalnumberurloptionalstringAddress in the browser bar
parallaxoptionalbooleanThe front devices drift a little against the page while it scrolls. Default true.
labeloptionalstring
Accessibility
Read from the source of the component.
- Sets aria-label, so screen readers announce purpose and state.
- Uses the role label.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 8 places in the code switch motion off then.
- 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, 387 lines
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round } from '@/lib/kit';
import { useScrollProgress } from '@/components/ui/motion';
import { BrowserMockup, LaptopMockup, MonitorMockup, PhoneMockup } from '@/components/ui/mockup';
import { TabletDevice } from '@/components/ui/device-frames';
import { PlayToggle, SCENE_CSS, keyframes, useSceneId, useScenePlay, type AutoPlay } from '@/components/ui/motion-scene';
/*
* Scenes built from mockups: devices that overlap, a window in quiet
* perspective, a page that scrolls inside its device and a collage of
* floating cards. All of them render their finished picture on the server
* and move only with transform and opacity.
*/
/* ------------------------------------------------------------------ */
/* Device ensemble */
/* ------------------------------------------------------------------ */
export interface DeviceEnsembleProps {
/** Content of the large screen */
desktop: React.ReactNode;
/** Content of the phone */
phone?: React.ReactNode;
/** Content of the tablet, only in layout="trio" */
tablet?: React.ReactNode;
/** The large device */
desktopDevice?: 'browser' | 'laptop' | 'monitor';
/** right: phone in front on the right, left: on the left, trio: tablet, desktop and phone */
layout?: 'right' | 'left' | 'trio';
/** Render widths of the screens, like real devices */
desktopViewport?: number;
phoneViewport?: number;
tabletViewport?: number;
/** Address in the browser bar */
url?: string;
/** The front devices drift a little against the page while it scrolls. Default true. */
parallax?: boolean;
label?: string;
className?: string;
}
/**
* Desktop and phone (and tablet) as one picture. Widths are shares of the
* scene, so it scales down as a whole.
* <DeviceEnsemble desktop={<Site />} phone={<SiteMobile />} url="atlas.com" />
*/
export function DeviceEnsemble({ desktop, phone, tablet, desktopDevice = 'browser', layout = 'right', desktopViewport = 1200, phoneViewport = 320, tabletViewport = 820, url, parallax = true, label, className }: DeviceEnsembleProps) {
const ref = useScrollProgress<HTMLDivElement>();
const trio = layout === 'trio';
const left = layout === 'left';
const drift = (px: number) => (parallax ? { transform: `translate3d(0, calc((0.5 - var(--kit-progress, 0.5)) * ${px}px), 0)` } : undefined);
const big =
desktopDevice === 'laptop' ? (
<LaptopMockup width="100%" viewport={desktopViewport}>{desktop}</LaptopMockup>
) : desktopDevice === 'monitor' ? (
<MonitorMockup width="100%" viewport={desktopViewport}>{desktop}</MonitorMockup>
) : (
<BrowserMockup width="100%" url={url} viewport={desktopViewport} aspect="16 / 10">{desktop}</BrowserMockup>
);
return (
<div
ref={ref}
role={label ? 'img' : undefined}
aria-label={label}
className={cn('relative mx-auto w-full [container-type:inline-size]', trio ? 'aspect-[16/9.6]' : 'aspect-[16/10.6]', className)}
>
<div className="absolute top-0" style={trio ? { left: '15%', width: '70%' } : left ? { right: 0, width: '84%' } : { left: 0, width: '84%' }}>
{big}
</div>
{trio && tablet && (
<div className="absolute bottom-0 left-0 will-change-transform motion-reduce:!transform-none" style={{ width: '34%', ...drift(-36) }}>
<TabletDevice width="100%" orientation="portrait" viewport={tabletViewport}>{tablet}</TabletDevice>
</div>
)}
{phone && (
<div
className="absolute bottom-0 will-change-transform motion-reduce:!transform-none"
style={{ width: trio ? '19%' : '23%', ...(left ? { left: 0 } : { right: trio ? '2%' : 0 }), ...drift(trio ? 52 : 64) }}
>
<PhoneMockup width="100%" viewport={phoneViewport}>{phone}</PhoneMockup>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Perspective */
/* ------------------------------------------------------------------ */
export interface PerspectiveMockupProps {
/** Which way the picture turns. All of them stay quiet, never more than 18 degrees. */
tilt?: 'left' | 'right' | 'back' | 'isometric';
/** 0 to 1, how strong the turn is. Default 1. */
strength?: number;
/** The picture follows a mouse pointer a few degrees. Default true. */
interactive?: boolean;
/** The picture turns towards the viewer while it scrolls into the middle. Default true. */
flatten?: boolean;
/** A soft shadow on the floor under the picture. Default true. */
shadow?: boolean;
label?: string;
className?: string;
children: React.ReactNode;
}
const TILTS = {
left: { x: 6, y: 14, z: 0, s: 0.96 },
right: { x: 6, y: -14, z: 0, s: 0.96 },
back: { x: 18, y: 0, z: 0, s: 0.95 },
isometric: { x: 16, y: 0, z: -9, s: 0.92 },
};
/**
* Any mockup or image in quiet 3D perspective, with a light that follows the pointer.
* <PerspectiveMockup tilt="left"><BrowserMockup …/></PerspectiveMockup>
*/
export function PerspectiveMockup({ tilt = 'left', strength = 1, interactive = true, flatten = true, shadow = true, label, className, children }: PerspectiveMockupProps) {
const ref = useScrollProgress<HTMLDivElement>({ start: 0.05, end: 0.55 });
const card = useRef<HTMLDivElement>(null);
const a = TILTS[tilt];
const k = Math.min(1, Math.max(0, strength));
const keep = flatten ? '(1 - var(--kit-progress, 0) * 0.55)' : '1';
const move = (e: React.PointerEvent<HTMLDivElement>) => {
const el = card.current;
if (!el || !interactive || e.pointerType !== 'mouse' || prefersReducedMotion()) return;
const r = e.currentTarget.getBoundingClientRect();
el.style.setProperty('--pv-x', ((e.clientX - r.left) / r.width - 0.5).toFixed(3));
el.style.setProperty('--pv-y', ((e.clientY - r.top) / r.height - 0.5).toFixed(3));
};
const leave = () => {
card.current?.style.setProperty('--pv-x', '0');
card.current?.style.setProperty('--pv-y', '0');
};
return (
<div ref={ref} role={label ? 'group' : undefined} aria-label={label} className={cn('relative [perspective:1800px]', className)} onPointerMove={move} onPointerLeave={leave}>
{shadow && (
<div
aria-hidden
className="absolute inset-x-[8%] bottom-[-4%] h-[14%] rounded-[50%] bg-[color-mix(in_oklab,var(--foreground)_22%,transparent)] blur-2xl dark:bg-[color-mix(in_oklab,#000_70%,transparent)]"
/>
)}
<div
ref={card}
className="relative transition-transform duration-500 ease-[var(--ease-kit)] [transform-style:preserve-3d] motion-reduce:transition-none"
style={{
transform: `rotateX(calc(${round(a.x * k, 2)}deg * ${keep} - var(--pv-y, 0) * 6deg)) rotateY(calc(${round(a.y * k, 2)}deg * ${keep} + var(--pv-x, 0) * 8deg)) rotateZ(calc(${round(a.z * k, 2)}deg * ${keep})) scale(${round(1 - (1 - a.s) * k, 3)})`,
}}
>
{children}
{/* glass light that follows the pointer */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-70 mix-blend-soft-light"
style={{ background: 'radial-gradient(60% 50% at calc(50% + var(--pv-x, -0.25) * 100%) calc(50% + var(--pv-y, -0.3) * 100%), color-mix(in oklab, #fff 38%, transparent), transparent 70%)' }}
/>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Scrolling screen */
/* ------------------------------------------------------------------ */
export interface ScrollingScreenProps {
/** loop: scrolls on its own in calm steps, scroll: follows the page scroll, hover: scrolls while pointed at or focused */
mode?: 'loop' | 'scroll' | 'hover';
/** Seconds for one loop. Default 14. */
duration?: number;
autoPlay?: AutoPlay;
/** Pause and play button (loop only). Default true. */
controls?: boolean;
/** A thin scroll thumb on the right that moves along. Default true. */
thumb?: boolean;
/** Accessible name for the hover mode, which takes focus */
label?: string;
className?: string;
/** The tall page */
children: React.ReactNode;
}
/**
* A tall page that scrolls inside a mockup. Put it as the content of any
* mockup with a fixed screen (aspect, viewport or a device).
* <BrowserMockup aspect="16 / 10" viewport={1200}><ScrollingScreen><Site /></ScrollingScreen></BrowserMockup>
*/
export function ScrollingScreen({ mode = 'loop', duration = 14, autoPlay = 'in-view', controls = true, thumb = true, label, className, children }: ScrollingScreenProps) {
const sid = useSceneId('kvss');
const play = useScenePlay<HTMLDivElement>(autoPlay);
const progress = useScrollProgress<HTMLDivElement>({ start: 0.15, end: 0.85 });
const content = useRef<HTMLDivElement>(null);
const [dist, setDist] = useState<{ d: number; t: number; h: number } | null>(null);
useEffect(() => {
const frame = play.ref.current;
const inner = content.current;
if (!frame || !inner || typeof ResizeObserver === 'undefined') return;
const measure = () => {
const fh = frame.clientHeight;
const ch = inner.scrollHeight;
const d = Math.max(0, ch - fh);
const th = ch ? Math.max(24, (fh / ch) * fh) : fh;
setDist({ d: Math.round(d), t: Math.round(fh - th), h: Math.round(th) });
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(frame);
ro.observe(inner);
return () => ro.disconnect();
}, [play.ref]);
// A person scrolling: rest, glide, rest, glide, rest at the end, glide back up
const stops: [number, number][] = [[0, 0], [9, 0], [22, 0.3], [34, 0.3], [47, 0.62], [59, 0.62], [72, 1], [88, 1], [97, 0], [100, 0]];
const kf = (name: string, v: string) => keyframes(name, stops.map(([p, f]) => [p, `transform:translate3d(0,calc(var(${v}) * ${-f}),0)`]));
const kfThumb = (name: string) => keyframes(name, stops.map(([p, f]) => [p, `transform:translate3d(0,calc(var(--kv-t) * ${f}),0)`]));
const css = `${SCENE_CSS}
${kf(`${sid}-page`, '--kv-d')}${kfThumb(`${sid}-thumb`)}
.${sid}:not([data-kv="off"]) .${sid}-page { animation: ${sid}-page ${round(duration, 2)}s cubic-bezier(.45,.05,.25,1) infinite both; }
.${sid}:not([data-kv="off"]) .${sid}-thumb { animation: ${sid}-thumb ${round(duration, 2)}s cubic-bezier(.45,.05,.25,1) infinite both; }`;
const vars = dist ? ({ '--kv-d': `${dist.d}px`, '--kv-t': `${dist.t}px`, '--kv-h': `${dist.h}px` } as React.CSSProperties) : undefined;
if (mode === 'scroll') {
return (
<div ref={(el) => { (play.ref as React.MutableRefObject<HTMLDivElement | null>).current = el; (progress as React.MutableRefObject<HTMLDivElement | null>).current = el; }} data-clip-ok className={cn('absolute inset-0 overflow-hidden', className)} style={vars}>
<div ref={content} className="will-change-transform motion-reduce:!transform-none" style={{ transform: 'translate3d(0, calc(var(--kv-d, 0px) * -1 * var(--kit-progress, 0)), 0)' }}>
{children}
</div>
{thumb && dist && dist.d > 0 && <span aria-hidden className="absolute right-1 top-0 w-1 rounded-full bg-[color-mix(in_oklab,var(--foreground)_28%,transparent)] motion-reduce:hidden" style={{ height: 'var(--kv-h)', transform: 'translate3d(0, calc(var(--kv-t) * var(--kit-progress, 0)), 0)' }} />}
</div>
);
}
if (mode === 'hover') {
return (
<div
ref={play.ref}
tabIndex={0}
role={label ? 'img' : undefined}
aria-label={label}
data-clip-ok
className={cn('group/ss absolute inset-0 overflow-hidden outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--ring)]', className)}
style={{ ...vars, '--ss-time': `${round(Math.max(2, (dist?.d ?? 1200) / 260), 2)}s` } as React.CSSProperties}
>
<div ref={content} className="transition-transform duration-[var(--ss-time)] ease-[cubic-bezier(.45,.05,.25,1)] will-change-transform group-hover/ss:[transform:translate3d(0,calc(var(--kv-d,0px)*-1),0)] group-focus/ss:[transform:translate3d(0,calc(var(--kv-d,0px)*-1),0)] motion-reduce:transition-none">
{children}
</div>
{thumb && dist && dist.d > 0 && (
<span aria-hidden className="absolute right-1 top-0 w-1 rounded-full bg-[color-mix(in_oklab,var(--foreground)_28%,transparent)] opacity-0 transition-[transform,opacity] duration-[var(--ss-time)] ease-[cubic-bezier(.45,.05,.25,1)] group-hover/ss:opacity-100 group-hover/ss:[transform:translate3d(0,var(--kv-t),0)] group-focus/ss:opacity-100 group-focus/ss:[transform:translate3d(0,var(--kv-t),0)]" style={{ height: 'var(--kv-h)' }} />
)}
</div>
);
}
return (
<div ref={play.ref} data-kv={play.state} data-clip-ok className={cn(sid, 'absolute inset-0 overflow-hidden', className)} style={vars}>
<style dangerouslySetInnerHTML={{ __html: css }} />
<div key={play.cycle} ref={content} className={`${sid}-page will-change-transform`}>
{children}
</div>
{thumb && dist && dist.d > 0 && <span aria-hidden className={`${sid}-thumb absolute right-1 top-0 w-1 rounded-full bg-[color-mix(in_oklab,var(--foreground)_28%,transparent)] motion-reduce:hidden`} style={{ height: 'var(--kv-h)' }} />}
{controls && <PlayToggle state={play.state} reduced={play.reduced} onToggle={play.toggle} />}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Floating collage */
/* ------------------------------------------------------------------ */
export interface CollageItem {
node: React.ReactNode;
/** Position of the top left corner in percent of the scene */
x: number;
y: number;
/** Width in px at the design width */
w: number;
/** 0 is far, 1 is near. Near items move more with scroll and pointer. Default 0.5. */
depth?: number;
/** A small turn in degrees */
rotate?: number;
/** Drop on phones, so the picture stays calm. */
hideOnPhone?: boolean;
}
export interface FloatingCollageProps {
items: CollageItem[];
/** The width the collage is designed for. It scales down as one picture below it. Default 960. */
designWidth?: number;
/** Height at the design width. Default 560. */
designHeight?: number;
/** Gentle idle float. Default true. */
float?: boolean;
/** Layers shift with the mouse pointer. Default true. */
pointer?: boolean;
/** Layers drift with the page scroll. Default true. */
parallax?: boolean;
label?: string;
className?: string;
}
/**
* UI cards floating in layers, like a product shot. Items keep their own
* size at the design width and the whole collage scales as one picture.
* <FloatingCollage items={[{ node: <Card />, x: 10, y: 20, w: 280, depth: 0.8 }]} />
*/
export function FloatingCollage({ items, designWidth = 960, designHeight = 560, float = true, pointer = true, parallax = true, label, className }: FloatingCollageProps) {
const sid = useSceneId('kvfc');
const play = useScenePlay<HTMLDivElement>('in-view');
const progress = useScrollProgress<HTMLDivElement>();
const box = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const [phone, setPhone] = useState(false);
useEffect(() => {
const el = box.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(([e]) => {
const w = e.contentRect.width;
if (!w) return;
setScale(round(Math.min(1, w / designWidth), 4));
setPhone(w < 560);
});
ro.observe(el);
return () => ro.disconnect();
}, [designWidth]);
const move = (e: React.PointerEvent<HTMLDivElement>) => {
if (!pointer || e.pointerType !== 'mouse' || prefersReducedMotion()) return;
const r = e.currentTarget.getBoundingClientRect();
e.currentTarget.style.setProperty('--fc-x', ((e.clientX - r.left) / r.width - 0.5).toFixed(3));
e.currentTarget.style.setProperty('--fc-y', ((e.clientY - r.top) / r.height - 0.5).toFixed(3));
};
const leave = (e: React.PointerEvent<HTMLDivElement>) => {
e.currentTarget.style.setProperty('--fc-x', '0');
e.currentTarget.style.setProperty('--fc-y', '0');
};
const css = `${SCENE_CSS}
${keyframes(`${sid}-float`, [[0, 'transform:translate3d(0,0,0)'], [50, 'transform:translate3d(0,-7px,0)'], [100, 'transform:translate3d(0,0,0)']])}
.${sid}:not([data-kv="off"]) .${sid}-f { animation: ${sid}-float var(--fc-dur) ease-in-out var(--fc-delay) infinite; }`;
return (
<div
ref={(el) => {
(box as React.MutableRefObject<HTMLDivElement | null>).current = el;
(play.ref as React.MutableRefObject<HTMLDivElement | null>).current = el;
(progress as React.MutableRefObject<HTMLDivElement | null>).current = el;
}}
data-kv={float ? play.state : 'off'}
role={label ? 'img' : undefined}
aria-label={label}
onPointerMove={move}
onPointerLeave={leave}
className={cn(sid, 'relative w-full overflow-clip', className)}
style={{ aspectRatio: `${designWidth} / ${designHeight}`, maxWidth: designWidth }}
>
<style dangerouslySetInnerHTML={{ __html: css }} />
<div className="absolute left-0 top-0 origin-top-left" style={{ width: designWidth, height: designHeight, transform: `scale(${scale})` }}>
{items.map((it, i) => {
if (phone && it.hideOnPhone) return null;
const d = it.depth ?? 0.5;
return (
<div
key={i}
className="absolute transition-transform duration-700 ease-[var(--ease-kit)] will-change-transform motion-reduce:!transform-none"
style={{
left: `${it.x}%`,
top: `${it.y}%`,
width: it.w,
zIndex: Math.round(d * 10),
transform: `translate3d(calc(var(--fc-x, 0) * ${round(d * 26)}px), calc(var(--fc-y, 0) * ${round(d * 20)}px + ${parallax ? `(0.5 - var(--kit-progress, 0.5)) * ${round(d * 90)}px` : '0px'}), 0)`,
}}
>
<div className={`${sid}-f`} style={{ '--fc-dur': `${round(5.5 + (i % 3) * 1.3, 1)}s`, '--fc-delay': `${round(-i * 0.9, 1)}s` } as React.CSSProperties}>
<div style={it.rotate ? { transform: `rotate(${it.rotate}deg)` } : undefined}>{it.node}</div>
</div>
</div>
);
})}
</div>
</div>
);
}Frequent questions
How do I install Mockup scenes?
With aiw add @ai-website/mockup-scene. The command puts components/ui/mockup-scene.tsx into your project, the code is yours from then on. It brings along mockup, device-frames, motion and motion-scene. It needs no further npm packages.
Which variants does Mockup scenes have?
8 variants, Desktop and phone, Tablet, laptop and phone, Perspective, Laid back, Scrolling website, Scrolling phone, Scroll on hover and Floating collage.
Can an AI assistant build with Mockup scenes?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Mockup scenes with aiw_components and install it with aiw_add @ai-website/mockup-scene.