Mockup-Szenen
Geräte, die sich überlagern, Fenster in ruhiger Perspektive, Seiten, die im Gerät scrollen, und schwebende Kartencollagen.
Eine von 2 Components in Mockups
Variants
Details
Was Mockup-Szenen mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Mockups
- Bereich
- Visuals
- Variants
- 8
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/mockup-scene.tsx
- Bringt mit
- utilskitmockupdevice-framesmotionmotion-scene
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle DeviceEnsembleProps aus components/ui/mockup-scene.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
desktopPflichtReact.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
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle label.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 8 Stellen im Code schalten Bewegung dann ab.
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 387 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Mockup-Szenen?
Mit aiw add @ai-website/mockup-scene. Der Befehl legt die Datei components/ui/mockup-scene.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind mockup, device-frames, motion und motion-scene. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Mockup-Szenen?
8 Varianten, Desktop und Telefon, Tablet, Laptop und Telefon, Perspektive, Zurückgelehnt, Scrollende Website, Scrollendes Telefon, Scrollen beim Zeigen und Schwebende Collage.
Kann ein KI-Assistent Mockup-Szenen einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Mockup-Szenen mit aiw_components und installieren sie mit aiw_add @ai-website/mockup-scene.