Zum Inhalt springen
UI Kit
Accent

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

8 Variants

Variants

Desktop und Telefon

Eine Website auf zwei Bildschirmen. Das Telefon steht vorn und verschiebt sich beim Scrollen leicht gegen die Seite.

Tablet, Laptop und Telefon

Drei Geräte als ein Bild, der Laptop in der Mitte. Die vorderen Geräte bewegen sich beim Scrollen in eigenem Tempo.

Perspektive

Ein Fenster, um wenige Grad gedreht. Beim Hereinscrollen dreht es sich zu dir und folgt der Maus mit weichem Licht.

Zurückgelehnt

Das Bild liegt zurück wie ein Druck auf dem Tisch und richtet sich beim Hereinscrollen zu dir auf.

Scrollende Website

Die Seite scrollt im Fenster in ruhigen Etappen, hält an und gleitet zurück nach oben. Ein schmaler Balken läuft mit.

Scrollendes Telefon

Zwei Telefone nebeneinander. Das linke scrollt von selbst, das rechte folgt deinem Scrollen.

Scrollen beim Zeigen

Die Seite wartet oben und scrollt bis zum Ende, solange du darauf zeigst oder sie mit der Tastatur fokussierst.

Schwebende Collage

Karten aus einem Produkt in Ebenen. Sie schweben leicht, folgen der Maus und dem Scrollen, nahe Ebenen stärker.

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

Props

Die Schnittstelle DeviceEnsembleProps aus components/ui/mockup-scene.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • desktopPflicht
    React.ReactNode

    Content of the large screen

  • phoneoptional
    React.ReactNode

    Content of the phone

  • tabletoptional
    React.ReactNode

    Content 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

  • desktopViewportoptional
    number

    Render widths of the screens, like real devices

  • phoneViewportoptional
    number
  • tabletViewportoptional
    number
  • urloptional
    string

    Address in the browser bar

  • parallaxoptional
    boolean

    The front devices drift a little against the page while it scrolls. Default true.

  • labeloptional
    string

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
components/ui/mockup-scene.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert