Skip to content
Motion
FreeIn reviewv1.0.0

Browser Frame

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

npx shadcn@latest add @vibebox/browser-frame

Controls

Scroll
Tone
Window ratio
<BrowserFrame />

About

The frame for showing a website: portfolio entries, case studies, "see the live site" moments and Launchpad previews. A quiet window with three dots and an address bar holds any content, usually a full-page screenshot. With scroll="hover" the window glides from the top of the page to the bottom while a mouse rests on it or keyboard focus is inside; with scroll="auto" it drifts up and down on its own, for heroes. The pan is pure CSS: the window is a size container, so the distance is 100cqh - 100% of the content height, with no JavaScript measuring. Ported from the VibeBox site's work frames, which needed a measuring hook for the same effect.

Use it when

  • Showing a website you built, a template, or a live preview.
  • A case study needs the full page to be reachable, not just the top.

Skip it when

  • The content is an app UI that is not a web page; use a plain card.
  • The screenshot is short (shorter than the window); scrolling does nothing, so use scroll="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/browser-frame
  3. 3Use it:
    import Image from "next/image";
    import { BrowserFrame } from "@/components/vibebox/browser-frame";
    
    <BrowserFrame url="northline-dental.ph" scroll="hover" className="w-full max-w-4xl">
      <Image src="/work/northline-full.webp" alt="Northline Dental home page" width={1440} height={6200} />
    </BrowserFrame>

Code

1 files
components/vibebox/browser-frame/index.tsx
/**
 * VibeBox Motion · Browser 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";

/* A browser window to hang work in. The content window can pan tall content on hover or focus,
   or drift through it on a loop, with CSS alone: the window is a size container, so the pan
   distance is calc(100cqh - 100%) of the content's own height. Touch screens and reduced motion
   get a natively scrollable window instead, so the whole page is always reachable. */

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

export type BrowserFrameProps = {
  /** Shown in the address bar. */
  url: string;
  /** What the window shows: usually a full-page screenshot (an img or next/image). */
  children: ReactNode;
  /** "auto" follows the page's tokens; "light" or "dark" forces that look. */
  tone?: "auto" | "light" | "dark";
  /** 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;
  /** Aspect ratio of the content window, e.g. "16 / 10". */
  ratio?: string;
  className?: string;
};

function Lock() {
  return (
    <svg width="9" height="10" viewBox="0 0 9 10" fill="none" aria-hidden="true" className="shrink-0">
      <rect x="0.75" y="4.25" width="7.5" height="5" rx="1.2" stroke="currentColor" strokeWidth="1.1" />
      <path d="M2.4 4.2V3a2.1 2.1 0 1 1 4.2 0v1.2" stroke="currentColor" strokeWidth="1.1" />
    </svg>
  );
}

export function BrowserFrame({ url, children, tone = "auto", scroll = "none", scrollSeconds, ratio = "16 / 10", className }: BrowserFrameProps) {
  const pans = scroll !== "none";
  return (
    <figure
      data-vb="browser-frame"
      className={cn(
        "relative m-0 overflow-hidden rounded-xl border border-border-strong bg-card text-card-foreground",
        "shadow-[inset_0_1px_0_color-mix(in_oklab,var(--foreground)_6%,transparent),0_40px_90px_-30px_rgba(0,0,0,0.65)]",
        tone === "dark" && "dark",
        tone === "light" && "light",
        className,
      )}
    >
      {pans && (
        <style href="vb-browser-frame" precedence="vb">
          {CSS}
        </style>
      )}
      <div aria-hidden="true" className="grid h-9 grid-cols-[60px_1fr_60px] items-center border-b border-border bg-muted px-3">
        <span className="flex gap-1.5">
          <span className="size-2 rounded-full bg-foreground/20" />
          <span className="size-2 rounded-full bg-foreground/20" />
          <span className="size-2 rounded-full bg-foreground/20" />
        </span>
        <span className="inline-flex h-[22px] max-w-full items-center gap-1.5 justify-self-center overflow-hidden rounded-md bg-foreground/5 px-3 font-mono text-[11px] text-ellipsis whitespace-nowrap text-muted-foreground">
          <Lock />
          {url}
        </span>
        <span />
      </div>
      <div
        data-vb-bf-window=""
        data-scroll={scroll}
        tabIndex={scroll === "hover" ? 0 : undefined}
        className="relative overflow-hidden bg-background focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
        style={{ aspectRatio: ratio, ...(scrollSeconds ? ({ "--vb-bf-duration": `${scrollSeconds}s` } as CSSProperties) : null) }}
      >
        <div data-vb-bf-track="" className="[&_img]:block [&_img]:h-auto [&_img]:w-full">
          {children}
        </div>
      </div>
    </figure>
  );
}

Props

PropTypeDefaultDescription
url *string—Shown in the address bar.
children *ReactNode—What the window shows, usually a full-page screenshot.
tone"auto" | "light" | "dark""auto"Follow the page's tokens or force a look.
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 from top to bottom (8 on hover, 18 on a loop).
ratiostring"16 / 10"Aspect ratio of the content window.
classNamestring—Merged with cn().

Accessibility and motion

Keyboard
With scroll="hover" the window takes focus (Tab) and pans while focused; links inside stay in the tab order.
Screen readers
A figure; the address bar is decorative. Give the content its own alt text.
Touch
On touch screens the window scrolls with a swipe instead of panning on hover.
Reduced motion
A still window at the top of the page; the window scrolls natively.

Edge cases and limits

  • Content shorter than the window never pans (min(0px, …)).
  • Very tall pages pan faster than they can be read; raise scrollSeconds.
  • Browsers without container query units (before 2023) show the top of the page only.
  • A graphite phone showing a mobile website

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

    Devices · Motion

↑ ↓ to move↵ to openesc to close