Skip to content
Motion
FreeIn reviewv1.0.0

Phone Frame

A phone that shows a mobile layout, scrolls through the full page, and tilts toward the cursor.

npx shadcn@latest add @vibebox/phone-frame

Controls

Scroll
<PhoneFrame />

About

The frame for mobile layouts: phone screenshots in case studies, "how it looks on a phone" moments, and app previews. A graphite phone with a dynamic-island notch holds any content. The screen pans through tall content on hover and keyboard focus (scroll="hover"), or drifts on a loop (scroll="auto"), with CSS alone. With tilt, the phone leans up to 8 degrees toward a mouse or pen and a soft glare follows it. Ported from the VibeBox site's work frames, adding real scrolling and the tilt the catalogue asked for.

Use it when

  • Showing a mobile layout or a mobile screenshot.
  • A desktop frame and a phone appear side by side (pair it with Browser Frame).

Skip it when

  • The content is not a phone screen.
  • Several phones sit in a row with tilt on all of them; tilt one, or none.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/phone-frame
  3. 3Use it:
    import Image from "next/image";
    import { PhoneFrame } from "@/components/vibebox/phone-frame";
    
    <PhoneFrame scroll="hover" tilt label="Northline Dental on a phone" className="w-[300px]">
      <Image src="/work/northline-mobile.webp" alt="Northline Dental mobile home page" width={390} height={4200} />
    </PhoneFrame>

Code

2 files
components/vibebox/phone-frame/index.tsx
/**
 * VibeBox Motion · Phone Frame
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
import type { CSSProperties, ReactNode } from "react";
import { cn } from "@/lib/utils";
import { PhoneTilt } from "./phone-frame.client";

/* A phone to show mobile layouts. The screen can pan tall content on hover or focus, or drift
   on a loop, with CSS alone (the screen is a size container); touch screens and reduced motion
   get a natively scrollable screen. The body depicts hardware, so its radius and graphite
   finish follow the device, not the UI scale; override them with --vb-pf-body. */

const CSS = `
[data-vb-pf-screen] { container-type: size; }
[data-vb-pf-track] { transform: translate3d(0, 0, 0); }
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
  [data-vb-pf-screen][data-scroll="hover"] [data-vb-pf-track] { transition: transform 1.1s var(--ease-expo-out, cubic-bezier(0.16, 1, 0.3, 1)); }
  [data-vb-pf-screen][data-scroll="hover"]:hover [data-vb-pf-track],
  [data-vb-pf-screen][data-scroll="hover"]:focus-within [data-vb-pf-track] {
    transform: translate3d(0, min(0px, calc(100cqh - 100%)), 0);
    transition: transform var(--vb-pf-duration, 7s) var(--ease-expo-in-out, cubic-bezier(0.65, 0, 0.35, 1));
  }
}
@keyframes vb-pf-pan { 0%, 8% { transform: translate3d(0, 0, 0); } 92%, 100% { transform: translate3d(0, min(0px, calc(100cqh - 100%)), 0); } }
@media (prefers-reduced-motion: no-preference) {
  [data-vb-pf-screen][data-scroll="auto"] [data-vb-pf-track] { animation: vb-pf-pan var(--vb-pf-duration, 16s) var(--ease-expo-in-out, cubic-bezier(0.65, 0, 0.35, 1)) infinite alternate; }
}
@media (hover: none), (prefers-reduced-motion: reduce) {
  [data-vb-pf-screen][data-scroll="hover"], [data-vb-pf-screen][data-scroll="auto"] { overflow-y: auto; overscroll-behavior: contain; }
  [data-vb-pf-screen][data-scroll] [data-vb-pf-track] { transform: none; animation: none; }
}
`;

export type PhoneFrameProps = {
  /** What the screen shows: usually a full-page mobile screenshot. */
  children: ReactNode;
  /** Pan tall content: on hover and keyboard focus, on a slow loop, or not at all. */
  scroll?: "none" | "hover" | "auto";
  /** Seconds for one pan from top to bottom. */
  scrollSeconds?: number;
  /** Tilt toward the pointer (mouse and pen only, off with reduced motion). */
  tilt?: boolean;
  /** Screen aspect ratio. */
  ratio?: string;
  /** Accessible name for the device, e.g. "Northline Dental on a phone". */
  label?: string;
  className?: string;
};

