Skip to content
UI Kit
Accent color

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

8 variants

Variants

Desktop and phone

One website on two screens. The phone stands in front and drifts a little against the page while it scrolls.

Tablet, laptop and phone

Three devices as one picture, the laptop in the middle. The front devices move at their own pace when you scroll.

Perspective

A window turned a few degrees. It turns towards you while it scrolls in and follows the mouse with a soft light.

Laid back

The picture lies back like a print on a table and rises towards you while it scrolls into view.

Scrolling website

The page scrolls inside the window in calm steps, rests and glides back up. A thin thumb moves along.

Scrolling phone

Two phones side by side. The left one scrolls on its own, the right one follows your page scroll.

Scroll on hover

The page waits at the top and scrolls to the end while you point at it or focus it with the keyboard.

Floating collage

Cards from a product in layers. They float gently, shift with the mouse and drift with the scroll, near layers more.

Details

What Mockup scenes brings along, read from the registry and the source.

Category
Mockups
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

Props

The DeviceEnsembleProps interface from components/ui/mockup-scene.tsx, with the comments from the code.

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

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
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>
  );
}

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.

Often searched

Jump to

Copied to the clipboard