export function PhoneFrame({ children, scroll = "none", scrollSeconds, tilt = false, ratio = "9 / 19.5", label, className }: PhoneFrameProps) {
  const pans = scroll !== "none";
  const device = (
    <figure
      data-vb="phone-frame"
      aria-label={label}
      className={cn(
        "relative m-0 rounded-[30px] p-[7px]",
        "shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08),0_40px_80px_-24px_rgba(0,0,0,0.7)]",
        !tilt && className,
      )}
      style={{ background: "var(--vb-pf-body, linear-gradient(160deg, #2a2f3d, #0d1018 40%, #1b1f2b))" }}
    >
      {pans && (
        <style href="vb-phone-frame" precedence="vb">
          {CSS}
        </style>
      )}
      <div
        data-vb-pf-screen=""
        data-scroll={scroll}
        tabIndex={scroll === "hover" ? 0 : undefined}
        className="relative overflow-hidden rounded-[23px] bg-background focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
        style={{ aspectRatio: ratio, ...(scrollSeconds ? ({ "--vb-pf-duration": `${scrollSeconds}s` } as CSSProperties) : null) }}
      >
        <span aria-hidden="true" className="absolute top-3.5 left-1/2 z-10 h-4 w-[28%] -translate-x-1/2 rounded-full bg-[#05070c]" />
        <div data-vb-pf-track="" className="[&_img]:block [&_img]:h-auto [&_img]:w-full">
          {children}
        </div>
      </div>
    </figure>
  );
  return tilt ? (
    <div className={className}>
      <PhoneTilt>{device}</PhoneTilt>
    </div>
  ) : (
    device
  );
}
components/vibebox/phone-frame/phone-frame.client.tsx
/**
 * VibeBox Motion · Phone Frame
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
"use client";

import { LazyMotion, domAnimation, m, useMotionTemplate, useSpring } from "motion/react";
import { useRef, type PointerEvent, type ReactNode } from "react";
import { useFinePointer, useReducedMotion } from "@/lib/vibebox/hooks";

/* Pointer tilt for the phone: up to 8 degrees toward the cursor, with a glare that follows it.
   Mouse and pen only; touch and reduced motion keep the phone flat. */

const MAX_TILT = 8;
const SPRING = { stiffness: 180, damping: 26, mass: 1 };

export function PhoneTilt({ children }: { children: ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const fine = useFinePointer();
  const reduced = useReducedMotion();
  const enabled = fine && !reduced;
  const rotateX = useSpring(0, SPRING);
  const rotateY = useSpring(0, SPRING);
  const glareX = useSpring(50, SPRING);
  const glareY = useSpring(30, SPRING);
  const glare = useSpring(0, SPRING);
  const glareBackground = useMotionTemplate`radial-gradient(circle at ${glareX}% ${glareY}%, rgba(255, 255, 255, 0.45), transparent 55%)`;

  const onMove = (event: PointerEvent<HTMLDivElement>) => {
    if (!enabled || event.pointerType === "touch" || !ref.current) return;
    const rect = ref.current.getBoundingClientRect();
    const x = (event.clientX - rect.left) / rect.width - 0.5;
    const y = (event.clientY - rect.top) / rect.height - 0.5;
    rotateY.set(x * MAX_TILT * 2);
    rotateX.set(-y * MAX_TILT * 2);
    glareX.set((x + 0.5) * 100);
    glareY.set((y + 0.5) * 100);
    glare.set(1);
  };
  const onLeave = () => {
    rotateX.set(0);
    rotateY.set(0);
    glare.set(0);
  };

  return (
    <LazyMotion features={domAnimation} strict>
      <m.div
        ref={ref}
        data-vb-pf-tilt={enabled ? "on" : "off"}
        onPointerMove={onMove}
        onPointerLeave={onLeave}
        className="relative [transform-style:preserve-3d]"
        style={{ rotateX, rotateY, transformPerspective: 900 }}
      >
        {children}
        <m.span
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 rounded-[30px] mix-blend-soft-light"
          style={{ opacity: glare, background: glareBackground }}
        />
      </m.div>
    </LazyMotion>
  );
}

Props

PropTypeDefaultDescription
children *ReactNode—What the screen shows, usually a full-page mobile screenshot.
scroll"none" | "hover" | "auto""none"Pan tall content on hover and focus, on a slow loop, or not at all.
scrollSecondsnumber—Seconds for one pan (7 on hover, 16 on a loop).
tiltbooleanfalseLean toward the pointer (mouse and pen only, off with reduced motion).
ratiostring"9 / 19.5"Screen aspect ratio.
labelstring—Accessible name for the device.
classNamestring—Merged with cn(); set the width with it.

Accessibility and motion

Keyboard
With scroll="hover" the screen takes focus (Tab) and pans while focused.
Screen readers
A figure named by label; give the screenshot its own alt text.
Touch
Touch screens swipe-scroll the screen; tilt is mouse and pen only.
Reduced motion
A flat phone at the top of the page; the screen scrolls natively.

Edge cases and limits

  • Content shorter than the screen never pans.
  • The tilt wrapper takes className, so layout classes still apply when tilt is on.
  • Browsers without container query units show the top of the page only.
  • A browser window showing a business website

    A browser window that frames a site and pans through the full page on hover, on focus, or on a slow loop.

    Devices · Server

↑ ↓ to move↵ to openesc to close